| 1 | // Package command loads custom slash commands from Markdown files. A command is |
| 2 | // a prompt template: invoking /name substitutes the arguments into the body and |
| 3 | // sends the result as a chat turn. Loading is pure and dependency-free — a small |
| 4 | // "key: value" frontmatter parser keeps Reasonix's single-(TOML)-dependency promise |
| 5 | // rather than pulling in a YAML library. |
| 6 | package command |
| 7 | |
| 8 | import ( |
| 9 | "fmt" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "regexp" |
| 13 | "sort" |
| 14 | "strconv" |
| 15 | "strings" |
| 16 | |
| 17 | fileencoding "reasonix/internal/fileutil/encoding" |
| 18 | "reasonix/internal/frontmatter" |
| 19 | ) |
| 20 | |
| 21 | // Command is a custom slash command loaded from a .md file. |
| 22 | type Command struct { |
| 23 | Name string // "review" or "git:commit", derived from the file path |
| 24 | Description string // from frontmatter |
| 25 | ArgHint string // from frontmatter (argument-hint) |
| 26 | Body string // template with $ARGUMENTS / $1..$N / $$ |
| 27 | Source string // originating file path, for diagnostics |
| 28 | Plugin string // installed plugin package name; empty for user/project commands |
| 29 | ShortName string // original plugin command name before the package qualifier |
| 30 | Hidden bool // compatibility-only short alias; invocable but omitted from listings |
| 31 | } |
| 32 | |
| 33 | // Root is one command directory and its optional plugin-package owner. Plugin |
| 34 | // ownership is carried through loading so plugin commands can use stable, |
| 35 | // package-qualified display names without losing unambiguous short-name |
| 36 | // compatibility. |
| 37 | type Root struct { |
| 38 | Path string |
| 39 | Plugin string |
| 40 | } |
| 41 | |
| 42 | // substRe matches the substitution tokens recognised in a command body. |
| 43 | var substRe = regexp.MustCompile(`\$(\$|ARGUMENTS|[0-9]+)`) |
| 44 | |
| 45 | // Render substitutes args into the command body: $ARGUMENTS is all args joined |
| 46 | // by spaces, $1..$N are positional (empty when absent), and $$ is a literal $. |
| 47 | func (c Command) Render(args []string) string { |
| 48 | return substRe.ReplaceAllStringFunc(c.Body, func(m string) string { |
| 49 | switch tok := m[1:]; tok { |
| 50 | case "$": |
| 51 | return "$" |
| 52 | case "ARGUMENTS": |
| 53 | return strings.Join(args, " ") |
| 54 | default: |
| 55 | n, _ := strconv.Atoi(tok) // regex guarantees digits |
| 56 | if n >= 1 && n <= len(args) { |
| 57 | return args[n-1] |
| 58 | } |
| 59 | return "" |
| 60 | } |
| 61 | }) |
| 62 | } |
| 63 | |
| 64 | // Load reads every *.md command file under each dir, in order, so a later dir |
| 65 | // overrides an earlier one on a name clash (pass the user dir first, project |
| 66 | // dir last). Missing dirs are skipped. Individual file failures are collected |
| 67 | // into the returned error but don't prevent the others from loading. The result |
| 68 | // is sorted by name. |
| 69 | func Load(dirs ...string) ([]Command, error) { |
| 70 | roots := make([]Root, 0, len(dirs)) |
| 71 | for _, dir := range dirs { |
| 72 | roots = append(roots, Root{Path: dir}) |
| 73 | } |
| 74 | return LoadRoots(roots...) |
| 75 | } |
| 76 | |
| 77 | // LoadRoots is Load with optional plugin ownership. Every plugin command is |
| 78 | // exposed canonically as /<plugin>:<name>. A short /<name> compatibility alias |
| 79 | // is retained only when exactly one plugin contributes that name and no user or |
| 80 | // project command owns it; the alias is hidden from completion and model-visible |
| 81 | // listings. An explicit command occupying the qualified name still wins. |
| 82 | func LoadRoots(roots ...Root) ([]Command, error) { |
| 83 | byName := map[string]Command{} |
| 84 | pluginCommands := map[string]map[string]Command{} |
| 85 | var errs []string |
| 86 | for _, spec := range roots { |
| 87 | root, err := filepath.Abs(spec.Path) |
| 88 | if err != nil { |
| 89 | continue |
| 90 | } |
| 91 | // A symlink-following walk (filepath.WalkDir does not follow links), so a |
| 92 | // symlinked command directory or a symlinked <name>.md is picked up like a |
| 93 | // real one. visited (keyed by resolved path) guards against symlink cycles. |
| 94 | visited := map[string]bool{} |
| 95 | if real, err := filepath.EvalSymlinks(root); err == nil { |
| 96 | visited[real] = true |
| 97 | } else { |
| 98 | visited[root] = true |
| 99 | } |
| 100 | walkCommands(root, root, visited, func(path string) { |
| 101 | c, perr := parseFile(root, path) |
| 102 | if perr != nil { |
| 103 | errs = append(errs, perr.Error()) |
| 104 | return |
| 105 | } |
| 106 | c.Plugin = strings.TrimSpace(spec.Plugin) |
| 107 | if c.Plugin == "" { |
| 108 | byName[c.Name] = c |
| 109 | } else { |
| 110 | if pluginCommands[c.Plugin] == nil { |
| 111 | pluginCommands[c.Plugin] = map[string]Command{} |
| 112 | } |
| 113 | pluginCommands[c.Plugin][c.Name] = c |
| 114 | } |
| 115 | }) |
| 116 | } |
| 117 | byShortName := map[string][]Command{} |
| 118 | for plugin, commands := range pluginCommands { |
| 119 | for shortName, pluginCommand := range commands { |
| 120 | pluginCommand.ShortName = shortName |
| 121 | byShortName[shortName] = append(byShortName[shortName], pluginCommand) |
| 122 | qualified := plugin + ":" + shortName |
| 123 | if _, occupied := byName[qualified]; occupied { |
| 124 | continue |
| 125 | } |
| 126 | pluginCommand.Name = qualified |
| 127 | byName[qualified] = pluginCommand |
| 128 | } |
| 129 | } |
| 130 | for shortName, candidates := range byShortName { |
| 131 | if _, occupied := byName[shortName]; occupied || len(candidates) != 1 { |
| 132 | continue |
| 133 | } |
| 134 | compat := candidates[0] |
| 135 | compat.Name = shortName |
| 136 | compat.Hidden = true |
| 137 | byName[shortName] = compat |
| 138 | } |
| 139 | cmds := make([]Command, 0, len(byName)) |
| 140 | for _, c := range byName { |
| 141 | cmds = append(cmds, c) |
| 142 | } |
| 143 | sort.Slice(cmds, func(i, j int) bool { return cmds[i].Name < cmds[j].Name }) |
| 144 | if len(errs) > 0 { |
| 145 | return cmds, fmt.Errorf("command load: %s", strings.Join(errs, "; ")) |
| 146 | } |
| 147 | return cmds, nil |
| 148 | } |
| 149 | |
| 150 | // walkCommands recursively visits dir, following symlinks, and calls fn with the |
| 151 | // path of every *.md file (including symlinked files and files under symlinked |
| 152 | // directories). visited (resolved-path set) prevents infinite recursion through |
| 153 | // a symlink cycle. Unreadable directories are skipped, never fatal. |
| 154 | func walkCommands(root, dir string, visited map[string]bool, fn func(path string)) { |
| 155 | entries, err := os.ReadDir(dir) |
| 156 | if err != nil { |
| 157 | return |
| 158 | } |
| 159 | for _, e := range entries { |
| 160 | full := filepath.Join(dir, e.Name()) |
| 161 | isDir := e.IsDir() |
| 162 | isFile := e.Type().IsRegular() |
| 163 | if e.Type()&os.ModeSymlink != 0 { |
| 164 | info, serr := os.Stat(full) // follow the link |
| 165 | if serr != nil { |
| 166 | continue // broken link |
| 167 | } |
| 168 | isDir = info.IsDir() |
| 169 | isFile = info.Mode().IsRegular() |
| 170 | } |
| 171 | switch { |
| 172 | case isDir: |
| 173 | real, rerr := filepath.EvalSymlinks(full) |
| 174 | if rerr != nil { |
| 175 | real = full |
| 176 | } |
| 177 | if visited[real] { |
| 178 | continue |
| 179 | } |
| 180 | visited[real] = true |
| 181 | walkCommands(root, full, visited, fn) |
| 182 | case isFile && strings.EqualFold(filepath.Ext(e.Name()), ".md"): |
| 183 | fn(full) |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // parseFile reads one command file and derives its name from the path relative |
| 189 | // to root: drop the .md suffix and turn subdirectories into ":" namespaces |
| 190 | // (git/commit.md → git:commit). |
| 191 | func parseFile(root, path string) (Command, error) { |
| 192 | b, err := fileencoding.ReadFileUTF8(path) |
| 193 | if err != nil { |
| 194 | return Command{}, err |
| 195 | } |
| 196 | rel, err := filepath.Rel(root, path) |
| 197 | if err != nil { |
| 198 | rel = filepath.Base(path) |
| 199 | } |
| 200 | name := strings.ReplaceAll(strings.TrimSuffix(filepath.ToSlash(rel), ".md"), "/", ":") |
| 201 | |
| 202 | // Normalise line endings and strip a leading UTF-8 BOM if present. |
| 203 | content := strings.TrimPrefix(strings.ReplaceAll(string(b), "\r\n", "\n"), string(rune(0xFEFF))) |
| 204 | fm, body := frontmatter.Split(content) |
| 205 | return Command{ |
| 206 | Name: name, |
| 207 | Description: fm["description"], |
| 208 | ArgHint: fm["argument-hint"], |
| 209 | Body: strings.TrimSpace(body), |
| 210 | Source: path, |
| 211 | }, nil |
| 212 | } |
| 213 | |
| 214 | // splitFrontmatter is a thin wrapper kept for test compatibility; the real |
| 215 | // parser lives in internal/frontmatter. |
| 216 | func splitFrontmatter(s string) (map[string]string, string) { |
| 217 | return frontmatter.Split(s) |
| 218 | } |
| 219 |