diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index a5875c1..a6d7b5c 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -7,6 +7,9 @@ import ( "os" "os/exec" "path/filepath" + "regexp" + "runtime" + "strings" "time" "clawgo/pkg/config" @@ -115,10 +118,46 @@ func (t *ExecTool) SetTimeout(timeout time.Duration) { } func (t *ExecTool) executeCommand(ctx context.Context, command, cwd string) (string, error) { + output, err, timedOut := t.runShellCommand(ctx, command, cwd) + if timedOut { + return fmt.Sprintf("Error: Command timed out after %v", t.timeout), nil + } + + if err != nil { + if missingCmd := detectMissingCommandFromOutput(output); missingCmd != "" { + if installLog, installed := t.tryAutoInstallMissingCommand(ctx, missingCmd, cwd); installed { + output += "\n[AUTO-INSTALL]\n" + installLog + retryOutput, retryErr, retryTimedOut := t.runShellCommand(ctx, command, cwd) + if retryTimedOut { + return fmt.Sprintf("Error: Command timed out after %v", t.timeout), nil + } + output += "\n[RETRY]\n" + retryOutput + err = retryErr + } + } + } + + if err != nil { + output += fmt.Sprintf("\nExit code: %v", err) + } + + if output == "" { + output = "(no output)" + } + + maxLen := 10000 + if len(output) > maxLen { + output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) + } + return output, nil +} + +func (t *ExecTool) runShellCommand(ctx context.Context, command, cwd string) (string, error, bool) { cmdCtx, cancel := context.WithTimeout(ctx, t.timeout) defer cancel() cmd := exec.CommandContext(cmdCtx, "sh", "-c", command) + cmd.Env = buildExecEnv() if cwd != "" { cmd.Dir = cwd } @@ -135,18 +174,105 @@ func (t *ExecTool) executeCommand(ctx context.Context, command, cwd string) (str if err != nil { if cmdCtx.Err() == context.DeadlineExceeded { - return fmt.Sprintf("Error: Command timed out after %v", t.timeout), nil + return output, err, true } - output += fmt.Sprintf("\nExit code: %v", err) } - - if output == "" { - output = "(no output)" - } - - maxLen := 10000 - if len(output) > maxLen { - output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) - } - return output, nil + return output, err, false +} + +func buildExecEnv() []string { + env := os.Environ() + current := os.Getenv("PATH") + fallback := "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/homebrew/bin:/opt/homebrew/sbin" + if strings.TrimSpace(current) == "" { + return append(env, "PATH="+fallback) + } + // Append common paths to reduce false "command not found" in service/daemon envs. + return append(env, "PATH="+current+":"+fallback) +} + +func detectMissingCommandFromOutput(output string) string { + patterns := []*regexp.Regexp{ + regexp.MustCompile(`(?m)(?:^|[:\s])([a-zA-Z0-9._+-]+): not found`), + regexp.MustCompile(`(?m)(?:^|[:\s])([a-zA-Z0-9._+-]+): command not found`), + } + for _, p := range patterns { + match := p.FindStringSubmatch(output) + if len(match) >= 2 && strings.TrimSpace(match[1]) != "" { + return strings.TrimSpace(match[1]) + } + } + return "" +} + +func commandExists(name string) bool { + if strings.TrimSpace(name) == "" { + return false + } + _, err := exec.LookPath(name) + return err == nil +} + +func buildInstallCommandCandidates(commandName string) []string { + cmd := strings.TrimSpace(commandName) + if cmd == "" { + return nil + } + type pkgTool struct { + bin string + cmd string + sudo bool + } + candidates := []pkgTool{ + {bin: "apt-get", cmd: "apt-get update && apt-get install -y %s", sudo: true}, + {bin: "dnf", cmd: "dnf install -y %s", sudo: true}, + {bin: "yum", cmd: "yum install -y %s", sudo: true}, + {bin: "apk", cmd: "apk add --no-cache %s", sudo: true}, + {bin: "pacman", cmd: "pacman -Sy --noconfirm %s", sudo: true}, + {bin: "zypper", cmd: "zypper --non-interactive install %s", sudo: true}, + {bin: "brew", cmd: "brew install %s", sudo: false}, + } + + var out []string + for _, c := range candidates { + if !commandExists(c.bin) { + continue + } + installCmd := fmt.Sprintf(c.cmd, cmd) + if c.sudo && runtime.GOOS != "windows" && os.Geteuid() != 0 && commandExists("sudo") { + installCmd = "sudo " + installCmd + } + out = append(out, installCmd) + } + return out +} + +func (t *ExecTool) tryAutoInstallMissingCommand(ctx context.Context, commandName, cwd string) (string, bool) { + name := strings.TrimSpace(commandName) + if name == "" || commandExists(name) { + return "", false + } + candidates := buildInstallCommandCandidates(name) + if len(candidates) == 0 { + return fmt.Sprintf("No supported package manager found to install missing command: %s", name), false + } + + timeout := 5 * time.Minute + if t.timeout > 0 && t.timeout < timeout { + timeout = t.timeout + } + + for _, installCmd := range candidates { + installCtx, cancel := context.WithTimeout(ctx, timeout) + output, err, timedOut := t.runShellCommand(installCtx, installCmd, cwd) + cancel() + + if timedOut { + continue + } + if err == nil && commandExists(name) { + return fmt.Sprintf("Installed %s using: %s\n%s", name, installCmd, output), true + } + } + return fmt.Sprintf("Failed to auto-install missing command: %s", name), false } diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index bb0c5e3..0a27dc3 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -34,3 +34,27 @@ func TestExecToolExecuteTimeout(t *testing.T) { t.Fatalf("expected timeout message, got %q", out) } } + +func TestDetectMissingCommandFromOutput(t *testing.T) { + cases := []struct { + in string + want string + }{ + {in: "sh: git: not found", want: "git"}, + {in: "/bin/sh: 1: rg: not found", want: "rg"}, + {in: "bash: foo: command not found", want: "foo"}, + {in: "normal error", want: ""}, + } + for _, tc := range cases { + got := detectMissingCommandFromOutput(tc.in) + if got != tc.want { + t.Fatalf("detectMissingCommandFromOutput(%q)=%q want %q", tc.in, got, tc.want) + } + } +} + +func TestBuildInstallCommandCandidates_EmptyName(t *testing.T) { + if got := buildInstallCommandCandidates(""); len(got) != 0 { + t.Fatalf("expected empty candidates, got %v", got) + } +}