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