• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

smallnest / goclaw / 21978860214

13 Feb 2026 07:45AM UTC coverage: 5.772% (+0.008%) from 5.764%
21978860214

push

github

chaoyuepan
improve web fetch

4 of 24 new or added lines in 10 files covered. (16.67%)

221 existing lines in 4 files now uncovered.

1517 of 26284 relevant lines covered (5.77%)

0.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

33.2
/skills/install.go
1
package skills
2

3
import (
4
        "context"
5
        "fmt"
6
        "os"
7
        "os/exec"
8
        "path/filepath"
9
        "strings"
10
        "time"
11

12
        "github.com/smallnest/goclaw/internal/logger"
13
        "go.uber.org/zap"
14
)
15

16
// installation timeout limits
17
const (
18
        MinInstallTimeout = 1 * time.Second
19
        MaxInstallTimeout = 15 * time.Minute
20
)
21

22
// InstallRequest represents a skill installation request
23
type InstallRequest struct {
24
        WorkspaceDir string
25
        SkillName    string
26
        InstallID    string
27
        Timeout      time.Duration
28
        Config       *SkillsConfig
29
}
30

31
// InstallResult represents the result of a skill installation
32
type InstallResult struct {
33
        Success   bool
34
        Message   string
35
        Stdout    string
36
        Stderr    string
37
        ExitCode  *int
38
        Warnings  []string
39
        Installed []string // Installed binaries
40
}
41

42
// Installer is the interface for installing skills
43
type Installer interface {
44
        // Install installs a skill using this installer
45
        Install(ctx context.Context, spec *SkillInstallSpec) InstallResult
46

47
        // CanInstall checks if this installer can be used on the current platform
48
        CanInstall(spec *SkillInstallSpec) bool
49
}
50

51
// WithWarnings adds warnings to an install result and returns a new result
52
func WithWarnings(result InstallResult, warnings []string) InstallResult {
1✔
53
        if len(warnings) == 0 {
1✔
54
                return result
×
55
        }
×
56
        // Copy the result and add warnings
57
        result.Warnings = make([]string, len(warnings))
1✔
58
        copy(result.Warnings, warnings)
1✔
59
        return result
1✔
60
}
61

62
// SummarizeInstallOutput extracts a concise summary from install output
63
func SummarizeInstallOutput(text string) string {
9✔
64
        lines := strings.Split(strings.TrimSpace(text), "\n")
9✔
65
        var nonEmptyLines []string
9✔
66
        for _, line := range lines {
24✔
67
                line = strings.TrimSpace(line)
15✔
68
                if line != "" {
28✔
69
                        nonEmptyLines = append(nonEmptyLines, line)
13✔
70
                }
13✔
71
        }
72

73
        if len(nonEmptyLines) == 0 {
11✔
74
                return ""
2✔
75
        }
2✔
76

77
        // Look for error lines
78
        for _, line := range nonEmptyLines {
18✔
79
                if strings.HasPrefix(strings.ToLower(line), "error") ||
11✔
80
                        strings.Contains(strings.ToLower(line), "err!") ||
11✔
81
                        strings.Contains(strings.ToLower(line), "error:") ||
11✔
82
                        strings.Contains(strings.ToLower(line), "failed") {
15✔
83
                        return line
4✔
84
                }
4✔
85
        }
86

87
        // Return last line as summary
88
        lastLine := nonEmptyLines[len(nonEmptyLines)-1]
3✔
89
        maxLen := 200
3✔
90
        if len(lastLine) > maxLen {
3✔
91
                return lastLine[:maxLen-1] + "…"
×
92
        }
×
93
        return lastLine
3✔
94
}
95

96
// FormatInstallFailureMessage formats a failure message from a command result
97
func FormatInstallFailureMessage(stdout, stderr string, code *int) string {
3✔
98
        exitInfo := "unknown exit"
3✔
99
        if code != nil {
5✔
100
                exitInfo = fmt.Sprintf("exit %d", *code)
2✔
101
        }
2✔
102

103
        summary := SummarizeInstallOutput(stderr)
3✔
104
        if summary == "" {
4✔
105
                summary = SummarizeInstallOutput(stdout)
1✔
106
        }
1✔
107

108
        if summary == "" {
3✔
109
                return fmt.Sprintf("Install failed (%s)", exitInfo)
×
110
        }
×
111
        return fmt.Sprintf("Install failed (%s): %s", exitInfo, summary)
3✔
112
}
113

114
// FindInstallSpec finds an install spec by ID in a skill entry
115
func FindInstallSpec(entry *SkillEntry, installID string) *SkillInstallSpec {
3✔
116
        if entry.Metadata == nil || len(entry.Metadata.Install) == 0 {
3✔
117
                return nil
×
118
        }
×
119

120
        for index, spec := range entry.Metadata.Install {
8✔
121
                specID := spec.ID
5✔
122
                if specID == "" {
8✔
123
                        specID = fmt.Sprintf("%s-%d", spec.Kind, index)
3✔
124
                }
3✔
125
                if specID == installID {
7✔
126
                        return &spec
2✔
127
                }
2✔
128
        }
129
        return nil
1✔
130
}
131

