| 1 | package command |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/tool" |
| 11 | ) |
| 12 | |
| 13 | // SlashEntry is one invocable slash command exposed to the model through the |
| 14 | // slash_command tool. It is a uniform view over the two kinds the user can also |
| 15 | // type at the prompt — custom commands and skills — so the tool need not know |
| 16 | // which is which. Render turns positional args into the prompt text the command |
| 17 | // expands to (the same text typing "/name args" would send). |
| 18 | type SlashEntry struct { |
| 19 | Name string // without the leading slash, e.g. "review" or "git:commit" |
| 20 | Description string |
| 21 | ArgHint string // optional argument hint, for the listing |
| 22 | Render func(args []string) string // expands the template/playbook with args |
| 23 | } |
| 24 | |
| 25 | // slashCommandTool lets the model invoke a loaded slash command by name. Unlike a |
| 26 | // tool that performs an action and returns a result, a slash command is a *prompt |
| 27 | // template*: the tool returns the expanded prompt text, which the model then reads |
| 28 | // and acts on within the same turn — mirroring what typing "/name" does for a |
| 29 | // human. Calling with no name (or "list") returns the available commands. |
| 30 | type slashCommandTool struct { |
| 31 | entries map[string]SlashEntry |
| 32 | names []string // sorted, for a stable listing |
| 33 | } |
| 34 | |
| 35 | // NewSlashCommandTool builds the tool from the invocable entries (custom commands |
| 36 | // + skills, adapted by the caller). A later entry wins on a name clash, matching |
| 37 | // the prompt's command>skill precedence when the caller orders them that way. |
| 38 | func NewSlashCommandTool(entries []SlashEntry) tool.Tool { |
| 39 | m := make(map[string]SlashEntry, len(entries)) |
| 40 | for _, e := range entries { |
| 41 | name := strings.TrimPrefix(strings.TrimSpace(e.Name), "/") |
| 42 | if name == "" { |
| 43 | continue |
| 44 | } |
| 45 | e.Name = name |
| 46 | m[name] = e |
| 47 | } |
| 48 | names := make([]string, 0, len(m)) |
| 49 | for n := range m { |
| 50 | names = append(names, n) |
| 51 | } |
| 52 | sort.Strings(names) |
| 53 | return &slashCommandTool{entries: m, names: names} |
| 54 | } |
| 55 | |
| 56 | func (*slashCommandTool) Name() string { return "slash_command" } |
| 57 | |
| 58 | func (*slashCommandTool) ReadOnly() bool { return true } |
| 59 | |
| 60 | func (t *slashCommandTool) Description() string { |
| 61 | var b strings.Builder |
| 62 | b.WriteString("Invoke a project slash command (a reusable prompt template or skill) by name. " + |
| 63 | "Returns the command's expanded prompt text for you to act on in this turn — it does not run on its own. " + |
| 64 | "Call with an empty command (or \"list\") to see what's available. ") |
| 65 | if len(t.names) == 0 { |
| 66 | b.WriteString("No slash commands are configured in this project.") |
| 67 | return b.String() |
| 68 | } |
| 69 | b.WriteString("Available: ") |
| 70 | for i, n := range t.names { |
| 71 | if i > 0 { |
| 72 | b.WriteString(", ") |
| 73 | } |
| 74 | b.WriteString(n) |
| 75 | } |
| 76 | b.WriteString(".") |
| 77 | return b.String() |
| 78 | } |
| 79 | |
| 80 | func (*slashCommandTool) Schema() json.RawMessage { |
| 81 | return json.RawMessage(`{ |
| 82 | "type": "object", |
| 83 | "properties": { |
| 84 | "command": {"type": "string", "description": "Slash command name (with or without a leading slash). Empty or \"list\" returns the available commands."}, |
| 85 | "arguments": {"type": "string", "description": "Arguments passed to the command, as you'd type them after the name (space-separated)."} |
| 86 | } |
| 87 | }`) |
| 88 | } |
| 89 | |
| 90 | func (t *slashCommandTool) Execute(_ context.Context, raw json.RawMessage) (string, error) { |
| 91 | var p struct { |
| 92 | Command string `json:"command"` |
| 93 | Arguments string `json:"arguments"` |
| 94 | } |
| 95 | if len(raw) > 0 { |
| 96 | if err := json.Unmarshal(raw, &p); err != nil { |
| 97 | return "", fmt.Errorf("invalid args: %w", err) |
| 98 | } |
| 99 | } |
| 100 | name := strings.TrimPrefix(strings.TrimSpace(p.Command), "/") |
| 101 | if name == "" || strings.EqualFold(name, "list") { |
| 102 | return t.list(), nil |
| 103 | } |
| 104 | e, ok := t.entries[name] |
| 105 | if !ok { |
| 106 | return "", fmt.Errorf("no slash command %q; available: %s", name, strings.Join(t.names, ", ")) |
| 107 | } |
| 108 | args := strings.Fields(p.Arguments) |
| 109 | expanded := e.Render(args) |
| 110 | // Frame the expansion so the model treats it as an instruction to follow now, |
| 111 | // not as data to echo back. |
| 112 | return fmt.Sprintf("Expanded /%s — follow these instructions now:\n\n%s", name, expanded), nil |
| 113 | } |
| 114 | |
| 115 | func (t *slashCommandTool) list() string { |
| 116 | if len(t.names) == 0 { |
| 117 | return "No slash commands are configured in this project." |
| 118 | } |
| 119 | var b strings.Builder |
| 120 | b.WriteString("Available slash commands:\n") |
| 121 | for _, n := range t.names { |
| 122 | e := t.entries[n] |
| 123 | line := "- /" + n |
| 124 | if e.ArgHint != "" { |
| 125 | line += " " + e.ArgHint |
| 126 | } |
| 127 | if e.Description != "" { |
| 128 | line += " — " + e.Description |
| 129 | } |
| 130 | b.WriteString(line + "\n") |
| 131 | } |
| 132 | return strings.TrimRight(b.String(), "\n") |
| 133 | } |
| 134 |