claudetool: add just-in-time tool installation for bash commands

Implements automatic tool installation when bash commands use missing tools,
providing a seamless experience for the LLM to Just Use tools it knows ought to exist.

Core Features:

1. Command Analysis Pipeline:
   - Parse bash commands to extract individual tools/programs
   - Use exec.LookPath to check if tools exist in PATH
   - Handle shell built-ins, absolute/relative paths correctly
   - Support complex command chaining with &&, ||, ;, and |

2. Subagent Tool Installation:
   - Spawn dedicated subagents to install missing tools

The system preserves existing bash tool behavior while adding invisible
tool installation. Original commands run regardless of installation
success/failure, avoiding agent confusion.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s226cd6260a6469e9k
diff --git a/claudetool/bash.go b/claudetool/bash.go
index 827235a..43659b8 100644
--- a/claudetool/bash.go
+++ b/claudetool/bash.go
@@ -5,16 +5,19 @@
 	"context"
 	"encoding/json"
 	"fmt"
+	"log/slog"
 	"math"
 	"os"
 	"os/exec"
 	"path/filepath"
 	"strings"
+	"sync"
 	"syscall"
 	"time"
 
 	"sketch.dev/claudetool/bashkit"
 	"sketch.dev/llm"
+	"sketch.dev/llm/conversation"
 )
 
 // PermissionCallback is a function type for checking if a command is allowed to run
@@ -24,12 +27,20 @@
 type BashTool struct {
 	// CheckPermission is called before running any command, if set
 	CheckPermission PermissionCallback
+	// EnableJITInstall enables just-in-time tool installation for missing commands
+	EnableJITInstall bool
 }
 
+const (
+	EnableBashToolJITInstall = true
+	NoBashToolJITInstall     = false
+)
+
 // NewBashTool creates a new Bash tool with optional permission callback
-func NewBashTool(checkPermission PermissionCallback) *llm.Tool {
+func NewBashTool(checkPermission PermissionCallback, enableJITInstall bool) *llm.Tool {
 	tool := &BashTool{
-		CheckPermission: checkPermission,
+		CheckPermission:  checkPermission,
+		EnableJITInstall: enableJITInstall,
 	}
 
 	return &llm.Tool{
@@ -41,7 +52,7 @@
 }
 
 // The Bash tool executes shell commands with bash -c and optional timeout
-var Bash = NewBashTool(nil)
+var Bash = NewBashTool(nil, NoBashToolJITInstall)
 
 const (
 	bashName        = "bash"
@@ -121,6 +132,14 @@
 		}
 	}
 
+	// Check for missing tools and try to install them if needed, best effort only
+	if b.EnableJITInstall {
+		err := b.checkAndInstallMissingTools(ctx, req.Command)
+		if err != nil {
+			slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err)
+		}
+	}
+
 	// If Background is set to true, use executeBackgroundBash
 	if req.Background {
 		result, err := executeBackgroundBash(ctx, req)
@@ -310,3 +329,176 @@
 	// Use the default Bash tool which has no permission callback
 	return Bash.Run(ctx, m)
 }