132
// ResolveInstallPreferences resolves installation preferences from config
133
func ResolveInstallPreferences(config *SkillsConfig) InstallConfig {
2✔
134
        if config == nil {
3✔
135
                return InstallConfig{
1✔
136
                        PreferBrew:  true,
1✔
137
                        NodeManager: "npm",
1✔
138
                }
1✔
139
        }
1✔
140
        return config.Install
1✔
141
}
142

143
// GetInstaller returns the appropriate installer for a spec
144
func GetInstaller(spec *SkillInstallSpec, prefs InstallConfig) (Installer, error) {
6✔
145
        switch spec.Kind {
6✔
146
        case "brew":
1✔
147
                return &BrewInstaller{}, nil
1✔
148
        case "node":
1✔
149
                return &NodeInstaller{NodeManager: prefs.NodeManager}, nil
1✔
150
        case "go":
1✔
151
                return &GoInstaller{}, nil
1✔
152
        case "uv":
1✔
153
                return &UVInstaller{}, nil
1✔
154
        case "download":
1✔
155
                return &DownloadInstaller{}, nil
1✔
156
        default:
1✔
157
                return nil, fmt.Errorf("unsupported installer kind: %s", spec.Kind)
1✔
158
        }
159
}
160

161
// InstallSkill installs a skill using the specified install ID
162
func InstallSkill(ctx context.Context, req InstallRequest) (*InstallResult, error) {
×
163
        // Validate request
×
164
        if req.WorkspaceDir == "" {
×
165
                return nil, fmt.Errorf("workspace directory is required")
×
166
        }
×
167
        if req.SkillName == "" {
×
168
                return nil, fmt.Errorf("skill name is required")
×
169
        }
×
170
        if req.InstallID == "" {
×
171
                return nil, fmt.Errorf("install ID is required")
×
172
        }
×
173

174
        // Resolve timeout
175
        timeout := req.Timeout
×
176
        if timeout == 0 {
×
177
                timeout = time.Duration(DefaultInstallTimeout) * time.Second
×
178
        }
×
179
        if timeout < MinInstallTimeout {
×
180
                timeout = MinInstallTimeout
×
181
        }
×
182
        if timeout > MaxInstallTimeout {
×
183
                timeout = MaxInstallTimeout
×
184
        }
×
185

186
        // Create context with timeout
187
        if ctx == nil {
×
188
                ctx = context.Background()
×
189
        }
×
NEW
190
        ctx, cancel := context.WithTimeout(ctx, timeout)
×
NEW
191
        defer cancel()
×
UNCOV
192

×
UNCOV
193
        // Load skill entries
×
194
        entries, err := LoadSkillEntries(req.WorkspaceDir, LoadSkillsOptions{
×
195
                IncludeDefaults: true,
×
196
        })
×
197
        if err != nil {
×
198
                return nil, fmt.Errorf("failed to load skills: %w", err)
×
199
        }
×
200

201
        // Find the skill entry
202
        var entry *SkillEntry
×
203
        for _, e := range entries {
×
204
                if e.Skill.Name == req.SkillName {
×
205
                        entry = e
×
206
                        break
×
207
                }
208
        }
209
        if entry == nil {
×
210
                return &InstallResult{
×
211
                        Success: false,
×
212
                        Message: fmt.Sprintf("Skill not found: %s", req.SkillName),
×
213
                }, nil
×
214
        }
×
215

216
        // Find install spec
217
        spec := FindInstallSpec(entry, req.InstallID)
×
218
        warnings := checkSkillSecurity(entry)
×
219

×
220
        if spec == nil {
×
221
                result := InstallResult{
×
222
                        Success: false,
×
223
                        Message: fmt.Sprintf("Installer not found: %s", req.InstallID),
×
224
                }
×
225
                res := WithWarnings(result, warnings)
×
226
                return &res, nil
×
227
        }
×
228

229
        // Get installer
230
        prefs := ResolveInstallPreferences(req.Config)
×
231
        installer, err := GetInstaller(spec, prefs)
×
232
        if err != nil {
×
233
                res := WithWarnings(InstallResult{
×
234
                        Success: false,
×
235
                        Message: err.Error(),
×
236
                }, warnings)
×
237
                return &res, nil
×
238
        }
×
239

240
        // Check if installer can be used
241
        if !installer.CanInstall(spec) {
×
242
                res := WithWarnings(InstallResult{
×
243
                        Success: false,
×
244
                        Message: fmt.Sprintf("Installer not available: %s", spec.Kind),
×
245
                }, warnings)
×
246
                return &res, nil
×
247
        }
×
248

249
        // Run installation
250
        logger.Info("Installing skill",
×
251
                zap.String("skill", req.SkillName),
×
252
                zap.String("installer", spec.Kind),
×
253
                zap.String("installID", req.InstallID),
×
254
        )
×
255

×
256
        result := installer.Install(ctx, spec)
×
257

×
258
        // Add any binaries that were installed
×
259
        if result.Success && len(spec.Bins) > 0 {
×
260
                result.Installed = detectInstalledBinaries(spec.Bins)
×
261
        }
×
262

263
        res := WithWarnings(result, warnings)
×
264
        return &res, nil
×
265
}
266

