返回 DeepSeek-Reasonix
plugin.go
根目录 / internal / cli / plugin.go
1 package cli
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11
12 "reasonix/internal/config"
13 "reasonix/internal/hook"
14 "reasonix/internal/installsource"
15 "reasonix/internal/pluginpkg"
16 )
17
18 func pluginCommand(args []string) int {
19 if len(args) == 0 {
20 pluginUsage()
21 return 0
22 }
23 switch args[0] {
24 case "install":
25 return pluginInstallCommand(args[1:])
26 case "list":
27 return pluginListCommand()
28 case "show":
29 return pluginShowCommand(args[1:])
30 case "remove", "uninstall":
31 return pluginRemoveCommand(args[1:])
32 case "enable":
33 return pluginSetEnabledCommand(args[1:], true)
34 case "disable":
35 return pluginSetEnabledCommand(args[1:], false)
36 case "doctor":
37 return pluginDoctorCommand(args[1:])
38 case "help", "--help", "-h":
39 pluginUsage()
40 return 0
41 default:
42 fmt.Fprintf(os.Stderr, "unknown plugin command %q\n\n", args[0])
43 pluginUsage()
44 return 2
45 }
46 }
47
48 func pluginUsage() {
49 fmt.Fprintln(os.Stderr, `usage:
50 reasonix plugin install <source> [--yes] [--dry-run] [--link] [--replace]
51 reasonix plugin list
52 reasonix plugin show <name>
53 reasonix plugin enable <name>
54 reasonix plugin disable <name>
55 reasonix plugin remove <name>
56 reasonix plugin doctor <name>`)
57 }
58
59 func pluginInstallCommand(args []string) int {
60 opts, source, err := parsePluginInstallArgs(args)
61 if err != nil {
62 fmt.Fprintln(os.Stderr, err)
63 return 2
64 }
65 if !opts.dryRun && !opts.yes {
66 fmt.Fprintln(os.Stderr, "plugin install writes files; re-run with --yes to apply, or --dry-run to preview")
67 return 2
68 }
69 mode := "copy"
70 if opts.link {
71 mode = "link"
72 }
73 body := map[string]any{
74 "source": source,
75 "kind": "plugin",
76 "apply": !opts.dryRun,
77 "mode": mode,
78 "replace": opts.replace,
79 }
80 if strings.TrimSpace(opts.name) != "" {
81 body["name"] = strings.TrimSpace(opts.name)
82 }
83 return runInstallSourceJSON(body)
84 }
85
86 type parsedPluginInstallArgs struct {
87 yes bool
88 dryRun bool
89 link bool
90 replace bool
91 name string
92 }
93
94 func parsePluginInstallArgs(args []string) (parsedPluginInstallArgs, string, error) {
95 var opts parsedPluginInstallArgs
96 var source string
97 for i := 0; i < len(args); i++ {
98 arg := args[i]
99 switch {
100 case arg == "--yes":
101 opts.yes = true
102 case arg == "--dry-run":
103 opts.dryRun = true
104 case arg == "--link":
105 opts.link = true
106 case arg == "--replace":
107 opts.replace = true
108 case arg == "--name":
109 i++
110 if i >= len(args) {
111 return opts, "", fmt.Errorf("--name requires a value")
112 }
113 opts.name = args[i]
114 case strings.HasPrefix(arg, "--name="):
115 opts.name = strings.TrimPrefix(arg, "--name=")
116 case strings.HasPrefix(arg, "-"):
117 return opts, "", fmt.Errorf("unknown plugin install flag %q", arg)
118 default:
119 if source != "" {
120 return opts, "", fmt.Errorf("plugin install requires exactly one source")
121 }
122 source = arg
123 }
124 }
125 if source == "" {
126 return opts, "", fmt.Errorf("plugin install requires exactly one source")
127 }
128 return opts, source, nil
129 }
130
131 func pluginRemoveCommand(args []string) int {
132 name, yes, err := parsePluginRemoveArgs(args)
133 if err != nil {
134 fmt.Fprintln(os.Stderr, err)
135 return 2
136 }
137 if !yes {
138 fmt.Fprintln(os.Stderr, "plugin remove writes files; re-run with --yes to apply")
139 return 2
140 }
141 return runInstallSourceJSON(map[string]any{"op": "uninstall", "kind": "plugin", "name": name, "scope": "global"})
142 }
143
144 func parsePluginRemoveArgs(args []string) (string, bool, error) {
145 var name string
146 var yes bool
147 for _, arg := range args {
148 switch arg {
149 case "--yes":
150 yes = true
151 default:
152 if strings.HasPrefix(arg, "-") {
153 return "", false, fmt.Errorf("unknown plugin remove flag %q", arg)
154 }
155 if name != "" {
156 return "", false, fmt.Errorf("plugin remove requires a plugin name")
157 }
158 name = arg
159 }
160 }
161 if name == "" {
162 return "", false, fmt.Errorf("plugin remove requires a plugin name")
163 }
164 return name, yes, nil
165 }
166
167 func runInstallSourceJSON(body map[string]any) int {
168 raw, _ := json.Marshal(body)
169 tl := installsource.NewTool(installsource.Options{})
170 out, err := tl.Execute(context.Background(), raw)
171 if err != nil {
172 fmt.Fprintln(os.Stderr, err)
173 return 1
174 }
175 fmt.Println(out)
176 var resp struct {
177 OK bool `json:"ok"`
178 }
179 if err := json.Unmarshal([]byte(out), &resp); err != nil {
180 fmt.Fprintln(os.Stderr, err)
181 return 1
182 }
183 if !resp.OK {
184 return 1
185 }
186 return 0
187 }
188
189 func pluginListCommand() int {
190 st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
191 if err != nil {
192 fmt.Fprintln(os.Stderr, err)
193 return 1
194 }
195 if len(st.Plugins) == 0 {
196 fmt.Println("no plugins installed")
197 return 0
198 }
199 for _, p := range st.Plugins {
200 state := "disabled"
201 if p.Enabled {
202 state = "enabled"
203 }
204 version := p.Version
205 if version == "" {
206 version = "-"
207 }
208 fmt.Printf("%s\t%s\t%s\t%s\n", p.Name, state, version, p.Source)
209 }
210 return 0
211 }
212
213 func pluginShowCommand(args []string) int {
214 if len(args) != 1 {
215 fmt.Fprintln(os.Stderr, "plugin show requires a plugin name")
216 return 2
217 }
218 p, ok, err := findInstalledPlugin(args[0])
219 if err != nil {
220 fmt.Fprintln(os.Stderr, err)
221 return 1
222 }
223 if !ok {
224 fmt.Fprintf(os.Stderr, "plugin %q is not installed\n", args[0])
225 return 1
226 }
227 root := pluginpkg.ResolveRoot(config.ReasonixHomeDir(), p.Root)
228 pkg, warnings, err := pluginpkg.ParseDir(root)
229 if err != nil {
230 fmt.Fprintln(os.Stderr, err)
231 return 1
232 }
233 summary := pkg.CapabilitySummary()
234 fmt.Printf("name: %s\nversion: %s\nenabled: %t\nkind: %s\nroot: %s\nsource: %s\nskills: %d\ncommands: %d\nprompts: %d\nhooks: %d\nmcpServers: %d\nthemes: %d\n",
235 p.Name, p.Version, p.Enabled, p.ManifestKind, root, p.Source, summary.Skills, summary.Commands, summary.Prompts, summary.Hooks, summary.MCPServers, summary.Themes)
236 if summary.Runtime {
237 fmt.Print(pluginpkg.RuntimeTrustText(pkg.Manifest.Runtime))
238 }
239 printPluginInventory(p.Name, pkg.Inventory())
240 for _, warning := range warnings {
241 fmt.Println("warning:", warning)
242 }
243 return 0
244 }
245
246 func printPluginInventory(pluginName string, inv pluginpkg.Inventory) {
247 if len(inv.Skills) > 0 {
248 fmt.Println("usage:")
249 fmt.Println(" skills are available in interactive sessions; run /skills to browse them, or invoke a skill directly with /<plugin>:<name>.")
250 fmt.Println("skills:")
251 for _, sk := range inv.Skills {
252 desc := sk.Description
253 if desc == "" {
254 desc = "(no description)"
255 }
256 invocation := "/" + pluginName + ":" + sk.Name
257 if sk.RunAs != "" {
258 fmt.Printf(" %s\t%s\t%s\n", invocation, sk.RunAs, desc)
259 } else {
260 fmt.Printf(" %s\t%s\n", invocation, desc)
261 }
262 }
263 }
264 if len(inv.Commands) > 0 {
265 fmt.Println("commands:")
266 for _, cmd := range inv.Commands {
267 desc := cmd.Description
268 if desc == "" {
269 desc = "(no description)"
270 }
271 invocation := "/" + pluginName + ":" + cmd.Name
272 if cmd.ArgHint != "" {
273 fmt.Printf(" %s %s\t%s\n", invocation, cmd.ArgHint, desc)
274 } else {
275 fmt.Printf(" %s\t%s\n", invocation, desc)
276 }
277 }
278 }
279 if len(inv.Prompts) > 0 {
280 fmt.Println("prompts:")
281 for _, pr := range inv.Prompts {
282 desc := pr.Description
283 if desc == "" {
284 desc = "(no description)"
285 }
286 if pr.ArgHint != "" {
287 fmt.Printf(" %s %s\t%s\n", pr.Name, pr.ArgHint, desc)
288 } else {
289 fmt.Printf(" %s\t%s\n", pr.Name, desc)
290 }
291 }
292 }
293 if len(inv.Themes) > 0 {
294 fmt.Println("themes:")
295 for _, theme := range inv.Themes {
296 fmt.Printf(" %s\t%s\n", theme.Name, theme.Path)
297 }
298 }
299 if len(inv.Hooks) > 0 {
300 fmt.Println("hooks:")
301 for _, hook := range inv.Hooks {
302 target := hook.Command
303 if target == "" {
304 target = hook.ContextFile
305 }
306 match := hook.Match
307 if match == "" {
308 match = "*"
309 }
310 if hook.Description != "" {
311 fmt.Printf(" %s\tmatch=%s\t%s\t%s\n", hook.Event, match, target, hook.Description)
312 } else {
313 fmt.Printf(" %s\tmatch=%s\t%s\n", hook.Event, match, target)
314 }
315 }
316 }
317 if len(inv.MCPServers) > 0 {
318 fmt.Println("mcpServers:")
319 for _, server := range inv.MCPServers {
320 target := server.Command
321 if target == "" {
322 target = server.URL
323 }
324 fmt.Printf(" %s\t%s\t%s\n", server.Name, server.Transport, target)
325 }
326 }
327 }
328
329 func pluginDoctorCommand(args []string) int {
330 if len(args) != 1 {
331 fmt.Fprintln(os.Stderr, "plugin doctor requires a plugin name")
332 return 2
333 }
334 p, ok, err := findInstalledPlugin(args[0])
335 if err != nil {
336 fmt.Fprintln(os.Stderr, err)
337 return 1
338 }
339 if !ok {
340 fmt.Fprintf(os.Stderr, "plugin %q is not installed\n", args[0])
341 return 1
342 }
343 root := pluginpkg.ResolveRoot(config.ReasonixHomeDir(), p.Root)
344 pkg, warnings, err := pluginpkg.ParseDir(root)
345 if err != nil {
346 fmt.Fprintln(os.Stderr, "invalid:", err)
347 return 1
348 }
349 for _, skillRoot := range pkg.SkillRoots() {
350 if st, err := os.Stat(skillRoot); err != nil || !st.IsDir() {
351 fmt.Fprintf(os.Stderr, "missing skill root: %s\n", skillRoot)
352 return 1
353 }
354 }
355 for _, commandRoot := range pkg.CommandRoots() {
356 if st, err := os.Stat(commandRoot); err != nil || !st.IsDir() {
357 fmt.Fprintf(os.Stderr, "missing command root: %s\n", commandRoot)
358 return 1
359 }
360 }
361 for _, promptRoot := range pkg.PromptRoots() {
362 if st, err := os.Stat(promptRoot); err != nil || !st.IsDir() {
363 fmt.Fprintf(os.Stderr, "missing prompt root: %s\n", promptRoot)
364 return 1
365 }
366 }
367 if rt := pkg.Manifest.Runtime; rt != nil {
368 fmt.Print(pluginpkg.RuntimeTrustText(rt))
369 if err := checkRuntimeCommand(rt, root); err != nil {
370 fmt.Fprintln(os.Stderr, err)
371 return 1
372 }
373 }
374 for _, warning := range warnings {
375 fmt.Println("warning:", warning)
376 }
377 workspaceRoot, _ := os.Getwd()
378 cfg, _ := config.LoadForRootReadOnly(workspaceRoot)
379 runtimeOptions := hook.RuntimeOptions{}
380 if cfg != nil {
381 runtimeOptions = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path)
382 }
383 runtimeIssues := hook.CheckPackageRuntime(pkg, runtimeOptions)
384 for _, issue := range runtimeIssues {
385 fmt.Fprintf(os.Stderr, "unavailable %s hook: %v\n", issue.Event, issue.Err)
386 }
387 if len(runtimeIssues) > 0 {
388 fmt.Fprintln(os.Stderr, "remediation: install Git for Windows, or configure [tools.shell] prefer=\"bash\" and path to a usable bash.exe")
389 return 1
390 }
391 fmt.Printf("ok: %s (%s)\n", p.Name, filepath.Clean(root))
392 return 0
393 }
394
395 // checkRuntimeCommand verifies a Manifest v1 runtime command resolves to
396 // something runnable. ${REASONIX_PLUGIN_ROOT} expands to the installed root;
397 // other relative path forms resolve against the plugin root. Bare executable
398 // names are looked up on PATH (a miss is a warning, not a failure — PATH
399 // varies by environment).
400 func checkRuntimeCommand(rt *pluginpkg.RuntimeSpec, root string) error {
401 expanded := pluginpkg.ExpandRuntimeCommand(rt.Command, root)
402 pathForm := filepath.IsAbs(expanded) || strings.ContainsRune(expanded, '/') || strings.ContainsRune(expanded, filepath.Separator)
403 if !pathForm {
404 if _, err := exec.LookPath(expanded); err != nil {
405 fmt.Printf("warning: runtime command %q not found on PATH\n", expanded)
406 }
407 return nil
408 }
409 if !filepath.IsAbs(expanded) {
410 expanded = filepath.Join(root, filepath.FromSlash(expanded))
411 }
412 info, err := os.Stat(expanded)
413 if err != nil || info.IsDir() {
414 return fmt.Errorf("runtime command not found: %s", expanded)
415 }
416 return nil
417 }
418
419 func pluginSetEnabledCommand(args []string, enabled bool) int {
420 if len(args) != 1 {
421 fmt.Fprintln(os.Stderr, "plugin enable/disable requires a plugin name")
422 return 2
423 }
424 if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), args[0], enabled); err != nil {
425 fmt.Fprintln(os.Stderr, err)
426 return 1
427 }
428 fmt.Printf("%s %s\n", map[bool]string{true: "enabled", false: "disabled"}[enabled], args[0])
429 return 0
430 }
431
432 func findInstalledPlugin(name string) (pluginpkg.InstalledPlugin, bool, error) {
433 st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
434 if err != nil {
435 return pluginpkg.InstalledPlugin{}, false, err
436 }
437 for _, p := range st.Plugins {
438 if p.Name == name {
439 return p, true, nil
440 }
441 }
442 return pluginpkg.InstalledPlugin{}, false, nil
443 }
444
444 lines GO