blob: 72c4eccc608feb7d47596b73ec67537e3e8acd84 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package claudetool
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -07008 "io"
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00009 "log/slog"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "math"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000011 "os"
Earl Lee2e463fb2025-04-17 11:22:22 -070012 "os/exec"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000013 "path/filepath"
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -070014 "slices"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "strings"
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000016 "sync"
Earl Lee2e463fb2025-04-17 11:22:22 -070017 "syscall"
18 "time"
19
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "sketch.dev/claudetool/bashkit"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070021 "sketch.dev/llm"
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000022 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070023)
24
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000025// PermissionCallback is a function type for checking if a command is allowed to run
26type PermissionCallback func(command string) error
27
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -070028// BashTool specifies an llm.Tool for executing shell commands.
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000029type BashTool struct {
30 // CheckPermission is called before running any command, if set
31 CheckPermission PermissionCallback
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000032 // EnableJITInstall enables just-in-time tool installation for missing commands
33 EnableJITInstall bool
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000034 // Timeouts holds the configurable timeout values (uses defaults if nil)
35 Timeouts *Timeouts
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -070036 // Pwd is the working directory for the tool
37 Pwd string
Earl Lee2e463fb2025-04-17 11:22:22 -070038}
39
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000040const (
41 EnableBashToolJITInstall = true
42 NoBashToolJITInstall = false
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000043
44 DefaultFastTimeout = 30 * time.Second
45 DefaultSlowTimeout = 15 * time.Minute
46 DefaultBackgroundTimeout = 24 * time.Hour
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +000047)
48
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000049// Timeouts holds the configurable timeout values for bash commands.
50type Timeouts struct {
51 Fast time.Duration // regular commands (e.g., ls, echo, simple scripts)
52 Slow time.Duration // commands that may reasonably take longer (e.g., downloads, builds, tests)
53 Background time.Duration // background commands (e.g., servers, long-running processes)
54}
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000055
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000056// Fast returns t's fast timeout, or DefaultFastTimeout if t is nil.
57func (t *Timeouts) fast() time.Duration {
58 if t == nil {
59 return DefaultFastTimeout
60 }
61 return t.Fast
62}
63
64// Slow returns t's slow timeout, or DefaultSlowTimeout if t is nil.
65func (t *Timeouts) slow() time.Duration {
66 if t == nil {
67 return DefaultSlowTimeout
68 }
69 return t.Slow
70}
71
72// Background returns t's background timeout, or DefaultBackgroundTimeout if t is nil.
73func (t *Timeouts) background() time.Duration {
74 if t == nil {
75 return DefaultBackgroundTimeout
76 }
77 return t.Background
78}
79
80// Tool returns an llm.Tool based on b.
81func (b *BashTool) Tool() *llm.Tool {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070082 return &llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000083 Name: bashName,
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -070084 Description: fmt.Sprintf(strings.TrimSpace(bashDescription), b.Pwd),
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070085 InputSchema: llm.MustSchema(bashInputSchema),
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000086 Run: b.Run,
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000087 }
88}
89
Earl Lee2e463fb2025-04-17 11:22:22 -070090const (
91 bashName = "bash"
92 bashDescription = `
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000093Executes shell commands via bash -c, returning combined stdout/stderr.
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -070094Bash state changes (working dir, variables, aliases) don't persist between calls.
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000095
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -070096With background=true, returns immediately, with output redirected to a file.
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000097Use background for servers/demos that need to stay running.
98
99MUST set slow_ok=true for potentially slow commands: builds, downloads,
100installs, tests, or any other substantive operation.
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700101
102<pwd>%s</pwd>
Earl Lee2e463fb2025-04-17 11:22:22 -0700103`
104 // If you modify this, update the termui template for prettier rendering.
105 bashInputSchema = `
106{
107 "type": "object",
108 "required": ["command"],
109 "properties": {
110 "command": {
111 "type": "string",
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000112 "description": "Shell to execute"
Earl Lee2e463fb2025-04-17 11:22:22 -0700113 },
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000114 "slow_ok": {
115 "type": "boolean",
116 "description": "Use extended timeout"
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000117 },
118 "background": {
119 "type": "boolean",
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000120 "description": "Execute in background"
Earl Lee2e463fb2025-04-17 11:22:22 -0700121 }
122 }
123}
124`
125)
126
127type bashInput struct {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000128 Command string `json:"command"`
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000129 SlowOK bool `json:"slow_ok,omitempty"`
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000130 Background bool `json:"background,omitempty"`
131}
132
133type BackgroundResult struct {
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700134 PID int
135 OutFile string
136}
137
138func (r *BackgroundResult) XMLish() string {
139 return fmt.Sprintf("<pid>%d</pid>\n<output_file>%s</output_file>\n<reminder>To stop the process: `kill -9 -%d`</reminder>\n",
140 r.PID, r.OutFile, r.PID)
Earl Lee2e463fb2025-04-17 11:22:22 -0700141}
142
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000143func (i *bashInput) timeout(t *Timeouts) time.Duration {
144 switch {
145 case i.Background:
146 return t.background()
147 case i.SlowOK:
148 return t.slow()
149 default:
150 return t.fast()
Earl Lee2e463fb2025-04-17 11:22:22 -0700151 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700152}
153
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700154func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
Earl Lee2e463fb2025-04-17 11:22:22 -0700155 var req bashInput
156 if err := json.Unmarshal(m, &req); err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700157 return llm.ErrorfToolOut("failed to unmarshal bash command input: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700158 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000159
Earl Lee2e463fb2025-04-17 11:22:22 -0700160 // do a quick permissions check (NOT a security barrier)
161 err := bashkit.Check(req.Command)
162 if err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700163 return llm.ErrorToolOut(err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700164 }
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000165
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000166 // Custom permission callback if set
167 if b.CheckPermission != nil {
168 if err := b.CheckPermission(req.Command); err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700169 return llm.ErrorToolOut(err)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000170 }
171 }
172
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000173 // Check for missing tools and try to install them if needed, best effort only
174 if b.EnableJITInstall {
175 err := b.checkAndInstallMissingTools(ctx, req.Command)
176 if err != nil {
177 slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err)
178 }
179 }
180
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000181 timeout := req.timeout(b.Timeouts)
182
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000183 // If Background is set to true, use executeBackgroundBash
184 if req.Background {
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700185 result, err := b.executeBackgroundBash(ctx, req, timeout)
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000186 if err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700187 return llm.ErrorToolOut(err)
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000188 }
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700189 return llm.ToolOut{LLMContent: llm.TextContent(result.XMLish())}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000190 }
191
192 // For foreground commands, use executeBash
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700193 out, execErr := b.executeBash(ctx, req, timeout)
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700194 if execErr != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700195 return llm.ErrorToolOut(execErr)
Earl Lee2e463fb2025-04-17 11:22:22 -0700196 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700197 return llm.ToolOut{LLMContent: llm.TextContent(out)}
Earl Lee2e463fb2025-04-17 11:22:22 -0700198}
199
200const maxBashOutputLength = 131072
201
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700202func (b *BashTool) makeBashCommand(ctx context.Context, command string, out io.Writer) *exec.Cmd {
203 cmd := exec.CommandContext(ctx, "bash", "-c", command)
204 cmd.Dir = b.Pwd
205 cmd.Stdin = nil
206 cmd.Stdout = out
207 cmd.Stderr = out
208 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // set up for killing the process group
209 cmd.Cancel = func() error {
210 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // kill entire process group
211 }
212 // Remove SKETCH_MODEL_URL, SKETCH_PUB_KEY, SKETCH_MODEL_API_KEY,
213 // and any other future SKETCH_ goodies from the environment.
214 // ...except for SKETCH_PROXY_ID, which is intentionally available.
215 env := slices.DeleteFunc(os.Environ(), func(s string) bool {
216 return strings.HasPrefix(s, "SKETCH_") && s != "SKETCH_PROXY_ID"
217 })
218 env = append(env, "SKETCH=1") // signal that this has been run by Sketch, sometimes useful for scripts
219 env = append(env, "EDITOR=/bin/false") // interactive editors won't work
220 cmd.Env = env
221 return cmd
222}
223
224// processGroupHasProcesses reports whether process group pgid contains any processes.
225func processGroupHasProcesses(pgid int) bool {
226 return syscall.Kill(-pgid, 0) == nil
227}
228
229func cmdWait(cmd *exec.Cmd) error {
230 pgid := cmd.Process.Pid
231 err := cmd.Wait()
232 // After Wait, if there were misbehaved children,
233 // and the process group is still around,
234 // (if the process died via some means other than context cancellation),
235 // we need to (re-)kill the process group, not just the process.
236 // Tiny logical race here--we could snipe some other process--but
237 // it is extremely unlikely in practice, because our process just died,
238 // so it almost certainly hasn't had its PID recycled yet.
239 if processGroupHasProcesses(pgid) {
240 _ = syscall.Kill(-pgid, syscall.SIGKILL)
241 }
242
243 // Clean up any zombie processes that may have been left behind.
244 reapZombies(pgid)
245
246 return err
247}
248
249func (b *BashTool) executeBash(ctx context.Context, req bashInput, timeout time.Duration) (string, error) {
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +0000250 execCtx, cancel := context.WithTimeout(ctx, timeout)
Earl Lee2e463fb2025-04-17 11:22:22 -0700251 defer cancel()
252
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700253 output := new(bytes.Buffer)
254 cmd := b.makeBashCommand(execCtx, req.Command, output)
255 // TODO: maybe detect simple interactive git rebase commands and auto-background them?
256 // Would need to hint to the agent what is happening.
257 // We might also be able to do this for other simple interactive commands that use EDITOR.
258 cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=echo "To do an interactive rebase, run it as a background task and check the output file." && exit 1`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700259 if err := cmd.Start(); err != nil {
260 return "", fmt.Errorf("command failed: %w", err)
261 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700262
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700263 err := cmdWait(cmd)
Earl Lee2e463fb2025-04-17 11:22:22 -0700264
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700265 out := output.String()
266 out = formatForegroundBashOutput(out)
Earl Lee2e463fb2025-04-17 11:22:22 -0700267
Philip Zeyliger8a1b89a2025-05-13 17:58:41 -0700268 if execCtx.Err() == context.DeadlineExceeded {
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700269 return "", fmt.Errorf("[command timed out after %s, showing output until timeout]\n%s", timeout, out)
Philip Zeyliger8a1b89a2025-05-13 17:58:41 -0700270 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700271 if err != nil {
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700272 return "", fmt.Errorf("[command failed: %w]\n%s", err, out)
Earl Lee2e463fb2025-04-17 11:22:22 -0700273 }
274
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700275 return out, nil
276}
Earl Lee2e463fb2025-04-17 11:22:22 -0700277
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700278// formatForegroundBashOutput formats the output of a foreground bash command for display to the agent.
279func formatForegroundBashOutput(out string) string {
280 if len(out) > maxBashOutputLength {
281 const snipSize = 4096
282 out = fmt.Sprintf("[output truncated in middle: got %v, max is %v]\n%s\n\n[snip]\n\n%s",
283 humanizeBytes(len(out)), humanizeBytes(maxBashOutputLength),
284 out[:snipSize], out[len(out)-snipSize:],
285 )
286 }
287 return out
Earl Lee2e463fb2025-04-17 11:22:22 -0700288}
289
290func humanizeBytes(bytes int) string {
291 switch {
292 case bytes < 4*1024:
293 return fmt.Sprintf("%dB", bytes)
294 case bytes < 1024*1024:
295 kb := int(math.Round(float64(bytes) / 1024.0))
296 return fmt.Sprintf("%dkB", kb)
297 case bytes < 1024*1024*1024:
298 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
299 return fmt.Sprintf("%dMB", mb)
300 }
301 return "more than 1GB"
302}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000303
304// executeBackgroundBash executes a command in the background and returns the pid and output file locations
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700305func (b *BashTool) executeBackgroundBash(ctx context.Context, req bashInput, timeout time.Duration) (*BackgroundResult, error) {
306 // Create temp output files
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000307 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
308 if err != nil {
309 return nil, fmt.Errorf("failed to create temp directory: %w", err)
310 }
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700311 // We can't really clean up tempDir, because we have no idea
312 // how far into the future the agent might want to read the output.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000313
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700314 outFile := filepath.Join(tmpDir, "output")
315 out, err := os.Create(outFile)
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000316 if err != nil {
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700317 return nil, fmt.Errorf("failed to create output file: %w", err)
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000318 }
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000319
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700320 execCtx, cancel := context.WithTimeout(context.Background(), timeout) // detach from tool use context
321 cmd := b.makeBashCommand(execCtx, req.Command, out)
322 cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=python3 -c "import os, sys, signal, threading; print(f\"Send USR1 to pid {os.getpid()} after editing {sys.argv[1]}\", flush=True); signal.signal(signal.SIGUSR1, lambda *_: sys.exit(0)); threading.Event().wait()"`)
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000323
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000324 if err := cmd.Start(); err != nil {
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700325 cancel()
326 out.Close()
327 os.RemoveAll(tmpDir) // clean up temp dir -- didn't start means we don't need the output
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000328 return nil, fmt.Errorf("failed to start background command: %w", err)
329 }
330
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700331 // Wait for completion in the background, then do cleanup.
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000332 go func() {
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700333 err := cmdWait(cmd)
334 // Leave a note to the agent so that it knows that the process has finished.
335 if err != nil {
336 fmt.Fprintf(out, "\n\n[background process failed: %v]\n", err)
337 } else {
338 fmt.Fprintf(out, "\n\n[background process completed]\n")
339 }
340 out.Close()
341 cancel()
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000342 }()
343
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000344 return &BackgroundResult{
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700345 PID: cmd.Process.Pid,
346 OutFile: outFile,
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000347 }, nil
348}
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000349
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000350// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
351func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
352 commands, err := bashkit.ExtractCommands(command)
353 if err != nil {
354 return err
355 }
356
357 autoInstallMu.Lock()
358 defer autoInstallMu.Unlock()
359
360 var missing []string
361 for _, cmd := range commands {
362 if doNotAttemptToolInstall[cmd] {
363 continue
364 }
365 _, err := exec.LookPath(cmd)
366 if err == nil {
367 doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
368 continue
369 }
370 missing = append(missing, cmd)
371 }
372
Josh Bleecher Snyder855afff2025-05-30 08:47:47 -0700373 if len(missing) == 0 {
374 return nil
375 }
376
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000377 for _, cmd := range missing {
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700378 err := b.installTool(ctx, cmd)
379 if err != nil {
380 slog.WarnContext(ctx, "failed to install tool", "tool", cmd, "error", err)
381 }
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000382 doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
383 }
384 return nil
385}
386
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000387// Command safety check cache to avoid repeated LLM calls
388var (
389 autoInstallMu sync.Mutex
390 doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
391)
392
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700393// autodetectPackageManager returns the first package‑manager binary
394// found in PATH, or an empty string if none are present.
395func autodetectPackageManager() string {
396 // TODO: cache this result with a sync.OnceValue
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000397
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700398 managers := []string{
399 "apt", "apt-get", // Debian/Ubuntu
400 "brew", "port", // macOS (Homebrew / MacPorts)
401 "apk", // Alpine
402 "yum", "dnf", // RHEL/Fedora
403 "pacman", // Arch
404 "zypper", // openSUSE
405 "xbps-install", // Void
406 "emerge", // Gentoo
407 "nix-env", "guix", // NixOS / Guix
408 "pkg", // FreeBSD
409 "slackpkg", // Slackware
410 }
411
412 for _, m := range managers {
413 if _, err := exec.LookPath(m); err == nil {
414 return m
415 }
416 }
417 return ""
418}
419
420// installTool attempts to install a single missing tool using LLM validation and system package manager.
421func (b *BashTool) installTool(ctx context.Context, cmd string) error {
422 slog.InfoContext(ctx, "attempting to install tool", "tool", cmd)
423
424 packageManager := autodetectPackageManager()
425 if packageManager == "" {
426 return fmt.Errorf("no known package manager found in PATH")
427 }
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000428 info := conversation.ToolCallInfoFromContext(ctx)
429 if info.Convo == nil {
430 return fmt.Errorf("no conversation context available for tool installation")
431 }
432 subConvo := info.Convo.SubConvo()
433 subConvo.Hidden = true
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700434 subConvo.SystemPrompt = "You are an expert in software developer tools."
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000435
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700436 query := fmt.Sprintf(`Do you know this command/package/tool? Is it legitimate, clearly non-harmful, and commonly used? Can it be installed with package manager %s?
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000437
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700438Command: %s
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000439
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700440- YES: Respond ONLY with the package name used to install it
441- NO or UNSURE: Respond ONLY with the word NO`, packageManager, cmd)
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000442
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700443 resp, err := subConvo.SendUserTextMessage(query)
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000444 if err != nil {
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700445 return fmt.Errorf("failed to validate tool with LLM: %w", err)
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000446 }
447
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700448 if len(resp.Content) == 0 {
449 return fmt.Errorf("empty response from LLM for tool validation")
450 }
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000451
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700452 response := strings.TrimSpace(resp.Content[0].Text)
453 if response == "NO" || response == "UNSURE" {
454 slog.InfoContext(ctx, "tool installation declined by LLM", "tool", cmd, "response", response)
455 return fmt.Errorf("tool %s not approved for installation", cmd)
456 }
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000457
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700458 packageName := strings.TrimSpace(response)
459 if packageName == "" {
460 return fmt.Errorf("no package name provided for tool %s", cmd)
461 }
462
463 // Install the package (with update command first if needed)
464 // TODO: these invocations create zombies when we are PID 1.
465 // We should give them the same zombie-reaping treatment as above,
466 // if/when we care enough to put in the effort. Not today.
467 var updateCmd, installCmd string
468 switch packageManager {
469 case "apt", "apt-get":
470 updateCmd = fmt.Sprintf("sudo %s update", packageManager)
471 installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
472 case "brew":
473 // brew handles updates automatically, no explicit update needed
474 installCmd = fmt.Sprintf("brew install %s", packageName)
475 case "apk":
476 updateCmd = "sudo apk update"
477 installCmd = fmt.Sprintf("sudo apk add %s", packageName)
478 case "yum", "dnf":
479 // For yum/dnf, we don't need a separate update command as the package cache is usually fresh enough
480 // and install will fetch the latest available packages
481 installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
482 case "pacman":
483 updateCmd = "sudo pacman -Sy"
484 installCmd = fmt.Sprintf("sudo pacman -S --noconfirm %s", packageName)
485 case "zypper":
486 updateCmd = "sudo zypper refresh"
487 installCmd = fmt.Sprintf("sudo zypper install -y %s", packageName)
488 case "xbps-install":
489 updateCmd = "sudo xbps-install -S"
490 installCmd = fmt.Sprintf("sudo xbps-install -y %s", packageName)
491 case "emerge":
492 // Note: emerge --sync is expensive, so we skip it for JIT installs
493 // Users should manually sync if needed
494 installCmd = fmt.Sprintf("sudo emerge %s", packageName)
495 case "nix-env":
496 // nix-env doesn't require explicit updates for JIT installs
497 installCmd = fmt.Sprintf("nix-env -i %s", packageName)
498 case "guix":
499 // guix doesn't require explicit updates for JIT installs
500 installCmd = fmt.Sprintf("guix install %s", packageName)
501 case "pkg":
502 updateCmd = "sudo pkg update"
503 installCmd = fmt.Sprintf("sudo pkg install -y %s", packageName)
504 case "slackpkg":
505 updateCmd = "sudo slackpkg update"
506 installCmd = fmt.Sprintf("sudo slackpkg install %s", packageName)
507 default:
508 return fmt.Errorf("unsupported package manager: %s", packageManager)
509 }
510
511 slog.InfoContext(ctx, "installing tool", "tool", cmd, "package", packageName, "update_command", updateCmd, "install_command", installCmd)
512
513 // Execute the update command first if needed
514 if updateCmd != "" {
515 slog.InfoContext(ctx, "updating package cache", "command", updateCmd)
516 updateCmdExec := exec.CommandContext(ctx, "sh", "-c", updateCmd)
517 updateOutput, err := updateCmdExec.CombinedOutput()
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000518 if err != nil {
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700519 slog.WarnContext(ctx, "package cache update failed, proceeding with install anyway", "error", err, "output", string(updateOutput))
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000520 }
521 }
522
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -0700523 // Execute the install command
524 cmdExec := exec.CommandContext(ctx, "sh", "-c", installCmd)
525 output, err := cmdExec.CombinedOutput()
526 if err != nil {
527 return fmt.Errorf("failed to install %s: %w\nOutput: %s", packageName, err, string(output))
528 }
529
530 slog.InfoContext(ctx, "tool installation successful", "tool", cmd, "package", packageName)
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000531 return nil
532}