返回 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 "reasonix/internal/secrets"
17 )
18
19 func pluginCommand(args []string) int {
20 if len(args) == 0 {
21 pluginUsage()
22 return 0
23 }
24 switch args[0] {
25 case "install":
26 return pluginInstallCommand(args[1:])
27 case "list":
28 return pluginListCommand()
29 case "show":
30 return pluginShowCommand(args[1:])
31 case "remove", "uninstall":
32 return pluginRemoveCommand(args[1:])
33 case "enable":
34 return pluginSetEnabledCommand(args[1:], true)
35 case "disable":
36 return pluginSetEnabledCommand(args[1:], false)
37 case "doctor":
38 return pluginDoctorCommand(args[1:])
39 case "migrate":
40 return pluginMigrateCommand(args[1:])
41 case "help", "--help", "-h":
42 pluginUsage()
43 return 0
44 default:
45 fmt.Fprintf(os.Stderr, "unknown plugin command %q\n\n", args[0])
46 pluginUsage()
47 return 2
48 }
49 }
50
51 func pluginUsage() {
52 fmt.Fprintln(os.Stderr, `usage:
53 reasonix plugin install <source> [--yes] [--dry-run] [--link] [--replace]
54 reasonix plugin list
55 reasonix plugin show <name>
56 reasonix plugin enable <name>
57 reasonix plugin disable <name>
58 reasonix plugin remove <name>
59 reasonix plugin doctor <name>
60 reasonix plugin migrate <name> --to-v2`)
61 }
62
63 func pluginInstallCommand(args []string) int {
64 opts, source, err := parsePluginInstallArgs(args)
65 if err != nil {
66 fmt.Fprintln(os.Stderr, err)
67 return 2
68 }
69 if !opts.dryRun && !opts.yes {
70 fmt.Fprintln(os.Stderr, "plugin install writes files; re-run with --yes to apply, or --dry-run to preview")
71 return 2
72 }
73 mode := "copy"
74 if opts.link {
75 mode = "link"
76 }
77 body := map[string]any{
78 "source": source,
79 "kind": "plugin",
80 "apply": !opts.dryRun,
81 "mode": mode,
82 "replace": opts.replace,
83 }
84 if strings.TrimSpace(opts.name) != "" {
85 body["name"] = strings.TrimSpace(opts.name)
86 }
87 return runInstallSourceJSON(body)
88 }
89
90 type parsedPluginInstallArgs struct {
91 yes bool
92 dryRun bool
93 link bool
94 replace bool
95 name string
96 }
97
98 func parsePluginInstallArgs(args []string) (parsedPluginInstallArgs, string, error) {
99 var opts parsedPluginInstallArgs
100 var source string
101 for i := 0; i < len(args); i++ {
102 arg := args[i]
103 switch {
104 case arg == "--yes":
105 opts.yes = true
106 case arg == "--dry-run":
107 opts.dryRun = true
108 case arg == "--link":
109 opts.link = true
110 case arg == "--replace":
111 opts.replace = true
112 case arg == "--name":
113 i++
114 if i >= len(args) {
115 return opts, "", fmt.Errorf("--name requires a value")
116 }
117 opts.name = args[i]
118 case strings.HasPrefix(arg, "--name="):
119 opts.name = strings.TrimPrefix(arg, "--name=")
120 case strings.HasPrefix(arg, "-"):
121 return opts, "", fmt.Errorf("unknown plugin install flag %q", arg)
122 default:
123 if source != "" {
124 return opts, "", fmt.Errorf("plugin install requires exactly one source")
125 }
126 source = arg
127 }
128 }
129 if source == "" {
130 return opts, "", fmt.Errorf("plugin install requires exactly one source")
131 }
132 return opts, source, nil
133 }
134
135 func pluginRemoveCommand(args []string) int {
136 name, yes, err := parsePluginRemoveArgs(args)
137 if err != nil {
138 fmt.Fprintln(os.Stderr, err)
139 return 2
140 }
141 if !yes {
142 fmt.Fprintln(os.Stderr, "plugin remove writes files; re-run with --yes to apply")
143 return 2
144 }
145 return runInstallSourceJSON(map[string]any{"op": "uninstall", "kind": "plugin", "name": name, "scope": "global"})
146 }
147
148 func parsePluginRemoveArgs(args []string) (string, bool, error) {
149 var name string
150 var yes bool
151 for _, arg := range args {
152 switch arg {
153 case "--yes":
154 yes = true
155 default:
156 if strings.HasPrefix(arg, "-") {
157 return "", false, fmt.Errorf("unknown plugin remove flag %q", arg)
158 }
159 if name != "" {
160 return "", false, fmt.Errorf("plugin remove requires a plugin name")
161 }
162 name = arg
163 }
164 }
165 if name == "" {
166 return "", false, fmt.Errorf("plugin remove requires a plugin name")
167 }
168 return name, yes, nil
169 }
170
171 func runInstallSourceJSON(body map[string]any) int {
172 raw, _ := json.Marshal(body)
173 tl := installsource.NewTool(installsource.Options{})
174 out, err := tl.Execute(context.Background(), raw)
175 if err != nil {
176 fmt.Fprintln(os.Stderr, err)
177 return 1
178 }
179 var resp struct {
180 OK bool `json:"ok"`
181 Status string `json:"status"`
182 }
183 if err := json.Unmarshal([]byte(out), &resp); err != nil {
184 fmt.Fprintln(os.Stderr, err)
185 return 1
186 }
187 encoded, err := redactInstallSourceJSON(out)
188 if err != nil {
189 fmt.Fprintln(os.Stderr, err)
190 return 1
191 }
192 fmt.Println(encoded)
193 if !resp.OK {
194 return 1
195 }
196 return 0
197 }
198
199 // Preserve the install plan and failure contract while scrubbing every string
200 // value. Decode/re-encode keeps quotes and escaping valid after redaction.
201 func redactInstallSourceJSON(raw string) (string, error) {
202 var value any
203 if err := json.Unmarshal([]byte(raw), &value); err != nil {
204 return "", err
205 }
206 // planId is an engine-generated approval identity, not free-form output.
207 // Scrubbing its digest would make the preview impossible to approve.
208 var planID string
209 if object, ok := value.(map[string]any); ok {
210 planID, _ = object["planId"].(string)
211 }
212 var redact func(any) any
213 redact = func(v any) any {
214 switch x := v.(type) {
215 case string:
216 return secrets.RedactCredentials(x)
217 case []any:
218 for i := range x {
219 x[i] = redact(x[i])
220 }
221 case map[string]any:
222 for key, item := range x {
223 if secrets.EnvKeySensitive(key) {
224 x[key] = "[REDACTED]"
225 } else {
226 x[key] = redact(item)
227 }
228 }
229 }
230 return v
231 }
232 redacted := redact(value)
233 if object, ok := redacted.(map[string]any); ok && planID != "" {
234 object["planId"] = planID
235 }
236 encoded, err := json.Marshal(redacted)
237 return string(encoded), err
238 }
239
240 func pluginListCommand() int {
241 st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
242 if err != nil {
243 fmt.Fprintln(os.Stderr, err)
244 return 1
245 }
246 if len(st.Plugins) == 0 {
247 fmt.Println("no plugins installed")
248 return 0
249 }
250 for _, p := range st.Plugins {
251 state := "disabled"
252 if p.Enabled {
253 state = "enabled"
254 }
255 if strings.TrimSpace(p.Status) != "" {
256 state = p.Status
257 }
258 version := p.Version
259 if version == "" {
260 version = "-"
261 }
262 fmt.Printf("%s\t%s\t%s\t%s\n", p.Name, state, version, p.Source)
263 }
264 return 0
265 }
266
267 func pluginShowCommand(args []string) int {
268 if len(args) != 1 {
269 fmt.Fprintln(os.Stderr, "plugin show requires a plugin name")
270 return 2
271 }
272 p, ok, err := findInstalledPlugin(args[0])
273 if err != nil {
274 fmt.Fprintln(os.Stderr, err)
275 return 1
276 }
277 if !ok {
278 fmt.Fprintf(os.Stderr, "plugin %q is not installed\n", args[0])
279 return 1
280 }
281 root := pluginpkg.ResolveRoot(config.ReasonixHomeDir(), p.Root)
282 pkg, warnings, err := pluginpkg.ParseDir(root)
283 if err != nil {
284 fmt.Fprintln(os.Stderr, err)
285 return 1
286 }
287 summary := pkg.CapabilitySummary()
288 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",
289 p.Name, p.Version, p.Enabled, p.ManifestKind, root, p.Source, summary.Skills, summary.Commands, summary.Prompts, summary.Hooks, summary.MCPServers, summary.Themes)
290 if summary.Runtime {
291 fmt.Print(pluginpkg.RuntimeTrustText(pkg.Manifest.Runtime))
292 }
293 printPluginInventory(p.Name, pkg.Inventory())
294 for _, warning := range warnings {
295 fmt.Println("warning:", warning)
296 }
297 return 0
298 }
299
300 func printPluginInventory(pluginName string, inv pluginpkg.Inventory) {
301 if len(inv.Skills) > 0 {
302 fmt.Println("usage:")
303 fmt.Println(" skills are available in interactive sessions; run /skills to browse them, or invoke a skill directly with /<plugin>:<name>.")
304 fmt.Println("skills:")
305 for _, sk := range inv.Skills {
306 desc := sk.Description
307 if desc == "" {
308 desc = "(no description)"
309 }
310 invocation := "/" + pluginName + ":" + sk.Name
311 if sk.RunAs != "" {
312 fmt.Printf(" %s\t%s\t%s\n", invocation, sk.RunAs, desc)
313 } else {
314 fmt.Printf(" %s\t%s\n", invocation, desc)
315 }
316 }
317 }
318 if len(inv.Commands) > 0 {
319 fmt.Println("commands:")
320 for _, cmd := range inv.Commands {
321 desc := cmd.Description
322 if desc == "" {
323 desc = "(no description)"
324 }
325 invocation := "/" + pluginName + ":" + cmd.Name
326 if cmd.ArgHint != "" {
327 fmt.Printf(" %s %s\t%s\n", invocation, cmd.ArgHint, desc)
328 } else {
329 fmt.Printf(" %s\t%s\n", invocation, desc)
330 }
331 }
332 }
333 if len(inv.Prompts) > 0 {
334 fmt.Println("prompts:")
335 for _, pr := range inv.Prompts {
336 desc := pr.Description
337 if desc == "" {
338 desc = "(no description)"
339 }
340 if pr.ArgHint != "" {
341 fmt.Printf(" %s %s\t%s\n", pr.Name, pr.ArgHint, desc)
342 } else {
343 fmt.Printf(" %s\t%s\n", pr.Name, desc)
344 }
345 }
346 }
347 if len(inv.Themes) > 0 {
348 fmt.Println("themes:")
349 for _, theme := range inv.Themes {
350 fmt.Printf(" %s\t%s\n", theme.Name, theme.Path)
351 }
352 }
353 if len(inv.Hooks) > 0 {
354 fmt.Println("hooks:")
355 for _, hook := range inv.Hooks {
356 target := hook.Command
357 if target == "" {
358 target = hook.ContextFile
359 }
360 match := hook.Match
361 if match == "" {
362 match = "*"
363 }
364 if hook.Description != "" {
365 fmt.Printf(" %s\tmatch=%s\t%s\t%s\n", hook.Event, match, target, hook.Description)
366 } else {
367 fmt.Printf(" %s\tmatch=%s\t%s\n", hook.Event, match, target)
368 }
369 }
370 }
371 if len(inv.MCPServers) > 0 {
372 fmt.Println("mcpServers:")
373 for _, server := range inv.MCPServers {
374 target := server.Command
375 if target == "" {
376 target = server.URL
377 }
378 fmt.Printf(" %s\t%s\t%s\n", server.Name, server.Transport, target)
379 }
380 }
381 }
382
383 func pluginMigrateCommand(args []string) int {
384 if len(args) < 1 {
385 fmt.Fprintln(os.Stderr, "plugin migrate requires a plugin name")
386 return 2
387 }
388 name := args[0]
389 toV2 := false
390 for _, a := range args[1:] {
391 if a == "--to-v2" {
392 toV2 = true
393 } else {
394 fmt.Fprintf(os.Stderr, "unknown plugin migrate flag %q\n", a)
395 return 2
396 }
397 }
398 if !toV2 {
399 fmt.Fprintln(os.Stderr, "plugin migrate requires --to-v2")
400 return 2
401 }
402 p, ok, err := findInstalledPlugin(name)
403 if err != nil {
404 fmt.Fprintln(os.Stderr, err)
405 return 1
406 }
407 if !ok {
408 fmt.Fprintf(os.Stderr, "plugin %q is not installed\n", name)
409 return 1
410 }
411 root := pluginpkg.ResolveRoot(config.ReasonixHomeDir(), p.Root)
412 pkg, _, err := pluginpkg.ParseNativeForMigrate(root)
413 if err != nil {
414 fmt.Fprintln(os.Stderr, "migrate parse:", err)
415 return 1
416 }
417 data, err := pluginpkg.MigrateManifestToV2(pkg)
418 if err != nil {
419 fmt.Fprintln(os.Stderr, "migrate:", err)
420 return 1
421 }
422 if err := pluginpkg.WriteMigratedManifestV2(root, data); err != nil {
423 fmt.Fprintln(os.Stderr, err)
424 return 1
425 }
426 if _, _, err := pluginpkg.ParseDir(root); err != nil {
427 fmt.Fprintln(os.Stderr, "migrated manifest failed validation:", err)
428 return 1
429 }
430 fmt.Printf("migrated %s to %s (backup: %s.bak)\n", name, pluginpkg.ManifestAPIVersionV2, pluginpkg.NativeManifest)
431 return 0
432 }
433
434 func pluginDoctorCommand(args []string) int {
435 if len(args) != 1 {
436 fmt.Fprintln(os.Stderr, "plugin doctor requires a plugin name")
437 return 2
438 }
439 p, ok, err := findInstalledPlugin(args[0])
440 if err != nil {
441 fmt.Fprintln(os.Stderr, err)
442 return 1
443 }
444 if !ok {
445 fmt.Fprintf(os.Stderr, "plugin %q is not installed\n", args[0])
446 return 1
447 }
448 root := pluginpkg.ResolveRoot(config.ReasonixHomeDir(), p.Root)
449 pkg, warnings, err := pluginpkg.ParseDir(root)
450 if err != nil {
451 fmt.Fprintln(os.Stderr, "invalid:", err)
452 if strings.Contains(err.Error(), "missing apiVersion") {
453 fmt.Fprintf(os.Stderr, "remediation: reasonix plugin migrate %s --to-v2\n", args[0])
454 }
455 return 1
456 }
457 if len(pkg.Manifest.Requires) > 0 {
458 fmt.Println("requires:")
459 for _, r := range pkg.Manifest.Requires {
460 opt := ""
461 if r.Optional {
462 opt = " (optional)"
463 }
464 fmt.Printf(" %s/%s/%s range=%s%s\n", r.Namespace, r.Kind, r.ID, r.VersionRange, opt)
465 }
466 }
467 if len(pkg.Manifest.Provides) > 0 {
468 fmt.Println("provides:")
469 for _, c := range pkg.Manifest.Provides {
470 fmt.Printf(" %s/%s/%s@%s\n", c.Namespace, c.Kind, c.ID, c.Version)
471 }
472 }
473 for _, skillRoot := range pkg.SkillRoots() {
474 if st, err := os.Stat(skillRoot); err != nil || !st.IsDir() {
475 fmt.Fprintf(os.Stderr, "missing skill root: %s\n", skillRoot)
476 return 1
477 }
478 }
479 for _, commandRoot := range pkg.CommandRoots() {
480 if st, err := os.Stat(commandRoot); err != nil || !st.IsDir() {
481 fmt.Fprintf(os.Stderr, "missing command root: %s\n", commandRoot)
482 return 1
483 }
484 }
485 for _, promptRoot := range pkg.PromptRoots() {
486 if st, err := os.Stat(promptRoot); err != nil || !st.IsDir() {
487 fmt.Fprintf(os.Stderr, "missing prompt root: %s\n", promptRoot)
488 return 1
489 }
490 }
491 if rt := pkg.Manifest.Runtime; rt != nil {
492 fmt.Print(pluginpkg.RuntimeTrustText(rt))
493 if err := checkRuntimeCommand(rt, root); err != nil {
494 fmt.Fprintln(os.Stderr, err)
495 return 1
496 }
497 }
498 for _, warning := range warnings {
499 fmt.Println("warning:", warning)
500 }
501 workspaceRoot, _ := os.Getwd()
502 cfg, _ := config.LoadForRootReadOnly(workspaceRoot)
503 runtimeOptions := hook.RuntimeOptions{}
504 if cfg != nil {
505 runtimeOptions = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path)
506 }
507 runtimeIssues := hook.CheckPackageRuntime(pkg, runtimeOptions)
508 for _, issue := range runtimeIssues {
509 fmt.Fprintf(os.Stderr, "unavailable %s hook: %v\n", issue.Event, issue.Err)
510 }
511 if len(runtimeIssues) > 0 {
512 fmt.Fprintln(os.Stderr, "remediation: install Git for Windows, or configure [tools.shell] prefer=\"bash\" and path to a usable bash.exe")
513 return 1
514 }
515 fmt.Printf("ok: %s (%s)\n", p.Name, filepath.Clean(root))
516 return 0
517 }
518
519 // checkRuntimeCommand verifies a Manifest v2 runtime command resolves to
520 // something runnable. ${REASONIX_PLUGIN_ROOT} expands to the installed root;
521 // other relative path forms resolve against the plugin root. Bare executable
522 // names are looked up on PATH (a miss is a warning, not a failure — PATH
523 // varies by environment).
524 func checkRuntimeCommand(rt *pluginpkg.RuntimeSpec, root string) error {
525 expanded := pluginpkg.ExpandRuntimeCommand(rt.Command, root)
526 pathForm := filepath.IsAbs(expanded) || strings.ContainsRune(expanded, '/') || strings.ContainsRune(expanded, filepath.Separator)
527 if !pathForm {
528 if _, err := exec.LookPath(expanded); err != nil {
529 fmt.Printf("warning: runtime command %q not found on PATH\n", expanded)
530 }
531 return nil
532 }
533 if !filepath.IsAbs(expanded) {
534 expanded = filepath.Join(root, filepath.FromSlash(expanded))
535 }
536 info, err := os.Stat(expanded)
537 if err != nil || info.IsDir() {
538 return fmt.Errorf("runtime command not found: %s", expanded)
539 }
540 return nil
541 }
542
543 func pluginSetEnabledCommand(args []string, enabled bool) int {
544 if len(args) != 1 {
545 fmt.Fprintln(os.Stderr, "plugin enable/disable requires a plugin name")
546 return 2
547 }
548 if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), args[0], enabled); err != nil {
549 fmt.Fprintln(os.Stderr, err)
550 return 1
551 }
552 fmt.Printf("%s %s\n", map[bool]string{true: "enabled", false: "disabled"}[enabled], args[0])
553 return 0
554 }
555
556 func findInstalledPlugin(name string) (pluginpkg.InstalledPlugin, bool, error) {
557 st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
558 if err != nil {
559 return pluginpkg.InstalledPlugin{}, false, err
560 }
561 for _, p := range st.Plugins {
562 if p.Name == name {
563 return p, true, nil
564 }
565 }
566 return pluginpkg.InstalledPlugin{}, false, nil
567 }
568
568 lines GO