267
// RunCommandWithTimeout runs a command with timeout
268
func RunCommandWithTimeout(ctx context.Context, argv []string, env map[string]string) (stdout, stderr string, code *int, err error) {
×
269
        if len(argv) == 0 {
×
270
                return "", "", nil, fmt.Errorf("empty command")
×
271
        }
×
272

273
        cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
×
274

×
275
        // Add environment variables
×
276
        if env != nil {
×
277
                cmd.Env = append(os.Environ(), envSlice(env)...)
×
278
        }
×
279

280
        // Capture output
281
        var stdoutBuf, stderrBuf strings.Builder
×
282
        cmd.Stdout = &stdoutBuf
×
283
        cmd.Stderr = &stderrBuf
×
284

×
285
        // Run command
×
286
        err = cmd.Run()
×
287

×
288
        // Get exit code
×
289
        var exitCode *int
×
290
        if cmd.ProcessState != nil {
×
291
                exitCodeVal := cmd.ProcessState.ExitCode()
×
292
                exitCode = &exitCodeVal
×
293
        }
×
294

295
        // Check if command failed
296
        if err != nil {
×
297
                if ctx.Err() == context.DeadlineExceeded {
×
298
                        return stdoutBuf.String(), stderrBuf.String(), exitCode,
×
299
                                fmt.Errorf("command timed out after %v", deadlineExceeded(ctx))
×
300
                }
×
301
                return stdoutBuf.String(), stderrBuf.String(), exitCode,
×
302
                        fmt.Errorf("command failed: %w", err)
×
303
        }
304

305
        return stdoutBuf.String(), stderrBuf.String(), exitCode, nil
×
306
}
307

308
// envSlice converts env map to slice of "key=value" strings
309
func envSlice(env map[string]string) []string {
×
310
        result := make([]string, 0, len(env))
×
311
        for k, v := range env {
×
312
                result = append(result, fmt.Sprintf("%s=%s", k, v))
×
313
        }
×
314
        return result
×
315
}
316

317
// deadlineExceeded extracts the remaining deadline from context
318
func deadlineExceeded(ctx context.Context) time.Duration {
×
319
        if deadline, ok := ctx.Deadline(); ok {
×
320
                return time.Until(deadline)
×
321
        }
×
322
        return 0
×
323
}
324

325
// HasBinary checks if a binary is available on PATH
326
func HasBinary(bin string) bool {
10✔
327
        path, err := exec.LookPath(bin)
10✔
328
        return err == nil && path != ""
10✔
329
}
10✔
330

331
// checkSkillSecurity performs simple security checks on a skill
332
func checkSkillSecurity(entry *SkillEntry) []string {
×
333
        var warnings []string
×
334

×
335
        // Check if skill has dependencies that require binaries or environment variables
×
336
        if entry.Metadata != nil && entry.Metadata.Requires != nil {
×
337
                if len(entry.Metadata.Requires.Bins) > 0 || len(entry.Metadata.Requires.AnyBins) > 0 {
×
338
                        // This requires binary dependencies - user should verify trust
×
339
                        warnings = append(warnings,
×
340
                                fmt.Sprintf("Skill \"%s\" requires binary dependencies. "+
×
341
                                        "Verify the skill source before installing.", entry.Skill.Name))
×
342
                }
×
343
                if len(entry.Metadata.Requires.Env) > 0 {
×
344
                        warnings = append(warnings,
×
345
                                fmt.Sprintf("Skill \"%s\" requires environment variables. "+
×
346
                                        "Check the skill documentation for required setup.", entry.Skill.Name))
×
347
                }
×
348
        }
349

350
        return warnings
×
351
}
352

353
// detectInstalledBinaries checks which of the specified binaries are now available
354
func detectInstalledBinaries(bins []string) []string {
2✔
355
        var installed []string
2✔
356
        for _, bin := range bins {
6✔
357
                if HasBinary(bin) {
7✔
358
                        installed = append(installed, bin)
3✔
359
                }
3✔
360
        }
361
        return installed
2✔
362
}
363

364
// ResolveUserPath expands user home directory (~) in paths
365
func ResolveUserPath(path string) string {
×
366
        if strings.HasPrefix(path, "~") {
×
367
                home, err := os.UserHomeDir()
×
368
                if err == nil {
×
369
                        return filepath.Join(home, strings.TrimPrefix(path, "~"))
×
370
                }
×
371
        }
372
        // Expand environment variables
373
        return os.ExpandEnv(path)
×
374
}
375

376
// EnsureDir creates directory if it doesn't exist
377
func EnsureDir(dir string) error {
×
378
        if _, err := os.Stat(dir); os.IsNotExist(err) {
×
379
                return os.MkdirAll(dir, 0755)
×
380
        }
×
381
        return nil
×
382
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc