返回 DeepSeek-Reasonix
pluginpkg.go
根目录 / internal / pluginpkg / pluginpkg.go
1 // Package pluginpkg handles installed Reasonix plugin packages.
2 //
3 // Plugin packages are higher-level bundles that can contribute skills, hooks,
4 // and MCP servers. They are intentionally parsed into package-local structs so
5 // config/hook/desktop callers can adapt them without creating import cycles.
6 package pluginpkg
7
8 import (
9 "encoding/json"
10 "errors"
11 "fmt"
12 "os"
13 "path"
14 "path/filepath"
15 "regexp"
16 "sort"
17 "strings"
18 "sync"
19
20 "reasonix/internal/command"
21 "reasonix/internal/fileutil"
22 fileencoding "reasonix/internal/fileutil/encoding"
23 "reasonix/internal/frontmatter"
24 )
25
26 const (
27 NativeManifest = "reasonix-plugin.json"
28 CodexManifest = ".codex-plugin/plugin.json"
29 ClaudeManifest = ".claude-plugin/plugin.json"
30 StateFilename = "plugin-packages.json"
31
32 claudeSettingsPath = ".claude/settings.json"
33 claudeInstructions = "CLAUDE.md"
34 )
35
36 var validName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
37 var windowsAbsolutePath = regexp.MustCompile(`^[A-Za-z]:/`)
38
39 // Package is one parsed plugin package rooted on disk.
40 type Package struct {
41 Root string
42 ManifestKind string
43 Manifest Manifest
44 Compatibility Compatibility
45 }
46
47 type Inventory struct {
48 Skills []SkillRef
49 Agents []AgentRef
50 Commands []CommandRef
51 Prompts []PromptRef
52 Themes []ThemeRef
53 Hooks []HookRef
54 MCPServers []MCPServerRef
55 }
56
57 type Compatibility struct {
58 Status string `json:"status"`
59 Mapped []string `json:"mapped,omitempty"`
60 Skipped []CompatibilityIssue `json:"skipped,omitempty"`
61 }
62
63 type CompatibilityIssue struct {
64 Capability string `json:"capability"`
65 Path string `json:"path,omitempty"`
66 Reason string `json:"reason"`
67 }
68
69 type SkillRef struct {
70 Name string
71 Description string
72 Path string
73 Invocation string
74 RunAs string
75 }
76
77 type AgentRef struct {
78 Name string
79 Description string
80 Path string
81 Invocation string
82 Model string
83 AllowedTools []string
84 }
85
86 // CommandRef is one custom slash command a plugin contributes: a flat <name>.md
87 // prompt template invoked as /<name> (Claude plugin commands map here 1:1).
88 type CommandRef struct {
89 Name string
90 Description string
91 ArgHint string
92 Path string
93 Invocation string
94 }
95
96 // PromptRef is one prompt template a plugin contributes from a prompts
97 // directory (the Manifest v1 name for what legacy manifests call commands).
98 // Prompt files share the slash-command file shape (flat <name>.md with
99 // frontmatter) but map to kernel KindPrompt contributions, not commands.
100 type PromptRef struct {
101 Name string
102 Description string
103 ArgHint string
104 Path string
105 }
106
107 // ThemeRef is one theme file (*.reasonix-theme) a plugin contributes,
108 // resolved from the manifest's themes list (plain paths and globs).
109 type ThemeRef struct {
110 Name string
111 Path string
112 }
113
114 type HookRef struct {
115 Event string
116 Match string
117 Command string
118 ContextFile string
119 Description string
120 }
121
122 type MCPServerRef struct {
123 Name string
124 DisplayName string
125 Description string
126 Transport string
127 Command string
128 URL string
129 AutoStart bool
130 }
131
132 // Manifest is the normalized manifest shape used by Reasonix.
133 type Manifest struct {
134 Name string
135 Version string
136 Description string
137 Homepage string
138 Repository string
139 Skills []string
140 // Agents are directories of Claude-style flat agent Markdown files. They are
141 // loaded as plugin-owned, manually invoked Reasonix subagent profiles.
142 Agents []string
143 // Commands are directories of flat <name>.md slash-command prompt templates
144 // (rendered with $ARGUMENTS/$1..$N on /<name>). Declared explicitly in a
145 // manifest or adopted from a Claude plugin's conventional commands/ dir.
146 Commands []string
147 Hooks map[string][]Hook
148 MCPServers map[string]MCPServer
149 // Prompts are directories of flat <name>.md prompt templates — the
150 // Manifest v1 name for what legacy manifests call commands. The two are
151 // separate semantic sets: commands become slash commands, prompts become
152 // kernel KindPrompt contributions. A path listed under both stays in both.
153 Prompts []string
154 // Themes are *.reasonix-theme file paths or per-segment glob patterns
155 // (e.g. "themes/*.reasonix-theme"), all lexically inside the plugin root.
156 Themes []string
157 // Runtime declares a plugin-owned runtime process (Manifest v1 only).
158 // nil for legacy, Codex, and Claude packages.
159 Runtime *RuntimeSpec
160 }
161
162 type Hook struct {
163 Match string `json:"match,omitempty"`
164 Command string `json:"command,omitempty"`
165 Args []string `json:"args,omitempty"`
166 ArgsSet bool `json:"-"`
167 ContextFile string `json:"contextFile,omitempty"`
168 ShellCommand bool `json:"shellCommand,omitempty"`
169 Shell string `json:"shell,omitempty"`
170 Async bool `json:"async,omitempty"`
171 PayloadFormat string `json:"payloadFormat,omitempty"`
172 Description string `json:"description,omitempty"`
173 Timeout int `json:"timeout,omitempty"`
174 Cwd string `json:"cwd,omitempty"`
175 Env map[string]string `json:"env,omitempty"`
176 }
177
178 // UnmarshalJSON preserves whether args was present, including an explicit
179 // empty array. Hook execution uses field presence — not argument count — to
180 // distinguish exec form from shell form.
181 func (h *Hook) UnmarshalJSON(data []byte) error {
182 type hookJSON Hook
183 var decoded hookJSON
184 if err := json.Unmarshal(data, &decoded); err != nil {
185 return err
186 }
187 var fields map[string]json.RawMessage
188 if err := json.Unmarshal(data, &fields); err != nil {
189 return err
190 }
191 *h = Hook(decoded)
192 for name := range fields {
193 if strings.EqualFold(name, "args") {
194 h.ArgsSet = true
195 break
196 }
197 }
198 return nil
199 }
200
201 // MarshalJSON keeps an explicit empty args array visible. Without this custom
202 // form, omitempty would erase args:[] and silently change exec form into shell
203 // form after a JSON round trip.
204 func (h Hook) MarshalJSON() ([]byte, error) {
205 type hookJSON Hook
206 data, err := json.Marshal(hookJSON(h))
207 if err != nil || !h.ArgsSet {
208 return data, err
209 }
210 var fields map[string]json.RawMessage
211 if err := json.Unmarshal(data, &fields); err != nil {
212 return nil, err
213 }
214 args := h.Args
215 if args == nil {
216 args = []string{}
217 }
218 rawArgs, err := json.Marshal(args)
219 if err != nil {
220 return nil, err
221 }
222 fields["args"] = rawArgs
223 return json.Marshal(fields)
224 }
225
226 type MCPServer struct {
227 Type string `json:"type,omitempty"`
228 Command string `json:"command,omitempty"`
229 Args []string `json:"args,omitempty"`
230 Env map[string]string `json:"env,omitempty"`
231 URL string `json:"url,omitempty"`
232 Headers map[string]string `json:"headers,omitempty"`
233 AutoStart *bool `json:"auto_start,omitempty"`
234 Tier string `json:"tier,omitempty"`
235 DisplayName string `json:"display_name,omitempty"`
236 Description string `json:"description,omitempty"`
237 Imported bool `json:"imported,omitempty"`
238 }
239
240 // State is persisted at <Reasonix home>/plugin-packages.json.
241 type State struct {
242 Version int `json:"version"`
243 Plugins []InstalledPlugin `json:"plugins"`
244 }
245
246 type InstalledPlugin struct {
247 Name string `json:"name"`
248 Source string `json:"source,omitempty"`
249 Root string `json:"root"`
250 Version string `json:"version,omitempty"`
251 Description string `json:"description,omitempty"`
252 ManifestKind string `json:"manifestKind,omitempty"`
253 Enabled bool `json:"enabled"`
254 Commit string `json:"commit,omitempty"`
255 }
256
257 type InstalledPackage struct {
258 Installed InstalledPlugin
259 Package Package
260 Warnings []string
261 }
262
263 func IsValidName(name string) bool { return validName.MatchString(strings.TrimSpace(name)) }
264
265 func StatePath(reasonixHome string) string {
266 return filepath.Join(reasonixHome, StateFilename)
267 }
268
269 func PluginsDir(reasonixHome string) string {
270 return filepath.Join(reasonixHome, "plugins")
271 }
272
273 func InstallRoot(reasonixHome, name string) string {
274 return filepath.Join(PluginsDir(reasonixHome), name)
275 }
276
277 func LoadState(reasonixHome string) (State, error) {
278 var st State
279 b, err := fileencoding.ReadFileUTF8(StatePath(reasonixHome))
280 if err != nil {
281 if errors.Is(err, os.ErrNotExist) {
282 return State{Version: 1}, nil
283 }
284 return State{}, err
285 }
286 if err := json.Unmarshal(b, &st); err != nil {
287 return State{}, err
288 }
289 if st.Version == 0 {
290 st.Version = 1
291 }
292 sort.SliceStable(st.Plugins, func(i, j int) bool { return st.Plugins[i].Name < st.Plugins[j].Name })
293 return st, nil
294 }
295
296 func SaveState(reasonixHome string, st State) error {
297 if st.Version == 0 {
298 st.Version = 1
299 }
300 sort.SliceStable(st.Plugins, func(i, j int) bool { return st.Plugins[i].Name < st.Plugins[j].Name })
301 b, err := json.MarshalIndent(st, "", " ")
302 if err != nil {
303 return err
304 }
305 b = append(b, '\n')
306 return fileutil.AtomicWriteFile(StatePath(reasonixHome), b, 0o644)
307 }
308
309 // stateMu serialises the read-modify-write of the state file within this
310 // process. SaveState writes atomically (tmpfile + rename), so concurrent
311 // callers never see a half-written file; this lock additionally prevents two
312 // in-process load-modify-save cycles from clobbering each other's edit. It is
313 // not a cross-process lock — concurrent Reasonix processes can still race.
314 var stateMu sync.Mutex
315
316 func Upsert(reasonixHome string, p InstalledPlugin) error {
317 if !IsValidName(p.Name) {
318 return fmt.Errorf("invalid plugin name %q", p.Name)
319 }
320 stateMu.Lock()
321 defer stateMu.Unlock()
322 st, err := LoadState(reasonixHome)
323 if err != nil {
324 return err
325 }
326 for i := range st.Plugins {
327 if st.Plugins[i].Name == p.Name {
328 st.Plugins[i] = p
329 return SaveState(reasonixHome, st)
330 }
331 }
332 st.Plugins = append(st.Plugins, p)
333 return SaveState(reasonixHome, st)
334 }
335
336 func Remove(reasonixHome, name string) (InstalledPlugin, bool, error) {
337 stateMu.Lock()
338 defer stateMu.Unlock()
339 st, err := LoadState(reasonixHome)
340 if err != nil {
341 return InstalledPlugin{}, false, err
342 }
343 for i, p := range st.Plugins {
344 if p.Name != name {
345 continue
346 }
347 st.Plugins = append(st.Plugins[:i], st.Plugins[i+1:]...)
348 return p, true, SaveState(reasonixHome, st)
349 }
350 return InstalledPlugin{}, false, nil
351 }
352
353 func SetEnabled(reasonixHome, name string, enabled bool) error {
354 stateMu.Lock()
355 defer stateMu.Unlock()
356 st, err := LoadState(reasonixHome)
357 if err != nil {
358 return err
359 }
360 for i := range st.Plugins {
361 if st.Plugins[i].Name == name {
362 st.Plugins[i].Enabled = enabled
363 return SaveState(reasonixHome, st)
364 }
365 }
366 return fmt.Errorf("plugin %q is not installed", name)
367 }
368
369 func LoadInstalled(reasonixHome string) ([]InstalledPackage, []string) {
370 st, err := LoadState(reasonixHome)
371 if err != nil {
372 return nil, []string{err.Error()}
373 }
374 var out []InstalledPackage
375 var warnings []string
376 for _, installed := range st.Plugins {
377 if !installed.Enabled {
378 continue
379 }
380 root := ResolveRoot(reasonixHome, installed.Root)
381 pkg, pkgWarnings, err := ParseDir(root)
382 if err != nil {
383 warnings = append(warnings, fmt.Sprintf("%s: %v", installed.Name, err))
384 continue
385 }
386 out = append(out, InstalledPackage{Installed: installed, Package: pkg, Warnings: pkgWarnings})
387 }
388 sort.SliceStable(out, func(i, j int) bool { return out[i].Installed.Name < out[j].Installed.Name })
389 return out, warnings
390 }
391
392 func ResolveRoot(reasonixHome, root string) string {
393 if filepath.IsAbs(root) {
394 return filepath.Clean(root)
395 }
396 return filepath.Join(reasonixHome, filepath.Clean(root))
397 }
398
399 func RelativeRoot(reasonixHome, root string) string {
400 if rel, err := filepath.Rel(reasonixHome, root); err == nil && rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." {
401 return filepath.ToSlash(rel)
402 }
403 return filepath.Clean(root)
404 }
405
406 func ParseDir(root string) (Package, []string, error) {
407 root = filepath.Clean(root)
408 // A manifest that EXISTS but fails to parse fails loudly with the real
409 // error (a v1 typo names its field path); only a missing file falls
410 // through to the next manifest kind.
411 if pkg, warnings, err := parseNative(filepath.Join(root, NativeManifest), root); err == nil {
412 return pkg, warnings, nil
413 } else if !errors.Is(err, os.ErrNotExist) {
414 return Package{}, nil, err
415 }
416 if pkg, warnings, err := parseCodex(filepath.Join(root, CodexManifest), root); err == nil {
417 return pkg, warnings, nil
418 } else if !errors.Is(err, os.ErrNotExist) {
419 return Package{}, nil, err
420 }
421 if pkg, warnings, err := parseClaudePlugin(filepath.Join(root, ClaudeManifest), root); err == nil {
422 return pkg, warnings, nil
423 } else if !errors.Is(err, os.ErrNotExist) {
424 return Package{}, nil, err
425 }
426 return Package{}, nil, fmt.Errorf("no %s, %s, or %s found", NativeManifest, CodexManifest, ClaudeManifest)
427 }
428
429 func parseNative(path, root string) (Package, []string, error) {
430 b, err := fileencoding.ReadFileUTF8(path)
431 if err != nil {
432 return Package{}, nil, err
433 }
434 apiVersion, err := sniffManifestAPIVersion(b)
435 if err != nil {
436 return Package{}, nil, err
437 }
438 if apiVersion != "" {
439 return parseNativeV1(b, root, apiVersion)
440 }
441 return parseNativeLegacy(b, root)
442 }
443
444 // parseNativeLegacy is the pre-v1 native manifest path, preserved
445 // byte-for-byte: manifests without an apiVersion parse exactly as they
446 // always have, including silently ignoring unknown fields.
447 func parseNativeLegacy(b []byte, root string) (Package, []string, error) {
448 var raw struct {
449 Name string `json:"name"`
450 Version string `json:"version"`
451 Description string `json:"description"`
452 Homepage string `json:"homepage"`
453 Repository string `json:"repository"`
454 Skills json.RawMessage `json:"skills"`
455 Commands json.RawMessage `json:"commands"`
456 Hooks map[string][]Hook `json:"hooks"`
457 MCPServers map[string]MCPServer `json:"mcpServers"`
458 }
459 if err := json.Unmarshal(b, &raw); err != nil {
460 return Package{}, nil, err
461 }
462 skills, err := parseSkillPaths(raw.Skills)
463 if err != nil {
464 return Package{}, nil, err
465 }
466 commands, err := parseSkillPaths(raw.Commands)
467 if err != nil {
468 return Package{}, nil, err
469 }
470 manifest := Manifest{
471 Name: strings.TrimSpace(raw.Name),
472 Version: strings.TrimSpace(raw.Version),
473 Description: strings.TrimSpace(raw.Description),
474 Homepage: strings.TrimSpace(raw.Homepage),
475 Repository: strings.TrimSpace(raw.Repository),
476 Skills: skills,
477 Commands: commands,
478 Hooks: normalizeHooks(raw.Hooks),
479 MCPServers: raw.MCPServers,
480 }
481 if err := validateManifest(root, &manifest); err != nil {
482 return Package{}, nil, err
483 }
484 warnings, issues := applyClaudeCompatibility(root, &manifest)
485 if err := validateManifest(root, &manifest); err != nil {
486 return Package{}, warnings, err
487 }
488 pkg := Package{Root: root, ManifestKind: "reasonix", Manifest: manifest}
489 pkg.Compatibility = compatibilityFor(pkg, issues)
490 return pkg, warnings, nil
491 }
492
493 func parseCodex(path, root string) (Package, []string, error) {
494 return parseCodexLike(path, root, "codex", true)
495 }
496
497 func parseClaudePlugin(path, root string) (Package, []string, error) {
498 return parseCodexLike(path, root, "claude", false)
499 }
500
501 func parseCodexLike(path, root, kind string, includeCodexSessionStartHook bool) (Package, []string, error) {
502 var raw struct {
503 Name string `json:"name"`
504 Version string `json:"version"`
505 Description string `json:"description"`
506 Homepage string `json:"homepage"`
507 Repository string `json:"repository"`
508 Skills json.RawMessage `json:"skills"`
509 Commands json.RawMessage `json:"commands"`
510 }
511 if err := readJSONFile(path, &raw); err != nil {
512 return Package{}, nil, err
513 }
514 skills, err := parseSkillPaths(raw.Skills)
515 if err != nil {
516 return Package{}, nil, err
517 }
518 commands, err := parseSkillPaths(raw.Commands)
519 if err != nil {
520 return Package{}, nil, err
521 }
522 manifest := Manifest{
523 Name: strings.TrimSpace(raw.Name),
524 Version: strings.TrimSpace(raw.Version),
525 Description: strings.TrimSpace(raw.Description),
526 Homepage: strings.TrimSpace(raw.Homepage),
527 Repository: strings.TrimSpace(raw.Repository),
528 Skills: skills,
529 Commands: commands,
530 }
531 hookPath := filepath.Join(root, "hooks", "session-start-codex")
532 if includeCodexSessionStartHook {
533 if info, err := os.Stat(hookPath); err == nil && info.Mode().IsRegular() {
534 manifest.Hooks = map[string][]Hook{
535 "SessionStart": {{
536 Command: hookPath,
537 Cwd: root,
538 Description: "Codex-compatible session start hook from " + manifest.Name,
539 }},
540 }
541 }
542 }
543 var warnings []string
544 var issues []CompatibilityIssue
545 if kind == "claude" {
546 warnings = append(warnings, applyClaudeConventionDirs(root, &manifest)...)
547 }
548 compatWarnings, compatIssues := applyClaudeCompatibility(root, &manifest)
549 warnings = append(warnings, compatWarnings...)
550 issues = append(issues, compatIssues...)
551 if err := validateManifest(root, &manifest); err != nil {
552 return Package{}, warnings, err
553 }
554 pkg := Package{Root: root, ManifestKind: kind, Manifest: manifest}
555 pkg.Compatibility = compatibilityFor(pkg, issues)
556 return pkg, warnings, nil
557 }
558
559 // claudeConventionSkillDirs are the directories a Claude plugin loads skills
560 // from BY CONVENTION — the official plugin layout auto-discovers skills/ (and
561 // packs in the wild use .claude/skills/) without declaring them in
562 // plugin.json, whose manifest usually carries metadata only.
563 var claudeConventionSkillDirs = []string{"skills", ".claude/skills"}
564
565 // claudeConventionCommandDirs are the directories a Claude plugin loads slash
566 // commands from by convention. A command is a flat <name>.md prompt template
567 // the user invokes as /<name> — exactly Reasonix's custom-command shape
568 // (internal/command) — so these directories map onto Manifest.Commands and
569 // join command discovery at the lowest priority. Unlike skill dirs they are
570 // adopted even when the manifest declares skills explicitly, because
571 // plugin.json never lists commands.
572 var claudeConventionCommandDirs = []string{"commands", ".claude/commands"}
573
574 var claudeConventionAgentDirs = []string{"agents"}
575
576 // applyClaudeConventionDirs fills manifest.Skills from the conventional skill
577 // directories when the manifest declares none (the standard Claude plugin
578 // shape), adopts conventional command directories into manifest.Commands, and
579 // reports the conventional capabilities Reasonix cannot map.
580 func applyClaudeConventionDirs(root string, manifest *Manifest) []string {
581 var warnings []string
582 if len(manifest.Skills) == 0 {
583 for _, rel := range claudeConventionSkillDirs {
584 dir := filepath.Join(root, filepath.FromSlash(rel))
585 if dirContainsSkill(dir) {
586 manifest.Skills = append(manifest.Skills, rel)
587 }
588 }
589 }
590 for _, rel := range claudeConventionCommandDirs {
591 dir := filepath.Join(root, filepath.FromSlash(rel))
592 if dirContainsCommandMd(dir) && !containsPathEntry(manifest.Commands, rel) {
593 manifest.Commands = append(manifest.Commands, rel)
594 }
595 }
596 for _, rel := range claudeConventionAgentDirs {
597 dir := filepath.Join(root, filepath.FromSlash(rel))
598 if dirContainsAgentMd(dir) && !containsPathEntry(manifest.Agents, rel) {
599 manifest.Agents = append(manifest.Agents, rel)
600 }
601 }
602 return warnings
603 }
604
605 // containsPathEntry reports whether the manifest path list already names rel
606 // (slash-normalized), so convention adoption never duplicates an explicit entry.
607 func containsPathEntry(paths []string, rel string) bool {
608 for _, p := range paths {
609 if filepath.ToSlash(filepath.Clean(filepath.FromSlash(p))) == rel {
610 return true
611 }
612 }
613 return false
614 }
615
616 // dirContainsSkill reports whether dir holds at least one skill definition
617 // (<dir>/<name>/SKILL.md), so an empty conventional directory is not adopted
618 // as a skill root.
619 func dirContainsSkill(dir string) bool {
620 entries, err := os.ReadDir(dir)
621 if err != nil {
622 return false
623 }
624 for _, e := range entries {
625 if !e.IsDir() {
626 continue
627 }
628 if info, err := os.Stat(filepath.Join(dir, e.Name(), "SKILL.md")); err == nil && info.Mode().IsRegular() {
629 return true
630 }
631 }
632 return false
633 }
634
635 // dirContainsCommandMd reports whether dir holds at least one command
636 // definition. It delegates to the runtime loader (internal/command), so the
637 // adoption gate and what /<name> actually loads can never diverge — including
638 // arbitrarily nested namespace layouts like commands/a/b/c/commit.md.
639 func dirContainsCommandMd(dir string) bool {
640 cmds, _ := command.Load(dir) // best-effort: a missing dir or malformed files load nothing
641 return len(cmds) > 0
642 }
643
644 func ManifestPath(kind string) string {
645 switch kind {
646 case "reasonix":
647 return NativeManifest
648 case "codex":
649 return CodexManifest
650 case "claude":
651 return ClaudeManifest
652 default:
653 return NativeManifest
654 }
655 }
656
657 func ManifestPaths() []string {
658 return []string{NativeManifest, CodexManifest, ClaudeManifest}
659 }
660
661 func applyClaudeCompatibility(root string, manifest *Manifest) ([]string, []CompatibilityIssue) {
662 appendRootClaudeInstructions(root, manifest)
663 return appendClaudeCompatibility(root, manifest)
664 }
665
666 func appendRootClaudeInstructions(root string, manifest *Manifest) {
667 path := filepath.Join(root, claudeInstructions)
668 info, err := os.Stat(path)
669 if err != nil || !info.Mode().IsRegular() {
670 return
671 }
672 if manifest.Hooks == nil {
673 manifest.Hooks = map[string][]Hook{}
674 }
675 manifest.Hooks["SessionStart"] = append(manifest.Hooks["SessionStart"], Hook{
676 ContextFile: claudeInstructions,
677 Cwd: ".",
678 Description: "Plugin CLAUDE.md startup context from " + manifest.Name,
679 })
680 }
681
682 func claudeTimeoutMillis(seconds int) int {
683 if seconds <= 0 {
684 return 0
685 }
686 return seconds * 1000
687 }
688
689 func cloneHookEnv(in map[string]string) map[string]string {
690 if len(in) == 0 {
691 return nil
692 }
693 out := map[string]string{}
694 for k, v := range in {
695 if strings.TrimSpace(k) != "" {
696 out[k] = v
697 }
698 }
699 return out
700 }
701
702 func firstNonEmpty(values ...string) string {
703 for _, value := range values {
704 if strings.TrimSpace(value) != "" {
705 return value
706 }
707 }
708 return ""
709 }
710
711 func readJSONFile(path string, v any) error {
712 b, err := fileencoding.ReadFileUTF8(path)
713 if err != nil {
714 return err
715 }
716 return json.Unmarshal(b, v)
717 }
718
719 func parseSkillPaths(raw json.RawMessage) ([]string, error) {
720 if len(raw) == 0 || string(raw) == "null" {
721 return nil, nil
722 }
723 var one string
724 if err := json.Unmarshal(raw, &one); err == nil {
725 return cleanPathList([]string{one})
726 }
727 var manyStrings []string
728 if err := json.Unmarshal(raw, &manyStrings); err == nil {
729 return cleanPathList(manyStrings)
730 }
731 var manyObjects []struct {
732 Path string `json:"path"`
733 }
734 if err := json.Unmarshal(raw, &manyObjects); err == nil {
735 paths := make([]string, 0, len(manyObjects))
736 for _, item := range manyObjects {
737 paths = append(paths, item.Path)
738 }
739 return cleanPathList(paths)
740 }
741 return nil, fmt.Errorf("skills must be a path string, string array, or object array")
742 }
743
744 func cleanPathList(paths []string) ([]string, error) {
745 var out []string
746 seen := map[string]bool{}
747 for _, raw := range paths {
748 slash, err := cleanPortableRelativePath(raw)
749 if err != nil {
750 return nil, err
751 }
752 if !seen[slash] {
753 seen[slash] = true
754 out = append(out, slash)
755 }
756 }
757 sort.Strings(out)
758 return out, nil
759 }
760
761 func normalizeHooks(in map[string][]Hook) map[string][]Hook {
762 if len(in) == 0 {
763 return nil
764 }
765 out := map[string][]Hook{}
766 for event, hooks := range in {
767 event = strings.TrimSpace(event)
768 for _, h := range hooks {
769 h.Command = strings.TrimSpace(h.Command)
770 h.ContextFile = strings.TrimSpace(h.ContextFile)
771 h.Cwd = strings.TrimSpace(h.Cwd)
772 h.Shell = strings.ToLower(strings.TrimSpace(h.Shell))
773 if h.Shell != "" && !h.ArgsSet {
774 h.ShellCommand = true
775 }
776 if h.Command == "" && h.ContextFile == "" {
777 continue
778 }
779 out[event] = append(out[event], h)
780 }
781 }
782 return out
783 }
784
785 func validateManifest(root string, m *Manifest) error {
786 if !IsValidName(m.Name) {
787 return fmt.Errorf("invalid plugin name %q", m.Name)
788 }
789 for _, p := range m.Skills {
790 if err := validateRelativePath(p); err != nil {
791 return err
792 }
793 }
794 for _, p := range m.Commands {
795 if err := validateRelativePath(p); err != nil {
796 return err
797 }
798 }
799 for _, p := range m.Agents {
800 if err := validateRelativePath(p); err != nil {
801 return err
802 }
803 }
804 for _, p := range m.Prompts {
805 if err := validateRelativePath(p); err != nil {
806 return err
807 }
808 }
809 for _, p := range m.Themes {
810 if err := validateRelativePath(p); err != nil {
811 return err
812 }
813 }
814 for event, hooks := range m.Hooks {
815 if strings.TrimSpace(event) == "" {
816 return fmt.Errorf("hook event is required")
817 }
818 for _, h := range hooks {
819 if h.Command == "" && h.ContextFile == "" {
820 return fmt.Errorf("hook command or contextFile is required")
821 }
822 if !h.ArgsSet && !validHookShell(h.Shell) {
823 return fmt.Errorf("hook shell %q is not supported (use auto, bash, powershell, pwsh, or cmd)", h.Shell)
824 }
825 if h.Command != "" && !h.ShellCommand && !filepath.IsAbs(h.Command) {
826 if err := validateRelativePath(h.Command); err != nil {
827 return err
828 }
829 }
830 if h.ContextFile != "" {
831 if err := validateRelativePath(h.ContextFile); err != nil {
832 return err
833 }
834 }
835 if h.Cwd != "" && !filepath.IsAbs(h.Cwd) {
836 if err := validateRelativePath(h.Cwd); err != nil {
837 return err
838 }
839 }
840 }
841 }
842 for name := range m.MCPServers {
843 if !IsValidName(name) {
844 return fmt.Errorf("invalid MCP server name %q", name)
845 }
846 }
847 if _, err := os.Stat(root); err != nil {
848 return err
849 }
850 return nil
851 }
852
853 func validHookShell(shell string) bool {
854 switch strings.ToLower(strings.TrimSpace(shell)) {
855 case "", "auto", "bash", "powershell", "pwsh", "cmd":
856 return true
857 default:
858 return false
859 }
860 }
861
862 func validateRelativePath(p string) error {
863 if strings.TrimSpace(p) == "" {
864 return fmt.Errorf("plugin path is required")
865 }
866 _, err := cleanPortableRelativePath(p)
867 return err
868 }
869
870 // cleanPortableRelativePath applies the same manifest path contract on every
871 // host OS. A plugin prepared on Windows must not turn a drive/UNC path into a
872 // harmless-looking relative path on Unix, and a Unix-rooted path must remain
873 // absolute when the same package is parsed on Windows.
874 func cleanPortableRelativePath(raw string) (string, error) {
875 trimmed := strings.TrimSpace(raw)
876 normalized := strings.ReplaceAll(trimmed, `\`, "/")
877 if filepath.IsAbs(trimmed) || path.IsAbs(normalized) || windowsAbsolutePath.MatchString(normalized) {
878 return "", fmt.Errorf("plugin path %q must be relative and stay inside the plugin root", trimmed)
879 }
880 cleaned := path.Clean(normalized)
881 if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
882 return "", fmt.Errorf("plugin path %q must be relative and stay inside the plugin root", trimmed)
883 }
884 return cleaned, nil
885 }
886
887 func (p Package) SkillRoots() []string {
888 var out []string
889 for _, rel := range p.Manifest.Skills {
890 out = append(out, filepath.Join(p.Root, filepath.FromSlash(rel)))
891 }
892 sort.Strings(out)
893 return out
894 }
895
896 func (p Package) AgentRoots() []string {
897 var out []string
898 for _, rel := range p.Manifest.Agents {
899 out = append(out, filepath.Join(p.Root, filepath.FromSlash(rel)))
900 }
901 sort.Strings(out)
902 return out
903 }
904
905 // CommandRoots returns the absolute command directories this package
906 // contributes to custom slash-command discovery.
907 func (p Package) CommandRoots() []string {
908 var out []string
909 for _, rel := range p.Manifest.Commands {
910 out = append(out, filepath.Join(p.Root, filepath.FromSlash(rel)))
911 }
912 sort.Strings(out)
913 return out
914 }
915
916 // PromptRoots returns the absolute prompt-template directories this package
917 // contributes (Manifest v1 prompts).
918 func (p Package) PromptRoots() []string {
919 var out []string
920 for _, rel := range p.Manifest.Prompts {
921 out = append(out, filepath.Join(p.Root, filepath.FromSlash(rel)))
922 }
923 sort.Strings(out)
924 return out
925 }
926
927 func (p Package) CapabilityCounts() (skills, commands, hooks, mcp int) {
928 skills = len(p.skillRefs())
929 commands = len(p.commandRefs())
930 for _, hs := range p.Manifest.Hooks {
931 hooks += len(hs)
932 }
933 mcp = len(p.Manifest.MCPServers)
934 return
935 }
936
937 func (p Package) AgentCount() int { return len(p.agentRefs()) }
938
939 // PromptCount counts the prompt templates discovered under Manifest.Prompts.
940 func (p Package) PromptCount() int { return len(p.promptRefs()) }
941
942 // ThemeCount counts the theme files resolved from Manifest.Themes.
943 func (p Package) ThemeCount() int { return len(p.themeRefs()) }
944
945 // CapabilitySummary is the full per-package capability count set. The
946 // four-value CapabilityCounts predates Manifest v1 and keeps its signature
947 // for existing callers (the desktop module among them); the v1 additions
948 // live here.
949 type CapabilitySummary struct {
950 Skills int
951 Agents int
952 Commands int
953 Hooks int
954 MCPServers int
955 Prompts int
956 Themes int
957 Runtime bool
958 }
959
960 // CapabilitySummary counts everything the package contributes, including
961 // the Manifest v1 additions (prompts, themes, runtime).
962 func (p Package) CapabilitySummary() CapabilitySummary {
963 skills, commands, hooks, mcp := p.CapabilityCounts()
964 return CapabilitySummary{
965 Skills: skills,
966 Agents: p.AgentCount(),
967 Commands: commands,
968 Hooks: hooks,
969 MCPServers: mcp,
970 Prompts: len(p.promptRefs()),
971 Themes: len(p.themeRefs()),
972 Runtime: p.Manifest.Runtime != nil,
973 }
974 }
975
976 func (p Package) Inventory() Inventory {
977 return Inventory{
978 Skills: p.skillRefs(),
979 Agents: p.agentRefs(),
980 Commands: p.commandRefs(),
981 Prompts: p.promptRefs(),
982 Themes: p.themeRefs(),
983 Hooks: p.hookRefs(),
984 MCPServers: p.mcpServerRefs(),
985 }
986 }
987
988 // commandRefs loads the package's command dirs through the same loader the
989 // runtime uses (internal/command), so names, namespacing, and frontmatter
990 // semantics can never drift between the inventory and actual invocation.
991 func (p Package) commandRefs() []CommandRef {
992 roots := p.CommandRoots()
993 if len(roots) == 0 {
994 return nil
995 }
996 cmds, _ := command.Load(roots...) // best-effort: malformed files are surfaced at load time elsewhere
997 out := make([]CommandRef, 0, len(cmds))
998 for _, c := range cmds {
999 out = append(out, CommandRef{
1000 Name: c.Name,
1001 Description: c.Description,
1002 ArgHint: c.ArgHint,
1003 Path: c.Source,
1004 Invocation: "/" + c.Name,
1005 })
1006 }
1007 return out
1008 }
1009
1010 // promptRefs loads the package's prompt dirs through the same loader the
1011 // command inventory uses (internal/command): prompt templates share the
1012 // slash-command file shape, so names, frontmatter, and malformed-file
1013 // handling stay identical.
1014 func (p Package) promptRefs() []PromptRef {
1015 roots := p.PromptRoots()
1016 if len(roots) == 0 {
1017 return nil
1018 }
1019 cmds, _ := command.Load(roots...) // best-effort, like commandRefs
1020 out := make([]PromptRef, 0, len(cmds))
1021 for _, c := range cmds {
1022 out = append(out, PromptRef{
1023 Name: c.Name,
1024 Description: c.Description,
1025 ArgHint: c.ArgHint,
1026 Path: c.Source,
1027 })
1028 }
1029 return out
1030 }
1031
1032 // themeRefs resolves the manifest's themes list (plain paths and
1033 // per-segment globs) to concrete theme files. Parse-time validation has
1034 // already rejected escapes and non-regular files, so unreadable entries
1035 // here simply drop out (they were reported as parse warnings).
1036 func (p Package) themeRefs() []ThemeRef {
1037 seen := map[string]bool{}
1038 var out []ThemeRef
1039 for _, pattern := range p.Manifest.Themes {
1040 var matches []string
1041 if hasGlobMeta(pattern) {
1042 matches, _ = globThemePattern(p.Root, pattern)
1043 } else {
1044 abs := filepath.Join(p.Root, filepath.FromSlash(pattern))
1045 if info, err := os.Stat(abs); err == nil && info.Mode().IsRegular() {
1046 matches = []string{abs}
1047 }
1048 }
1049 for _, match := range matches {
1050 match = filepath.Clean(match)
1051 if seen[match] {
1052 continue
1053 }
1054 seen[match] = true
1055 base := filepath.Base(match)
1056 out = append(out, ThemeRef{
1057 Name: strings.TrimSuffix(base, filepath.Ext(base)),
1058 Path: match,
1059 })
1060 }
1061 }
1062 sort.SliceStable(out, func(i, j int) bool {
1063 if out[i].Name != out[j].Name {
1064 return out[i].Name < out[j].Name
1065 }
1066 return out[i].Path < out[j].Path
1067 })
1068 return out
1069 }
1070
1071 func (p Package) skillRefs() []SkillRef {
1072 var out []SkillRef
1073 seen := map[string]bool{}
1074 for _, rel := range p.Manifest.Skills {
1075 root := filepath.Join(p.Root, filepath.FromSlash(rel))
1076 p.scanSkillPath(root, 1, map[string]bool{}, &out)
1077 }
1078 filtered := out[:0]
1079 for _, sk := range out {
1080 key := sk.Path
1081 if key == "" {
1082 key = sk.Name
1083 }
1084 if seen[key] {
1085 continue
1086 }
1087 seen[key] = true
1088 filtered = append(filtered, sk)
1089 }
1090 sort.SliceStable(filtered, func(i, j int) bool {
1091 if filtered[i].Name != filtered[j].Name {
1092 return filtered[i].Name < filtered[j].Name
1093 }
1094 return filtered[i].Path < filtered[j].Path
1095 })
1096 return filtered
1097 }
1098
1099 func (p Package) scanSkillPath(path string, depth int, seen map[string]bool, out *[]SkillRef) {
1100 info, err := os.Stat(path)
1101 if err != nil {
1102 return
1103 }
1104 if !info.IsDir() {
1105 if info.Mode().IsRegular() && strings.EqualFold(filepath.Ext(path), ".md") {
1106 if sk, ok := parseSkillRef(path, strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))); ok {
1107 *out = append(*out, sk)
1108 }
1109 }
1110 return
1111 }
1112
1113 key := filepath.Clean(path)
1114 if resolved, err := filepath.EvalSymlinks(path); err == nil {
1115 key = filepath.Clean(resolved)
1116 }
1117 if seen[key] {
1118 return
1119 }
1120 seen[key] = true
1121
1122 if sk, ok := parseSkillRef(filepath.Join(path, "SKILL.md"), filepath.Base(path)); ok {
1123 *out = append(*out, sk)
1124 return
1125 }
1126 if depth >= 5 {
1127 return
1128 }
1129 entries, err := os.ReadDir(path)
1130 if err != nil {
1131 return
1132 }
1133 for _, entry := range entries {
1134 name := entry.Name()
1135 if shouldSkipSkillScanDir(name) {
1136 continue
1137 }
1138 full := filepath.Join(path, name)
1139 if entry.IsDir() {
1140 p.scanSkillPath(full, depth+1, seen, out)
1141 continue
1142 }
1143 if entry.Type().IsRegular() && strings.EqualFold(filepath.Ext(name), ".md") {
1144 if sk, ok := parseSkillRef(full, strings.TrimSuffix(name, filepath.Ext(name))); ok {
1145 *out = append(*out, sk)
1146 }
1147 }
1148 }
1149 }
1150
1151 func shouldSkipSkillScanDir(name string) bool {
1152 if strings.HasPrefix(name, ".") {
1153 return true
1154 }
1155 switch strings.ToLower(name) {
1156 case "assets", "node_modules", "references", "scripts":
1157 return true
1158 default:
1159 return false
1160 }
1161 }
1162
1163 func parseSkillRef(path, stem string) (SkillRef, bool) {
1164 if !IsValidName(stem) {
1165 return SkillRef{}, false
1166 }
1167 b, err := fileencoding.ReadFileUTF8(path)
1168 if err != nil {
1169 return SkillRef{}, false
1170 }
1171 content := strings.TrimPrefix(strings.ReplaceAll(string(b), "\r\n", "\n"), "\uFEFF")
1172 fm, _ := frontmatter.Split(content)
1173 name := stem
1174 if v := strings.TrimSpace(fm["name"]); IsValidName(v) {
1175 name = v
1176 }
1177 return SkillRef{
1178 Name: name,
1179 Description: strings.TrimSpace(fm["description"]),
1180 Path: filepath.Clean(path),
1181 Invocation: "/" + name,
1182 RunAs: pluginSkillRunMode(fm),
1183 }, true
1184 }
1185
1186 func pluginSkillRunMode(fm map[string]string) string {
1187 if strings.TrimSpace(fm["runas"]) == "subagent" {
1188 return "subagent"
1189 }
1190 if strings.EqualFold(strings.TrimSpace(fm["context"]), "fork") {
1191 return "subagent"
1192 }
1193 if strings.TrimSpace(fm["agent"]) != "" {
1194 return "subagent"
1195 }
1196 return "inline"
1197 }
1198
1199 func (p Package) hookRefs() []HookRef {
1200 events := make([]string, 0, len(p.Manifest.Hooks))
1201 for event := range p.Manifest.Hooks {
1202 events = append(events, event)
1203 }
1204 sort.Strings(events)
1205 var out []HookRef
1206 for _, event := range events {
1207 for _, hook := range p.Manifest.Hooks[event] {
1208 out = append(out, HookRef{
1209 Event: event,
1210 Match: hook.Match,
1211 Command: hook.Command,
1212 ContextFile: hook.ContextFile,
1213 Description: hook.Description,
1214 })
1215 }
1216 }
1217 return out
1218 }
1219
1220 func (p Package) mcpServerRefs() []MCPServerRef {
1221 names := make([]string, 0, len(p.Manifest.MCPServers))
1222 for name := range p.Manifest.MCPServers {
1223 names = append(names, name)
1224 }
1225 sort.Strings(names)
1226 out := make([]MCPServerRef, 0, len(names))
1227 for _, name := range names {
1228 server := p.Manifest.MCPServers[name]
1229 out = append(out, MCPServerRef{
1230 Name: name,
1231 DisplayName: firstNonEmpty(strings.TrimSpace(server.DisplayName), name),
1232 Description: strings.TrimSpace(server.Description),
1233 Transport: pluginMCPTransport(server),
1234 Command: strings.TrimSpace(server.Command),
1235 URL: strings.TrimSpace(server.URL),
1236 AutoStart: server.AutoStart == nil || *server.AutoStart,
1237 })
1238 }
1239 return out
1240 }
1241
1242 func pluginMCPTransport(server MCPServer) string {
1243 if typ := strings.TrimSpace(server.Type); typ != "" {
1244 return typ
1245 }
1246 if strings.TrimSpace(server.URL) != "" {
1247 return "http"
1248 }
1249 return "stdio"
1250 }
1251
1251 lines GO