From d17233e19538f30e2f85ea2b098c976d18d87360 Mon Sep 17 00:00:00 2001 From: seagochen Date: Wed, 11 Mar 2026 13:56:21 +0800 Subject: [PATCH] feat(commands): add /exec command and ! prefix shell execution Register /exec as a built-in command so shell commands can be run from pico (AI) mode without switching sessions. Also makes ! a universal shell-exec prefix: unknown !foo commands fall back to /exec instead of passing through to the agent. - pkg/commands/cmd_exec.go: new /exec command definition - pkg/commands/runtime.go: add ExecCmd callback to Runtime - pkg/commands/builtin.go: register execCommand() - pkg/commands/executor.go: ! prefix fallback to /exec for unknown commands - pkg/agent/loop.go: wire ExecCmd callback in buildCommandsRuntime() Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/loop.go | 8 ++++++++ pkg/commands/builtin.go | 1 + pkg/commands/cmd_exec.go | 38 ++++++++++++++++++++++++++++++++++++++ pkg/commands/executor.go | 9 +++++++++ pkg/commands/runtime.go | 3 +++ 5 files changed, 59 insertions(+) create mode 100644 pkg/commands/cmd_exec.go 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)