539 lines
21 KiB
Go
539 lines
21 KiB
Go
// 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. "<PR-URL>"). Convention: <angle> 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:<expanded>}. For a
|
|
// known action command it RUNS the action and returns {Handled:true,
|
|
// Kind:SlashAction, Message:<status>} — no prompt to run. For a known hybrid
|
|
// (Run) command it RUNS the side effect and returns {Handled:true,
|
|
// Kind:SlashPrompt, Message:<status>, Prompt:<text>} — 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
|
|
}
|