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