+
+// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
+func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
+	commands, err := bashkit.ExtractCommands(command)
+	if err != nil {
+		return err
+	}
+
+	autoInstallMu.Lock()
+	defer autoInstallMu.Unlock()
+
+	var missing []string
+	for _, cmd := range commands {
+		if doNotAttemptToolInstall[cmd] {
+			continue
+		}
+		_, err := exec.LookPath(cmd)
+		if err == nil {
+			doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
+			continue
+		}
+		missing = append(missing, cmd)
+	}
+
+	err = b.installTools(ctx, missing)
+	if err != nil {
+		return err
+	}
+	for _, cmd := range missing {
+		doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
+	}
+	return nil
+}
+
+// commandExists checks if a command exists using exec.LookPath
+func commandExists(command string) bool {
+	// If it's an absolute or relative path, check if the file exists
+	if strings.Contains(command, "/") {
+		_, err := os.Stat(command)
+		return err == nil
+	}
+
+	// Otherwise, use exec.LookPath to find it in PATH
+	_, err := exec.LookPath(command)
+	return err == nil
+}
+
+// Command safety check cache to avoid repeated LLM calls
+var (
+	autoInstallMu           sync.Mutex
+	doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
+)
+
+// installTools installs missing tools.
+func (b *BashTool) installTools(ctx context.Context, missing []string) error {
+	slog.InfoContext(ctx, "installTools subconvo", "tools", missing)
+
+	info := conversation.ToolCallInfoFromContext(ctx)
+	if info.Convo == nil {
+		return fmt.Errorf("no conversation context available for tool installation")
+	}
+	subConvo := info.Convo.SubConvo()
+	subConvo.Hidden = true
+	subBash := NewBashTool(nil, NoBashToolJITInstall)
+
+	done := false
+	doneTool := &llm.Tool{
+		Name:        "done",
+		Description: "Call this tool once when finished processing all commands, providing the installation status for each.",
+		InputSchema: json.RawMessage(`{
+  "type": "object",
+  "properties": {
+    "results": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "properties": {
+          "command_name": {
+            "type": "string",
+            "description": "The name of the command"
+          },
+          "installed": {
+            "type": "boolean",
+            "description": "Whether the command was installed"
+          }
+        },
+        "required": ["command_name", "installed"]
+      }
+    }
+  },
+  "required": ["results"]
+}`),
+		Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
+			type InstallResult struct {
+				CommandName string `json:"command_name"`
+				Installed   bool   `json:"installed"`
+			}
+			type DoneInput struct {
+				Results []InstallResult `json:"results"`
+			}
+			var doneInput DoneInput
+			err := json.Unmarshal(input, &doneInput)
+			results := doneInput.Results
+			if err != nil {
+				slog.WarnContext(ctx, "failed to parse install results", "raw", string(input), "error", err)
+			} else {
+				slog.InfoContext(ctx, "auto-tool installation complete", "results", results)
+			}
+			done = true
+			return llm.TextContent(""), nil
+		},
+	}
+
+	subConvo.Tools = []*llm.Tool{
+		subBash,
+		doneTool,
+	}
+
+	const autoinstallSystemPrompt = `The assistant powers an entirely automated auto-installer tool.
+
+The user will provide a list of commands that were not found on the system.
+
+The assistant's task:
+
+First, decide whether each command is mainstream and safe for automatic installation in a development environment. Skip any commands that could cause security issues, legal problems, or consume excessive resources.
+
+For each appropriate command:
+
+1. Detect the system's package manager and install the command using standard repositories only (no source builds, no curl|bash installs).
+2. Make a minimal verification attempt (package manager success is sufficient).
+3. If installation fails after reasonable attempts, mark as failed and move on.
+
+Once all commands have been processed, call the "done" tool with the status of each command.
+`
+
+	subConvo.SystemPrompt = autoinstallSystemPrompt
+
+	cmds := new(strings.Builder)
+	cmds.WriteString("<commands>\n")
+	for _, cmd := range missing {
+		cmds.WriteString("<command>")
+		cmds.WriteString(cmd)
+		cmds.WriteString("</command>\n")
+	}
+	cmds.WriteString("</commands>\n")
+
+	resp, err := subConvo.SendUserTextMessage(cmds.String())
+	if err != nil {
+		return err
+	}
+
+	for !done {
+		if resp.StopReason != llm.StopReasonToolUse {
+			return fmt.Errorf("subagent finished without calling tool")
+		}
+
+		ctxWithWorkDir := WithWorkingDir(ctx, WorkingDir(ctx))
+		results, _, err := subConvo.ToolResultContents(ctxWithWorkDir, resp)
+		if err != nil {
+			return err
+		}
+
+		resp, err = subConvo.SendMessage(llm.Message{
+			Role:    llm.MessageRoleUser,
+			Content: results,
+		})
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/claudetool/bashkit/parsing.go b/claudetool/bashkit/parsing.go
new file mode 100644
index 0000000..2b17ae3
--- /dev/null
+++ b/claudetool/bashkit/parsing.go
@@ -0,0 +1,82 @@
+package bashkit
+
+import (
+	"fmt"
+	"strings"
+
+	"mvdan.cc/sh/v3/syntax"
+)
+
+// ExtractCommands parses a bash command and extracts individual command names that are
+// candidates for auto-installation.
+//
+// Returns only simple command names (no paths, no builtins, no variable assignments)
+// that could potentially be missing tools that need installation.
+//
+// Filtering logic:
+// - Excludes commands with paths (./script.sh, /usr/bin/tool, ../build.sh)
+// - Excludes shell builtins (echo, cd, test, [, etc.)
+// - Excludes variable assignments (FOO=bar)
+// - Deduplicates repeated command names
+//
+// Examples:
+//
+//	"ls -la && echo done" → ["ls"] (echo filtered as builtin)
+//	"./deploy.sh && curl api.com" → ["curl"] (./deploy.sh filtered as path)
+//	"yamllint config.yaml" → ["yamllint"] (candidate for installation)
+func ExtractCommands(command string) ([]string, error) {
+	r := strings.NewReader(command)
+	parser := syntax.NewParser()
+	file, err := parser.Parse(r, "")
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse bash command: %w", err)
+	}
+
+	var commands []string
+	seen := make(map[string]bool)
+
+	syntax.Walk(file, func(node syntax.Node) bool {
+		callExpr, ok := node.(*syntax.CallExpr)
+		if !ok || len(callExpr.Args) == 0 {
+			return true
+		}
+		cmdName := callExpr.Args[0].Lit()
+		if cmdName == "" {
+			return true
+		}
+		if strings.Contains(cmdName, "=") {
+			// variable assignment
+			return true
+		}
+		if strings.Contains(cmdName, "/") {
+			// commands with slashes are user-specified executables/scripts
+			return true
+		}
+		if isBuiltin(cmdName) {
+			return true
+		}
+		if !seen[cmdName] {
+			seen[cmdName] = true
+			commands = append(commands, cmdName)
+		}
+		return true
+	})
+
+	return commands, nil
+}
+
+// isBuiltin checks if a command is a shell built-in using the same logic as mvdan.cc/sh/v3/interp
+// This is copied from mvdan.cc/sh/v3/interp.isBuiltin since it's not exported
+// See https://github.com/mvdan/sh/issues/1164
+func isBuiltin(name string) bool {
+	switch name {
+	case "true", ":", "false", "exit", "set", "shift", "unset",
+		"echo", "printf", "break", "continue", "pwd", "cd",
+		"wait", "builtin", "trap", "type", "source", ".", "command",
+		"dirs", "pushd", "popd", "umask", "alias", "unalias",
+		"fg", "bg", "getopts", "eval", "test", "[", "exec",
+		"return", "read", "mapfile", "readarray", "shopt":
+		return true
+	}
+	return false
+}
diff --git a/claudetool/bashkit/parsing_test.go b/claudetool/bashkit/parsing_test.go
new file mode 100644
index 0000000..9a2b831
--- /dev/null
+++ b/claudetool/bashkit/parsing_test.go
@@ -0,0 +1,172 @@
+package bashkit
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestExtractCommands(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected []string
+	}{
+		{
+			name:     "simple command",
+			input:    "ls -la",
+			expected: []string{"ls"},
+		},
+		{
+			name:     "command with pipe",
+			input:    "ls -la | grep test",
+			expected: []string{"ls", "grep"},
+		},
+		{
+			name:     "command with logical and (builtin filtered)",
+			input:    "mkdir test && cd test",
+			expected: []string{"mkdir"}, // cd is builtin, filtered out
+		},
+		{
+			name:     "if statement with commands (builtin filtered)",
+			input:    "if [ -f file.txt ]; then cat file.txt; fi",
+			expected: []string{"cat"}, // [ is builtin, filtered out
+		},
+		{
+			name:     "variable assignment with command (builtin filtered)",
+			input:    "FOO=bar echo $FOO",
+			expected: []string{}, // echo is builtin, filtered out
+		},
+		{
+			name:     "script path filtered out (builtin also filtered)",
+			input:    "./script.sh && echo done",
+			expected: []string{}, // echo is builtin, filtered out
+		},
+		{
+			name:     "multiline script (builtin filtered)",
+			input:    "python3 -c 'print(\"hello\")'\necho 'done'",
+			expected: []string{"python3"}, // echo is builtin, filtered out
+		},
+		{
+			name:     "complex command chain (builtin filtered)",
+			input:    "curl -s https://api.github.com | jq '.name' && echo 'done'",
+			expected: []string{"curl", "jq"}, // echo is builtin, filtered out
+		},
+		{
+			name:     "builtins filtered out",
+			input:    "echo 'test' && true && ls",
+			expected: []string{"ls"},
+		},
+		{
+			name:     "empty command",
+			input:    "",
+			expected: []string{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := ExtractCommands(tt.input)
+			if err != nil {
+				t.Fatalf("ExtractCommands() error = %v", err)
+			}
+			// Handle empty slice comparison
+			if len(result) == 0 && len(tt.expected) == 0 {
+				return // Both are empty, test passes
+			}
+			if !reflect.DeepEqual(result, tt.expected) {
+				t.Errorf("ExtractCommands() = %v, want %v", result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestIsBuiltin(t *testing.T) {
+	tests := []struct {
+		name     string
+		command  string
+		expected bool
+	}{
+		{"echo is builtin", "echo", true},
+		{"cd is builtin", "cd", true},
+		{"test is builtin", "test", true},
+		{"[ is builtin", "[", true},
+		{"ls is not builtin", "ls", false},
+		{"curl is not builtin", "curl", false},
+		{"python3 is not builtin", "python3", false},
+		{"nonexistent is not builtin", "nonexistent", false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := isBuiltin(tt.command)
+			if result != tt.expected {
+				t.Errorf("IsBuiltin(%q) = %v, want %v", tt.command, result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestExtractCommandsErrorHandling(t *testing.T) {
+	// Test with syntactically invalid bash
+	invalidBash := "if [ incomplete"
+	_, err := ExtractCommands(invalidBash)
+	if err == nil {
+		t.Error("ExtractCommands() should return error for invalid bash syntax")
+	}
+}
+
+func TestExtractCommandsPathFiltering(t *testing.T) {
+	// Test that commands with paths are properly filtered out during extraction
+	tests := []struct {
+		name     string
+		input    string
+		expected []string
+	}{
+		{
+			name:     "relative script path filtered (builtin also filtered)",
+			input:    "./my-script.sh && echo 'done'",
+			expected: []string{}, // echo is builtin, filtered out
+		},
+		{
+			name:     "absolute path filtered",
+			input:    "/usr/bin/custom-tool --help",
+			expected: []string{},
+		},
+		{
+			name:     "parent directory script filtered",
+			input:    "../scripts/build.sh",
+			expected: []string{},
+		},
+		{
+			name:     "home directory path filtered",
+			input:    "~/.local/bin/tool",
+			expected: []string{},
+		},
+		{
+			name:     "simple commands without paths included",
+			input:    "curl https://example.com | jq '.name'",
+			expected: []string{"curl", "jq"},
+		},
+		{
+			name:     "mixed paths and simple commands",
+			input:    "./setup.sh && python3 -c 'print(\"hello\")' && /bin/ls",
+			expected: []string{"python3"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := ExtractCommands(tt.input)
+			if err != nil {
+				t.Fatalf("ExtractCommands() error = %v", err)
+			}
+			// Handle empty slice comparison
+			if len(result) == 0 && len(tt.expected) == 0 {
+				return // Both are empty, test passes
+			}
+			if !reflect.DeepEqual(result, tt.expected) {
+				t.Errorf("ExtractCommands() = %v, want %v", result, tt.expected)
+			}
+		})
+	}
+}