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/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)
+ }
+ })
+ }
+}