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 <noreply@anthropic.com>
This commit is contained in:
seagochen 2026-03-11 13:56:21 +08:00
parent a93f5a80cd
commit d17233e195
5 changed files with 59 additions and 0 deletions

View file

@ -1957,6 +1957,14 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
return agent.TotalPromptTokens.Load(), agent.TotalCompletionTokens.Load(), agent.TotalRequests.Load() 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) // One-shot AI query for /hipico (stays in modeCmd, separate session key)
RunOneShot: func(ctx context.Context, message string) (string, error) { RunOneShot: func(ctx context.Context, message string) (string, error) {
if agent == nil { if agent == nil {

View file

@ -16,6 +16,7 @@ func BuiltinDefinitions() []Definition {
cmdModeCommand(), cmdModeCommand(),
picoModeCommand(), picoModeCommand(),
hipicoCmnd(), hipicoCmnd(),
execCommand(),
editCommand(), editCommand(),
// Info and session management // Info and session management
usageCommand(), usageCommand(),

38
pkg/commands/cmd_exec.go Normal file
View file

@ -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 <command>",
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 <command>\nExample: /exec ls -la")
}
result, err := rt.ExecCmd(ctx, args)
if err != nil {
return req.Reply("Error: " + err.Error())
}
return req.Reply(result)
},
}
}

View file

@ -3,6 +3,7 @@ package commands
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
) )
type Outcome int type Outcome int
@ -44,6 +45,14 @@ func (e *Executor) Execute(ctx context.Context, req Request) ExecuteResult {
def, found := e.reg.Lookup(cmdName) def, found := e.reg.Lookup(cmdName)
if !found { 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} return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
} }

View file

@ -33,6 +33,9 @@ type Runtime struct {
// Token and model usage stats // Token and model usage stats
GetTokenUsage func() (promptTokens, completionTokens, requests int64) 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) // One-shot AI query from cmd mode (/hipico)
RunOneShot func(ctx context.Context, message string) (string, error) RunOneShot func(ctx context.Context, message string) (string, error)