// This file implements slash-commands (US-029, #45): typed "/name" shortcuts a // user invokes in the REPL. There are two sources, resolved with a fixed // priority: // // - Built-in commands are registered at compile time via RegisterBuiltin // (from init() in the fork's own code). They are always available. // - User commands are declarative markdown templates loaded from a directory // (mirrors the .../commands/*.md convention): the file name is the command // name and the body is a prompt template that may reference $ARGUMENTS. // // Conflict rule: same-name commands resolve by priority tier (built-in > // project > global > package > settings > CLI); the higher tier wins and the // loser is reported via Shadowed. Built-ins are load-bearing and always win. // Within a tier, the last-added command overrides earlier ones (a re-load). // // There is deliberately no standalone plugin mechanism: a fork adds built-ins // via init() registration, and external extensions go through MCP (deferred). package runtime import ( "fmt" "os" "path/filepath" "sort" "strings" "gopkg.in/yaml.v3" ) // SlashCommandSource identifies where a command came from, used for the // conflict/priority rule and for display. type SlashCommandSource int const ( // SourceBuiltin is a compile-time registered command (highest priority). SourceBuiltin SlashCommandSource = iota // SourceUser is a declarative markdown command template loaded from disk // (e.g. ~/.pigo/commands/*.md). SourceUser // SourceSkill is a skill loaded from ~/.agents/skills and surfaced as a // /skill-name command. It behaves like SourceUser for the built-in-wins // conflict rule; the finer tag is for display only (e.g. /status). SourceSkill // SourcePlugin is a command declared by a loaded plugin. It behaves like // SourceUser for the built-in-wins conflict rule; the finer tag is for // display only. SourcePlugin ) func (s SlashCommandSource) String() string { switch s { case SourceBuiltin: return "builtin" case SourceSkill: return "skill" case SourcePlugin: return "plugin" default: return "user" } } // Tier is the priority tier of a command, used to resolve same-name conflicts // across sources (mirrors pi prompt-templates discovery priority). Higher tiers // win; the loser is recorded in Shadowed. Within the same tier the last-added // command wins (a re-load overrides). Skills and plugins are treated as // Global-tier for priority - their finer Source label is for display only. type Tier int const ( // Tier values are ordered lowest-to-highest priority: in a same-name // conflict the higher Tier value wins, so TierBuiltin always wins and // TierCLI always loses. Declared ascending so the natural > comparison // matches "higher priority wins". TierCLI Tier = iota // TierSettings is a prompt template referenced by the config.toml prompts array. TierSettings // TierPackage is a prompt template discovered from an installed package // source (distinct from one copied into the global dir). TierPackage // TierGlobal is a global user prompt template (e.g. ~/.pigo/prompts or the // legacy ~/.pigo/commands); also the tier used for skills and plugins. TierGlobal // TierProject is a project-local prompt template (e.g. .pigo/prompts). TierProject // TierBuiltin is a compile-time or instance built-in command (highest). TierBuiltin ) func (t Tier) String() string { switch t { case TierBuiltin: return "builtin" case TierProject: return "project" case TierGlobal: return "global" case TierPackage: return "package" case TierSettings: return "settings" case TierCLI: return "cli" default: return "unknown" } } // ShadowedEntry records a command that lost a same-name conflict to a higher- // tier command, for diagnostics. It carries the loser's name, tier, and source // label so /help and the startup warning can say which source was shadowed. type ShadowedEntry struct { Name string Tier Tier Source SlashCommandSource } // String renders a shadowed entry as "name (tier)" for log lines. func (e ShadowedEntry) String() string { return fmt.Sprintf("%s (%s)", e.Name, e.Tier) } // SlashCommand is a resolved command: its name (without the leading "/"), a // short description for the command palette, and its source. A command is one // of three kinds, distinguished by which callback is set: // // - A prompt command sets Expand: it turns the invocation arguments into the // prompt text fed to the agent (the original slash-command behavior). // - An action command sets Action instead: it performs a side effect (e.g. // switching the runtime model) and returns a status line to show the user, // rather than producing a prompt. No agent run is started. // - A hybrid command sets Run: it performs a side effect AND may return prompt // text to run — used by plugin commands, which RPC their plugin, surface the // returned notifications, then inject the returned prompt as the next turn. // // Exactly one of Expand/Action/Run should be set. Precedence when more than one // is set: Action wins over Run, which wins over Expand. This split is what lets // a control command like "/model" change runtime state — the old design could // only emit prompt text. type SlashCommand struct { Name string Description string // ArgumentHint is an optional frontmatter hint shown before the description // in autocomplete (e.g. ""). Convention: for required args, // [square] for optional. Empty when not set; display-only, not enforced. ArgumentHint string Source SlashCommandSource // Tier is the priority tier used to resolve same-name conflicts across // sources (built-in > project > global > package > settings > CLI). It is // set by the AddX method matching the command's source; callers should not // set it directly. Tier Tier // Expand maps the argument string (everything after "/name ") to the prompt // text the command produces. For a built-in it may be arbitrary Go; for a // user template it substitutes $ARGUMENTS into the markdown body. Nil for an // action command. Expand func(args string) string // Action performs a side effect for the invocation and returns a status // message to display (may be empty). Set instead of Expand for a control // command like "/model". Because it is an arbitrary Go closure it can capture // and mutate live runtime state, which Expand (a pure prompt producer) // cannot. Nil for a prompt command. Action func(args string) string // Run is the hybrid of Action and Expand: it performs a side effect AND may // produce prompt text to run as the next agent turn. It returns // (message, prompt): message is shown to the user immediately (like an // Action's status, e.g. plugin notifications), and prompt, when non-empty, is // run as a normal turn (like Expand's output). This is what a plugin command // needs — it RPCs its plugin (side effect), surfaces the returned // notifications (message), then injects the returned prompt (prompt). Set // instead of Expand/Action for such a command; nil otherwise. When Run is set // it takes precedence over Expand (but Action still wins over Run). Run func(args string) (message, prompt string) } // SlashKind classifies how a resolved invocation should be handled by the // caller: run its prompt through the agent, or treat it as a completed action. type SlashKind int const ( // SlashPrompt means the outcome carries prompt text to run (or, when not a // command at all, the verbatim input). SlashPrompt SlashKind = iota // SlashAction means an action command already ran; the outcome carries only // a status Message and no agent run should start. SlashAction ) // SlashOutcome is the structured result of resolving one input line. Handled is // false when the input was not a slash command (Prompt holds the verbatim input // to run). When Handled is true, Kind says whether Prompt should be run // (SlashPrompt) or an action already ran and Message should be shown without // starting a run (SlashAction). // // A hybrid (Run) command resolves to Kind SlashPrompt with BOTH fields set: its // side effect already ran, Message carries the text to show the user first // (e.g. plugin notifications), and Prompt, when non-empty, is the turn to run // after. The caller shows Message (if any) then runs Prompt (if non-empty). type SlashOutcome struct { Handled bool Kind SlashKind Prompt string Message string } // builtinCommands holds compile-time registered commands, keyed by name. It is // populated by RegisterBuiltin from init() and read when building a registry. // // Concurrency contract: this global is written only by RegisterBuiltin, which // must be called from init() (single-threaded, before main), and read only // afterwards by NewSlashRegistry. It carries no lock because that init-only // discipline means there is never a concurrent write; do not call // RegisterBuiltin after startup. var builtinCommands = map[string]SlashCommand{} // RegisterBuiltin registers a built-in slash command at compile time. It is // intended to be called from init(); a duplicate name panics, since two // built-ins claiming the same name is a programming error in the fork. func RegisterBuiltin(cmd SlashCommand) { if cmd.Name == "" { panic("agent: RegisterBuiltin with empty name") } if _, exists := builtinCommands[cmd.Name]; exists { panic(fmt.Sprintf("agent: duplicate built-in slash command %q", cmd.Name)) } cmd.Source = SourceBuiltin cmd.Tier = TierBuiltin builtinCommands[cmd.Name] = cmd } // SlashRegistry resolves "/name" invocations against built-in and user // commands, applying the built-in-wins priority rule. type SlashRegistry struct { commands map[string]SlashCommand // shadowed records commands that lost a same-name conflict to a higher-tier // command, with their tier and source for diagnostics. Same-tier overrides // (last-write-wins) are not recorded. shadowed []ShadowedEntry } // NewSlashRegistry builds a registry seeded with all registered built-ins. func NewSlashRegistry() *SlashRegistry { r := &SlashRegistry{commands: make(map[string]SlashCommand, len(builtinCommands))} for name, cmd := range builtinCommands { r.commands[name] = cmd } return r } // AddBuiltin installs a built-in command directly on this registry instance, // bypassing the compile-time global. It exists for action commands whose // closure must capture live, per-run state (e.g. a model controller created in // main) — such state cannot be reached from an init()-time RegisterBuiltin. The // command is marked SourceBuiltin so it wins over a same-named user command, // exactly like a globally registered built-in. A duplicate name panics, since // two built-ins claiming one name is a programming error. func (r *SlashRegistry) AddBuiltin(cmd SlashCommand) { if cmd.Name == "" { panic("agent: AddBuiltin with empty name") } if existing, ok := r.commands[cmd.Name]; ok && existing.Source == SourceBuiltin { panic(fmt.Sprintf("agent: duplicate built-in slash command %q", cmd.Name)) } cmd.Source = SourceBuiltin cmd.Tier = TierBuiltin r.add(cmd) } // AddUser installs a user command (TierGlobal), e.g. a prompt template from // ~/.pigo/prompts or the legacy ~/.pigo/commands. A same-named built-in or // project-tier command wins; same-tier (global) adds override silently. func (r *SlashRegistry) AddUser(cmd SlashCommand) { cmd.Source = SourceUser cmd.Tier = TierGlobal r.add(cmd) } // AddSkill installs a skill command (loaded from ~/.agents/skills) at TierGlobal. // It follows the same tier rule as AddUser - a built-in or project-tier command // wins - only the source tag differs, so /status can report skills separately. func (r *SlashRegistry) AddSkill(cmd SlashCommand) { cmd.Source = SourceSkill cmd.Tier = TierGlobal r.add(cmd) } // AddPlugin installs a plugin-declared command at TierGlobal, mirroring AddUser // with a SourcePlugin tag for display. func (r *SlashRegistry) AddPlugin(cmd SlashCommand) { cmd.Source = SourcePlugin cmd.Tier = TierGlobal r.add(cmd) } // Shadowed returns the commands that lost a same-name conflict to a higher-tier // command, with their tier and source for diagnostics. Same-tier overrides // (last-write-wins) are not recorded here. func (r *SlashRegistry) Shadowed() []ShadowedEntry { return r.shadowed } // AddProject installs a project-local prompt template (TierProject), which // overrides a same-named global/package/settings/CLI template but loses to a // built-in. func (r *SlashRegistry) AddProject(cmd SlashCommand) { cmd.Source = SourceUser cmd.Tier = TierProject r.add(cmd) } // AddPackage installs a package-discovered prompt template (TierPackage). func (r *SlashRegistry) AddPackage(cmd SlashCommand) { cmd.Source = SourceUser cmd.Tier = TierPackage r.add(cmd) } // AddSettings installs a prompt template referenced by config.toml (TierSettings). func (r *SlashRegistry) AddSettings(cmd SlashCommand) { cmd.Source = SourceUser cmd.Tier = TierSettings r.add(cmd) } // AddCLI installs a prompt template referenced by --prompt-template (TierCLI, // the lowest priority). func (r *SlashRegistry) AddCLI(cmd SlashCommand) { cmd.Source = SourceUser cmd.Tier = TierCLI r.add(cmd) } // add installs cmd with tier-based conflict resolution. If a same-named command // already exists, the higher tier wins and the loser is appended to shadowed; // within the same tier the new command replaces the old (last-write-wins, no // shadow entry). A built-in always wins because TierBuiltin is highest. func (r *SlashRegistry) add(cmd SlashCommand) { existing, ok := r.commands[cmd.Name] if !ok { r.commands[cmd.Name] = cmd return } switch { case existing.Tier > cmd.Tier: // New command is lower tier: it loses and is shadowed. r.shadowed = append(r.shadowed, ShadowedEntry{Name: cmd.Name, Tier: cmd.Tier, Source: cmd.Source}) case existing.Tier < cmd.Tier: // New command is higher tier: it wins; the old one is shadowed. r.shadowed = append(r.shadowed, ShadowedEntry{Name: existing.Name, Tier: existing.Tier, Source: existing.Source}) r.commands[cmd.Name] = cmd default: // Same tier: last-write-wins (a re-load), no shadow entry. r.commands[cmd.Name] = cmd } } // Lookup returns the command bound to name (without the leading "/"). func (r *SlashRegistry) Lookup(name string) (SlashCommand, bool) { cmd, ok := r.commands[name] return cmd, ok } // List returns all commands sorted by name. func (r *SlashRegistry) List() []SlashCommand { out := make([]SlashCommand, 0, len(r.commands)) for _, c := range r.commands { out = append(out, c) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // Resolve parses a raw input line and, if it is a slash-command invocation, // expands it to the prompt text the agent should run. It returns (prompt, true) // when input begins with "/" and names a known PROMPT command; (input, false) // when the input is not a slash command (the caller runs it verbatim); and an // error when input is a "/name" for an unknown command. // // This is the legacy string API, kept for callers that only handle prompt // commands. It reports an action command as handled with an empty prompt (the // action does NOT run here) — callers that want action commands to execute must // use ResolveOutcome instead. func (r *SlashRegistry) Resolve(input string) (prompt string, handled bool, err error) { out, err := r.ResolveOutcome(input) if err != nil { return "", false, err } return out.Prompt, out.Handled, nil } // ResolveOutcome parses a raw input line into a structured SlashOutcome. For a // non-command it returns {Handled:false, Prompt:input}. For a known prompt // command it returns {Handled:true, Kind:SlashPrompt, Prompt:}. For a // known action command it RUNS the action and returns {Handled:true, // Kind:SlashAction, Message:} — no prompt to run. For a known hybrid // (Run) command it RUNS the side effect and returns {Handled:true, // Kind:SlashPrompt, Message:, Prompt:} — the caller shows Message // then runs Prompt when non-empty. An unknown "/name" yields an error. func (r *SlashRegistry) ResolveOutcome(input string) (SlashOutcome, error) { trimmed := strings.TrimLeft(input, " \t") if !strings.HasPrefix(trimmed, "/") { return SlashOutcome{Handled: false, Kind: SlashPrompt, Prompt: input}, nil } rest := trimmed[1:] name := rest args := "" if i := strings.IndexAny(rest, " \t"); i >= 0 { name = rest[:i] args = strings.TrimSpace(rest[i+1:]) } cmd, ok := r.commands[name] if !ok { return SlashOutcome{}, fmt.Errorf("unknown command %q", "/"+name) } if cmd.Action != nil { return SlashOutcome{Handled: true, Kind: SlashAction, Message: cmd.Action(args)}, nil } if cmd.Run != nil { // A hybrid command runs its side effect now and may yield prompt text. // The outcome is a prompt (SlashPrompt) that also carries a Message to // surface first; the caller shows Message then runs Prompt if non-empty. message, prompt := cmd.Run(args) return SlashOutcome{Handled: true, Kind: SlashPrompt, Message: message, Prompt: prompt}, nil } return SlashOutcome{Handled: true, Kind: SlashPrompt, Prompt: cmd.Expand(args)}, nil } // firstNonEmptyLine returns the first line of s whose trimmed form is non-empty, // itself trimmed. It is the description fallback for templates whose frontmatter // omits a description (mirrors pi: "If missing, the first non-empty line is used"). func firstNonEmptyLine(s string) string { for _, line := range strings.Split(s, "\n") { if t := strings.TrimSpace(line); t != "" { return t } } return "" } // LoadPromptFile loads a single prompt-template file. The command name is the // filename without its extension (e.g. /x/review.md -> "review"). It is the // single-file counterpart of LoadUserCommandsDir, used for settings/CLI paths // that point at one file rather than a directory. func LoadPromptFile(path string) (SlashCommand, error) { content, err := os.ReadFile(path) if err != nil { return SlashCommand{}, fmt.Errorf("read prompt %s: %w", path, err) } base := filepath.Base(path) name := strings.TrimSuffix(base, filepath.Ext(base)) return ParseUserCommand(name, content) } // LoadUserCommandsDir loads declarative markdown command templates from dir // (non-recursively). Each "*.md" file defines a command named after the file // (without extension). The file may carry an optional YAML frontmatter block // with a "description" (mirrors skills); the remaining body is the prompt template, // expanded via ExpandTemplate at invoke time. A missing directory yields no // commands and no error. func LoadUserCommandsDir(dir string) ([]SlashCommand, error) { entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("read commands dir %s: %w", dir, err) } var cmds []SlashCommand for _, e := range entries { if e.IsDir() || !strings.EqualFold(filepath.Ext(e.Name()), ".md") { continue } path := filepath.Join(dir, e.Name()) content, readErr := os.ReadFile(path) if readErr != nil { return nil, fmt.Errorf("read command %s: %w", path, readErr) } name := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) cmd, parseErr := ParseUserCommand(name, content) if parseErr != nil { return nil, parseErr } cmds = append(cmds, cmd) } sort.Slice(cmds, func(i, j int) bool { return cmds[i].Name < cmds[j].Name }) return cmds, nil } // ParseUserCommand parses a declarative command template. An optional YAML // frontmatter block supplies a description; the body is the prompt template, // expanded at invoke time via ExpandTemplate (positional $N, $@/$ARGUMENTS, // ${1:-default}, ${@:N}). If arg tokenization fails (e.g. an unterminated // quote) the raw arg string is used as $ARGUMENTS so the invocation still works. func ParseUserCommand(name string, content []byte) (SlashCommand, error) { body := string(content) description := "" hint := "" // Reuse the skills frontmatter splitter when a fence is present; otherwise // treat the whole file as the template body. if strings.HasPrefix(strings.TrimLeft(strings.TrimPrefix(body, "\ufeff"), "\r\n"), "---") { fm, rest, splitErr := splitFrontmatter(content) if splitErr != nil { return SlashCommand{}, fmt.Errorf("command %s: %w", name, splitErr) } var meta struct { Description string `yaml:"description"` Name string `yaml:"name"` ArgumentHint string `yaml:"argument-hint"` } if err := yaml.Unmarshal(fm, &meta); err != nil { return SlashCommand{}, fmt.Errorf("command %s: parse frontmatter: %w", name, err) } description = meta.Description hint = meta.ArgumentHint if meta.Name != "" { name = meta.Name } body = string(rest) } // When the frontmatter omits a description, fall back to the first non-empty // line of the body (\u5bf9\u6807 pi: "If missing, the first non-empty line is used"). if description == "" { description = firstNonEmptyLine(body) } template := strings.TrimSpace(body) return SlashCommand{ Name: name, Description: description, ArgumentHint: hint, Source: SourceUser, Expand: func(args string) string { tokens, err := SplitArgs(args) if err != nil { // Split failure (e.g. an unterminated quote): treat the raw arg // string as a single $ARGUMENTS rather than feeding a malformed // arg list to the engine, so a bad invocation stays usable. tokens = []string{args} } return ExpandTemplate(template, tokens) }, }, nil }