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