diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 6f8264809..49cb1962f 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1957,6 +1957,14 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt return agent.TotalPromptTokens.Load(), agent.TotalCompletionTokens.Load(), agent.TotalRequests.Load() }, + // Shell command execution — wraps executeCmdMode with session context + ExecCmd: func(ctx context.Context, command string) (string, error) { + if agent == nil { + return "", fmt.Errorf("no agent available") + } + return al.executeCmdMode(ctx, agent, command, opts.SessionKey, opts.Channel, opts.ChatID) + }, + // One-shot AI query for /hipico (stays in modeCmd, separate session key) RunOneShot: func(ctx context.Context, message string) (string, error) { if agent == nil { diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index 8c408aebc..9e1d97643 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -16,6 +16,7 @@ func BuiltinDefinitions() []Definition { cmdModeCommand(), picoModeCommand(), hipicoCmnd(), + execCommand(), editCommand(), // Info and session management usageCommand(), diff --git a/pkg/commands/cmd_exec.go b/pkg/commands/cmd_exec.go new file mode 100644 index 000000000..68fa0f248 --- /dev/null +++ b/pkg/commands/cmd_exec.go @@ -0,0 +1,38 @@ +package commands + +import ( + "context" + "strings" +) + +// execCommand registers /exec (alias: !) for running shell commands. +// Available in both pico and cmd mode; in cmd mode bare text is rewritten to /exec +// before dispatch so all shell execution flows through this single handler. +func execCommand() Definition { + return Definition{ + Name: "exec", + Description: "Execute a shell command", + Usage: "/exec ", + Handler: func(ctx context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ExecCmd == nil { + return req.Reply(unavailableMsg) + } + // Strip the leading command token ("/exec", "!exec", or "!" fallback) + // and treat the remainder as the shell command to run. + args := strings.TrimSpace(req.Text) + if idx := strings.IndexAny(args, " \t"); idx >= 0 { + args = strings.TrimSpace(args[idx:]) + } else { + args = "" + } + if args == "" { + return req.Reply("Usage: /exec \nExample: /exec ls -la") + } + result, err := rt.ExecCmd(ctx, args) + if err != nil { + return req.Reply("Error: " + err.Error()) + } + return req.Reply(result) + }, + } +} diff --git a/pkg/commands/executor.go b/pkg/commands/executor.go index 78a50e6c2..2f21770d4 100644 --- a/pkg/commands/executor.go +++ b/pkg/commands/executor.go @@ -3,6 +3,7 @@ package commands import ( "context" "fmt" + "strings" ) type Outcome int @@ -44,6 +45,14 @@ func (e *Executor) Execute(ctx context.Context, req Request) ExecuteResult { def, found := e.reg.Lookup(cmdName) if !found { + // "!foo" where foo is not a registered command → fall back to /exec. + // This makes ! a universal shell-exec prefix: !ls, !git status, etc. + if strings.HasPrefix(strings.TrimSpace(req.Text), "!") { + if execDef, ok := e.reg.Lookup("exec"); ok { + req.Text = "/exec " + strings.TrimSpace(req.Text)[1:] + return e.executeDefinition(ctx, req, execDef) + } + } return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName} } diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index a817d294f..fdbe5df54 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -33,6 +33,9 @@ type Runtime struct { // Token and model usage stats GetTokenUsage func() (promptTokens, completionTokens, requests int64) + // Shell command execution — delegates to the loop's executeCmdMode with proper path/session handling + ExecCmd func(ctx context.Context, command string) (string, error) + // One-shot AI query from cmd mode (/hipico) RunOneShot func(ctx context.Context, message string) (string, error)