返回 DeepSeek-Reasonix
command.go
根目录 / internal / command / command.go
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
219 lines GO