返回 DeepSeek-Reasonix
pluginpkg_test.go
根目录 / internal / pluginpkg / pluginpkg_test.go
1 package pluginpkg
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "reflect"
8 "strings"
9 "testing"
10
11 fileencoding "reasonix/internal/fileutil/encoding"
12 )
13
14 func TestParseCodexSuperpowersManifest(t *testing.T) {
15 root := t.TempDir()
16 writeTestFile(t, filepath.Join(root, CodexManifest), `{
17 "name": "superpowers",
18 "version": "6.1.0",
19 "description": "Planning workflows",
20 "skills": "./skills/"
21 }`)
22 writeTestFile(t, filepath.Join(root, "skills", "plan", "SKILL.md"), "---\ndescription: Plan work\n---\nbody")
23 writeTestFile(t, filepath.Join(root, "hooks", "session-start-codex"), "#!/usr/bin/env bash\n")
24
25 pkg, warnings, err := ParseDir(root)
26 if err != nil {
27 t.Fatalf("ParseDir: %v", err)
28 }
29 if len(warnings) != 0 {
30 t.Fatalf("warnings = %v, want none", warnings)
31 }
32 if pkg.ManifestKind != "codex" || pkg.Manifest.Name != "superpowers" || pkg.Manifest.Version != "6.1.0" {
33 t.Fatalf("pkg = %+v", pkg)
34 }
35 if got := pkg.SkillRoots(); len(got) != 1 || got[0] != filepath.Join(root, "skills") {
36 t.Fatalf("SkillRoots = %#v", got)
37 }
38 if hooks := pkg.Manifest.Hooks["SessionStart"]; len(hooks) != 1 || hooks[0].Command != filepath.Join(root, "hooks", "session-start-codex") {
39 t.Fatalf("SessionStart hooks = %+v", hooks)
40 }
41 inv := pkg.Inventory()
42 if len(inv.Skills) != 1 || inv.Skills[0].Name != "plan" || inv.Skills[0].Invocation != "/plan" {
43 t.Fatalf("Inventory().Skills = %+v", inv.Skills)
44 }
45 if skills, _, hooks, _ := pkg.CapabilityCounts(); skills != 1 || hooks != 1 {
46 t.Fatalf("CapabilityCounts skills=%d hooks=%d", skills, hooks)
47 }
48 }
49
50 func TestParseDirDecodesGB18030Manifest(t *testing.T) {
51 root := t.TempDir()
52 manifest := `{"apiVersion":"reasonix.io/plugin/v2","name":"cn-plugin","version":"1.0.0","description":"中文插件"}`
53 path := filepath.Join(root, NativeManifest)
54 if err := os.WriteFile(path, fileencoding.Encode(manifest, fileencoding.GB18030), 0o644); err != nil {
55 t.Fatal(err)
56 }
57
58 pkg, warnings, err := ParseDir(root)
59 if err != nil {
60 t.Fatalf("ParseDir: %v", err)
61 }
62 if len(warnings) != 0 {
63 t.Fatalf("warnings = %v", warnings)
64 }
65 if pkg.Manifest.Description != "中文插件" {
66 t.Fatalf("decoded manifest = %+v", pkg.Manifest)
67 }
68 }
69
70 func TestParseCodexClaudeCompatibility(t *testing.T) {
71 root := t.TempDir()
72 writeTestFile(t, filepath.Join(root, CodexManifest), `{
73 "name": "claude-pack",
74 "version": "1.0.0",
75 "skills": "skills"
76 }`)
77 writeTestFile(t, filepath.Join(root, "CLAUDE.md"), "Always use the bundled workflow.")
78 writeTestFile(t, filepath.Join(root, ".claude", "settings.json"), `{
79 "hooks": {
80 "PostToolUse": [
81 {
82 "matcher": "bash|write_file",
83 "hooks": [
84 {
85 "type": "command",
86 "command": "node hooks/post-tool.js",
87 "description": "post tool check",
88 "timeout": 3,
89 "env": { "MODE": "check" }
90 },
91 { "type": "prompt", "command": "ignored" }
92 ]
93 }
94 ],
95 "UserPromptSubmit": [
96 {
97 "hooks": [
98 { "type": "command", "command": "node hooks/prompt.js" }
99 ]
100 }
101 ]
102 }
103 }`)
104
105 pkg, warnings, err := ParseDir(root)
106 if err != nil {
107 t.Fatalf("ParseDir: %v", err)
108 }
109 if len(warnings) != 1 || warnings[0] == "" {
110 t.Fatalf("warnings = %v, want unsupported hook warning", warnings)
111 }
112 if got := pkg.Manifest.Hooks["SessionStart"]; len(got) != 1 || got[0].ContextFile != "CLAUDE.md" {
113 t.Fatalf("SessionStart hooks = %+v, want CLAUDE.md context hook", got)
114 }
115 if got := pkg.Manifest.Hooks["PostToolUse"]; len(got) != 1 || got[0].Match != "bash|write_file" || got[0].Command != "node hooks/post-tool.js" || got[0].Timeout != 3000 || got[0].Env["MODE"] != "check" {
116 t.Fatalf("PostToolUse hooks = %+v", got)
117 }
118 if got := pkg.Manifest.Hooks["UserPromptSubmit"]; len(got) != 1 || got[0].Command != "node hooks/prompt.js" {
119 t.Fatalf("UserPromptSubmit hooks = %+v", got)
120 }
121 }
122
123 func TestParseClaudePluginManifestDoesNotLoadRootClaudeInstructions(t *testing.T) {
124 root := t.TempDir()
125 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{
126 "name": "ui-ux-pro-max",
127 "version": "2.6.2",
128 "description": "UI/UX design intelligence",
129 "skills": "./.claude/skills/"
130 }`)
131 writeTestFile(t, filepath.Join(root, ".claude", "skills", "ui-ux-pro-max", "SKILL.md"), "---\ndescription: UI design helper\n---\nbody")
132 writeTestFile(t, filepath.Join(root, "CLAUDE.md"), "Use the bundled UI workflow.")
133
134 pkg, warnings, err := ParseDir(root)
135 if err != nil {
136 t.Fatalf("ParseDir: %v", err)
137 }
138 if len(warnings) != 0 {
139 t.Fatalf("warnings = %v, want none", warnings)
140 }
141 if pkg.ManifestKind != "claude" || pkg.Manifest.Name != "ui-ux-pro-max" || pkg.Manifest.Version != "2.6.2" {
142 t.Fatalf("pkg = %+v", pkg)
143 }
144 if got := pkg.SkillRoots(); len(got) != 1 || got[0] != filepath.Join(root, ".claude", "skills") {
145 t.Fatalf("SkillRoots = %#v", got)
146 }
147 inv := pkg.Inventory()
148 if len(inv.Skills) != 1 || inv.Skills[0].Name != "ui-ux-pro-max" || inv.Skills[0].Invocation != "/ui-ux-pro-max" {
149 t.Fatalf("Inventory().Skills = %+v", inv.Skills)
150 }
151 if hooks := pkg.Manifest.Hooks["SessionStart"]; len(hooks) != 0 {
152 t.Fatalf("SessionStart hooks = %+v, want plugin-root CLAUDE.md ignored", hooks)
153 }
154 if ManifestPath(pkg.ManifestKind) != ClaudeManifest {
155 t.Fatalf("ManifestPath(%q) = %q, want %q", pkg.ManifestKind, ManifestPath(pkg.ManifestKind), ClaudeManifest)
156 }
157 }
158
159 func TestParseCodexWithoutSessionStartHookDoesNotWarn(t *testing.T) {
160 root := t.TempDir()
161 writeTestFile(t, filepath.Join(root, CodexManifest), `{
162 "name": "skills-only",
163 "skills": "skills"
164 }`)
165
166 _, warnings, err := ParseDir(root)
167 if err != nil {
168 t.Fatalf("ParseDir: %v", err)
169 }
170 if len(warnings) != 0 {
171 t.Fatalf("warnings = %v, want none", warnings)
172 }
173 }
174
175 func TestRejectsEscapingSkillPath(t *testing.T) {
176 root := t.TempDir()
177 writeTestFile(t, filepath.Join(root, NativeManifest), `{
178 "apiVersion": "reasonix.io/plugin/v2",
179 "name": "bad",
180 "skills": "../skills"
181 }`)
182 if _, _, err := ParseDir(root); err == nil {
183 t.Fatal("ParseDir should reject escaping skill path")
184 }
185 }
186
187 func TestStateRoundTripSortsPlugins(t *testing.T) {
188 home := t.TempDir()
189 if err := Upsert(home, InstalledPlugin{Name: "zeta", Root: "plugins/zeta", Enabled: true}); err != nil {
190 t.Fatal(err)
191 }
192 if err := Upsert(home, InstalledPlugin{Name: "alpha", Root: "plugins/alpha", Enabled: false}); err != nil {
193 t.Fatal(err)
194 }
195 st, err := LoadState(home)
196 if err != nil {
197 t.Fatal(err)
198 }
199 if len(st.Plugins) != 2 || st.Plugins[0].Name != "alpha" || st.Plugins[1].Name != "zeta" {
200 t.Fatalf("state plugins = %+v", st.Plugins)
201 }
202 }
203
204 func TestInstalledTextDescribesUsageInventory(t *testing.T) {
205 home := t.TempDir()
206 root := filepath.Join(home, "plugins", "superpowers")
207 writeTestFile(t, filepath.Join(root, CodexManifest), `{
208 "name": "superpowers",
209 "version": "6.1.0",
210 "description": "Planning workflows",
211 "skills": "skills"
212 }`)
213 writeTestFile(t, filepath.Join(root, "skills", "plan", "SKILL.md"), "---\ndescription: Plan work\nrunAs: subagent\n---\nbody")
214 writeTestFile(t, filepath.Join(root, "hooks", "session-start-codex"), "#!/usr/bin/env bash\n")
215 if err := Upsert(home, InstalledPlugin{Name: "superpowers", Root: "plugins/superpowers", Version: "6.1.0", Description: "Planning workflows", ManifestKind: "codex", Enabled: true}); err != nil {
216 t.Fatal(err)
217 }
218 list, err := InstalledListText(home)
219 if err != nil {
220 t.Fatal(err)
221 }
222 for _, want := range []string{"plugins (1):", "superpowers [enabled]", "1 skills / 1 hooks", "/plugins show <name>"} {
223 if !strings.Contains(list, want) {
224 t.Fatalf("InstalledListText missing %q:\n%s", want, list)
225 }
226 }
227 details, err := InstalledShowText(home, "superpowers")
228 if err != nil {
229 t.Fatal(err)
230 }
231 for _, want := range []string{"plugin superpowers [enabled]", "usage: enabled plugins load into new sessions", "/superpowers:plan [subagent] - Plan work", "SessionStart"} {
232 if !strings.Contains(details, want) {
233 t.Fatalf("InstalledShowText missing %q:\n%s", want, details)
234 }
235 }
236 }
237
238 func writeTestFile(t *testing.T, path, body string) {
239 t.Helper()
240 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
241 t.Fatal(err)
242 }
243 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
244 t.Fatal(err)
245 }
246 }
247
248 // TestParseClaudePluginConventionSkillDirs pins the standard Claude plugin
249 // shape: plugin.json carries metadata only, and skills live in the
250 // conventional skills/ directory that Claude auto-discovers. Without the
251 // fallback such a package installed as zero capabilities with no warning.
252 func TestParseClaudePluginConventionSkillDirs(t *testing.T) {
253 root := t.TempDir()
254 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{
255 "name": "design-pack",
256 "version": "1.0.0",
257 "description": "metadata-only manifest"
258 }`)
259 writeTestFile(t, filepath.Join(root, "skills", "design-review", "SKILL.md"), "---\ndescription: review designs\n---\nbody")
260
261 pkg, warnings, err := ParseDir(root)
262 if err != nil {
263 t.Fatalf("ParseDir: %v", err)
264 }
265 if len(warnings) != 0 {
266 t.Fatalf("warnings = %v, want none", warnings)
267 }
268 if pkg.ManifestKind != "claude" {
269 t.Fatalf("kind = %q", pkg.ManifestKind)
270 }
271 if got := pkg.SkillRoots(); len(got) != 1 || got[0] != filepath.Join(root, "skills") {
272 t.Fatalf("SkillRoots = %#v, want conventional skills dir", got)
273 }
274 if inv := pkg.Inventory(); len(inv.Skills) != 1 || inv.Skills[0].Name != "design-review" {
275 t.Fatalf("Inventory().Skills = %+v", inv.Skills)
276 }
277 }
278
279 func TestParseClaudePluginDotClaudeConventionDir(t *testing.T) {
280 root := t.TempDir()
281 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "pack"}`)
282 writeTestFile(t, filepath.Join(root, ".claude", "skills", "helper", "SKILL.md"), "---\ndescription: helper\n---\nbody")
283
284 pkg, _, err := ParseDir(root)
285 if err != nil {
286 t.Fatalf("ParseDir: %v", err)
287 }
288 if got := pkg.SkillRoots(); len(got) != 1 || got[0] != filepath.Join(root, ".claude", "skills") {
289 t.Fatalf("SkillRoots = %#v, want .claude/skills", got)
290 }
291 }
292
293 func TestParseClaudePluginIgnoresEmptyConventionDirAndExplicitSkillsWin(t *testing.T) {
294 root := t.TempDir()
295 // Empty conventional dir (no SKILL.md inside) must not be adopted.
296 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "empty-pack"}`)
297 if err := os.MkdirAll(filepath.Join(root, "skills", "stub"), 0o755); err != nil {
298 t.Fatal(err)
299 }
300 pkg, _, err := ParseDir(root)
301 if err != nil {
302 t.Fatalf("ParseDir: %v", err)
303 }
304 if got := pkg.SkillRoots(); len(got) != 0 {
305 t.Fatalf("SkillRoots = %#v, want none for a skill-less conventional dir", got)
306 }
307
308 // Explicit skills declaration disables the fallback entirely.
309 root2 := t.TempDir()
310 writeTestFile(t, filepath.Join(root2, ClaudeManifest), `{"name": "explicit-pack", "skills": "./custom/"}`)
311 writeTestFile(t, filepath.Join(root2, "custom", "one", "SKILL.md"), "---\ndescription: one\n---\nbody")
312 writeTestFile(t, filepath.Join(root2, "skills", "two", "SKILL.md"), "---\ndescription: two\n---\nbody")
313 pkg2, _, err := ParseDir(root2)
314 if err != nil {
315 t.Fatalf("ParseDir explicit: %v", err)
316 }
317 if got := pkg2.SkillRoots(); len(got) != 1 || got[0] != filepath.Join(root2, "custom") {
318 t.Fatalf("SkillRoots = %#v, want only the declared custom dir", got)
319 }
320 }
321
322 func TestParseClaudeHooksKeepsDistinctEnvTimeoutAsyncCwd(t *testing.T) {
323 root := t.TempDir()
324 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "hook-pack"}`)
325 // Same event/matcher/command/args, but each block differs in exactly one
326 // of env, timeout, async, cwd — none should be dropped as a duplicate of
327 // another.
328 writeTestFile(t, filepath.Join(root, "hooks", "hooks.json"), `{
329 "hooks": {"PreToolUse": [
330 {"matcher": "bash", "hooks": [{"type":"command","command":"bin/guard","env":{"MODE":"a"}}]},
331 {"matcher": "bash", "hooks": [{"type":"command","command":"bin/guard","env":{"MODE":"b"}}]},
332 {"matcher": "bash", "hooks": [{"type":"command","command":"bin/guard","env":{"MODE":"b"},"timeout":5}]},
333 {"matcher": "bash", "hooks": [{"type":"command","command":"bin/guard","env":{"MODE":"b"},"timeout":5,"async":true}]},
334 {"matcher": "bash", "hooks": [{"type":"command","command":"bin/guard","env":{"MODE":"b"},"timeout":5,"async":true}]}
335 ]}
336 }`)
337
338 pkg, _, err := ParseDir(root)
339 if err != nil {
340 t.Fatalf("ParseDir: %v", err)
341 }
342 hooks := pkg.Manifest.Hooks["PreToolUse"]
343 // Four distinct configurations; the fifth block is an exact duplicate of
344 // the fourth (same env, timeout, and async) and must still be dropped.
345 if len(hooks) != 4 {
346 t.Fatalf("hooks = %#v, want 4 distinct configurations (dedup must not collapse different env/timeout/async)", hooks)
347 }
348 }
349
350 func TestParseClaudeHooksPreservesExecAndShellForms(t *testing.T) {
351 root := t.TempDir()
352 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "hook-contract-pack"}`)
353 writeTestFile(t, filepath.Join(root, "hooks", "hooks.json"), `{
354 "hooks": {"SessionStart": [
355 {"hooks": [
356 {"type":"command","command":"node","args":[],"shell":"powershell"},
357 {"type":"command","command":"tool","args":[""," spaced ","$HOME"]},
358 {"type":"command","command":"Write-Output \"a && b\"","shell":"powershell"}
359 ]}
360 ]}
361 }`)
362
363 pkg, warnings, err := ParseDir(root)
364 if err != nil {
365 t.Fatalf("ParseDir: %v", err)
366 }
367 if len(warnings) != 0 {
368 t.Fatalf("warnings = %v, want none", warnings)
369 }
370 hooks := pkg.Manifest.Hooks["SessionStart"]
371 if len(hooks) != 3 {
372 t.Fatalf("hooks = %#v, want 3", hooks)
373 }
374 if !hooks[0].ArgsSet || hooks[0].Args == nil || len(hooks[0].Args) != 0 || hooks[0].Shell != "" {
375 t.Fatalf("explicit empty args did not remain exec form (and ignore shell): %#v", hooks[0])
376 }
377 wantArgs := []string{"", " spaced ", "$HOME"}
378 if !hooks[1].ArgsSet || !reflect.DeepEqual(hooks[1].Args, wantArgs) {
379 t.Fatalf("literal exec args = %#v, want %#v", hooks[1].Args, wantArgs)
380 }
381 if hooks[2].ArgsSet || hooks[2].Shell != "powershell" || !hooks[2].ShellCommand {
382 t.Fatalf("PowerShell hook did not remain shell form: %#v", hooks[2])
383 }
384 }
385
386 func TestHookJSONPreservesExplicitEmptyArgs(t *testing.T) {
387 var hook Hook
388 if err := json.Unmarshal([]byte(`{"command":"bin/check","args":[]}`), &hook); err != nil {
389 t.Fatal(err)
390 }
391 if !hook.ArgsSet || hook.Args == nil || len(hook.Args) != 0 {
392 t.Fatalf("hook = %#v, want explicit empty exec-form args", hook)
393 }
394 }
395
396 func TestParseClaudeHooksWarnOnUnsupportedSemantics(t *testing.T) {
397 cases := []struct {
398 name string
399 hooksJSON string
400 wantSub string
401 }{
402 {
403 name: "conditional-if-runs-unconditionally",
404 hooksJSON: `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"bin/guard","if":"Bash(git *)"}]}]}}`,
405 wantSub: `does not evaluate`,
406 },
407 {
408 name: "asyncRewake-not-supported",
409 hooksJSON: `{"hooks":{"PostToolUse":[{"hooks":[{"type":"command","command":"bin/watch","asyncRewake":true}]}]}}`,
410 wantSub: `asyncRewake`,
411 },
412 {
413 name: "stop-cannot-block",
414 hooksJSON: `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bin/gate"}]}]}}`,
415 wantSub: `cannot block the turn`,
416 },
417 {
418 name: "subagentstop-cannot-block",
419 hooksJSON: `{"hooks":{"SubagentStop":[{"hooks":[{"type":"command","command":"bin/gate"}]}]}}`,
420 wantSub: `cannot block the turn`,
421 },
422 {
423 name: "matcher-names-unsupported-tool",
424 hooksJSON: `{"hooks":{"PreToolUse":[{"matcher":"WebSearch","hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
425 wantSub: `will never fire`,
426 },
427 {
428 name: "matcher-alternation-all-unsupported",
429 hooksJSON: `{"hooks":{"PermissionRequest":[{"matcher":"ExitPlanMode|EnterPlanMode","hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
430 wantSub: `will never fire`,
431 },
432 {
433 name: "webfetch-required-prompt-is-unavailable",
434 hooksJSON: `{"hooks":{"PreToolUse":[{"matcher":"WebFetch","hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
435 wantSub: `required "prompt"`,
436 },
437 {
438 name: "mixed-matcher-includes-webfetch",
439 hooksJSON: `{"hooks":{"PostToolUse":[{"matcher":"Bash|WebFetch","hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
440 wantSub: `required "prompt"`,
441 },
442 {
443 name: "wildcard-matcher-includes-webfetch",
444 hooksJSON: `{"hooks":{"PermissionRequest":[{"matcher":"*","hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
445 wantSub: `required "prompt"`,
446 },
447 {
448 name: "empty-matcher-includes-webfetch",
449 hooksJSON: `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
450 wantSub: `required "prompt"`,
451 },
452 {
453 name: "regex-matcher-includes-webfetch",
454 hooksJSON: `{"hooks":{"PreToolUse":[{"matcher":"Web(Fetch|Search)","hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
455 wantSub: `required "prompt"`,
456 },
457 {
458 name: "notebook-cell-number-has-no-claude-equivalent",
459 hooksJSON: `{"hooks":{"PreToolUse":[{"matcher":"NotebookEdit","hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
460 wantSub: `cell_number`,
461 },
462 {
463 name: "task-output-may-cover-multiple-jobs",
464 hooksJSON: `{"hooks":{"PostToolUse":[{"matcher":"TaskOutput","hooks":[{"type":"command","command":"bin/watch"}]}]}}`,
465 wantSub: `multiple or all background jobs`,
466 },
467 {
468 name: "legacy-bash-output-may-cover-multiple-jobs",
469 hooksJSON: `{"hooks":{"PostToolUse":[{"matcher":"BashOutput","hooks":[{"type":"command","command":"bin/watch"}]}]}}`,
470 wantSub: `multiple or all background jobs`,
471 },
472 }
473 for _, c := range cases {
474 t.Run(c.name, func(t *testing.T) {
475 root := t.TempDir()
476 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "hook-pack"}`)
477 writeTestFile(t, filepath.Join(root, "hooks", "hooks.json"), c.hooksJSON)
478
479 pkg, warnings, err := ParseDir(root)
480 if err != nil {
481 t.Fatalf("ParseDir: %v", err)
482 }
483 if pkg.Compatibility.Status != "partial" {
484 t.Fatalf("compatibility status = %q, want partial (unsupported semantics must not claim full compatibility)", pkg.Compatibility.Status)
485 }
486 found := false
487 for _, w := range warnings {
488 if strings.Contains(w, c.wantSub) {
489 found = true
490 break
491 }
492 }
493 if !found {
494 t.Fatalf("warnings = %v, want one containing %q", warnings, c.wantSub)
495 }
496 // The hook is still imported best-effort — dropping it entirely
497 // could remove a plugin's only safety hook.
498 if pkg.Manifest.Hooks == nil {
499 t.Fatal("hook should still be imported despite the unsupported semantics")
500 }
501 })
502 }
503 }
504
505 func TestParseClaudeHooksSkipsUnsupportedShell(t *testing.T) {
506 root := t.TempDir()
507 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "hook-pack"}`)
508 writeTestFile(t, filepath.Join(root, "hooks", "hooks.json"),
509 `{"hooks":{"PostToolUse":[{"hooks":[{"type":"command","command":"echo ok","shell":"cmd"}]}]}}`)
510
511 pkg, warnings, err := ParseDir(root)
512 if err != nil {
513 t.Fatalf("ParseDir: %v", err)
514 }
515 if pkg.Compatibility.Status != "none" {
516 t.Fatalf("compatibility status = %q, want none", pkg.Compatibility.Status)
517 }
518 if len(pkg.Manifest.Hooks) != 0 {
519 t.Fatalf("unsupported shell hook was imported: %#v", pkg.Manifest.Hooks)
520 }
521 if len(warnings) != 1 || !strings.Contains(warnings[0], `unsupported shell "cmd"`) {
522 t.Fatalf("warnings = %v, want unsupported shell diagnostic", warnings)
523 }
524 }
525
526 // TestParseClaudeHooksReportsStructuralGapsOncePerFile pins the noise bound:
527 // a plugin with several wildcard hooks reports each structural input gap
528 // (WebFetch prompt, NotebookEdit cell_number, TaskOutput multi-job) once per
529 // hooks file, not once per hook item.
530 func TestParseClaudeHooksReportsStructuralGapsOncePerFile(t *testing.T) {
531 root := t.TempDir()
532 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "hook-pack"}`)
533 writeTestFile(t, filepath.Join(root, "hooks", "hooks.json"), `{"hooks":{
534 "PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"bin/a"},{"type":"command","command":"bin/b"}]}],
535 "PostToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"bin/c"}]}]
536 }}`)
537
538 pkg, warnings, err := ParseDir(root)
539 if err != nil {
540 t.Fatalf("ParseDir: %v", err)
541 }
542 if pkg.Compatibility.Status != "partial" {
543 t.Fatalf("compatibility status = %q, want partial", pkg.Compatibility.Status)
544 }
545 gapSubs := []string{`required "prompt"`, "cell_number", "multiple or all background jobs"}
546 for _, sub := range gapSubs {
547 warned := 0
548 for _, w := range warnings {
549 if strings.Contains(w, sub) {
550 warned++
551 }
552 }
553 if warned != 1 {
554 t.Errorf("warnings mentioning %q = %d, want exactly 1 per hooks file (got %v)", sub, warned, warnings)
555 }
556 skipped := 0
557 for _, issue := range pkg.Compatibility.Skipped {
558 if strings.Contains(issue.Reason, sub) {
559 skipped++
560 }
561 }
562 if skipped != 1 {
563 t.Errorf("compatibility issues mentioning %q = %d, want exactly 1 per hooks file", sub, skipped)
564 }
565 }
566 }
567
568 func TestParseClaudeHooksDoesNotWarnOnMatchersThatCanFire(t *testing.T) {
569 cases := []struct {
570 name string
571 hooksJSON string
572 }{
573 {
574 name: "supported-tool-name",
575 hooksJSON: `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
576 },
577 {
578 // A partly-unsupported alternation can still fire for Bash calls,
579 // so it must not be flagged as dead.
580 name: "mixed-alternation-still-fires",
581 hooksJSON: `{"hooks":{"PreToolUse":[{"matcher":"Bash|WebSearch","hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
582 },
583 {
584 // A regex beyond a plain "|" alternation isn't evaluated, to
585 // avoid guessing wrong and producing a false positive.
586 name: "complex-regex-not-evaluated",
587 hooksJSON: `{"hooks":{"PreToolUse":[{"matcher":"WebSearch.*","hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
588 },
589 {
590 // A previously-unmapped Reasonix tool the fix now supports.
591 name: "run-skill-now-mapped",
592 hooksJSON: `{"hooks":{"PreToolUse":[{"matcher":"Skill","hooks":[{"type":"command","command":"bin/guard"}]}]}}`,
593 },
594 }
595 for _, c := range cases {
596 t.Run(c.name, func(t *testing.T) {
597 root := t.TempDir()
598 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "hook-pack"}`)
599 writeTestFile(t, filepath.Join(root, "hooks", "hooks.json"), c.hooksJSON)
600
601 pkg, warnings, err := ParseDir(root)
602 if err != nil {
603 t.Fatalf("ParseDir: %v", err)
604 }
605 for _, w := range warnings {
606 if strings.Contains(w, "will never fire") {
607 t.Fatalf("warnings = %v, want no dead-matcher warning", warnings)
608 }
609 }
610 if pkg.Compatibility.Status != "full" {
611 t.Fatalf("compatibility status = %q, want full", pkg.Compatibility.Status)
612 }
613 })
614 }
615 }
616
617 func TestParseClaudePluginMapsConventionCapabilities(t *testing.T) {
618 root := t.TempDir()
619 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "big-pack"}`)
620 writeTestFile(t, filepath.Join(root, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
621 writeTestFile(t, filepath.Join(root, "commands", "deploy.md"), "run deploy")
622 writeTestFile(t, filepath.Join(root, "agents", "reviewer.md"), "---\nname: reviewer\ndescription: review changes\nmodel: sonnet\ntools: [Read, Grep]\n---\nReview carefully.")
623 writeTestFile(t, filepath.Join(root, "hooks", "hooks.json"), `{
624 "hooks": {"SessionStart": [{"hooks": [{"type":"command","command":"bin/start","args":["--hook"],"async":true}]}]}
625 }`)
626 writeTestFile(t, filepath.Join(root, ".mcp.json"), `{
627 "mcpServers": {"Google Drive": {"type":"local","command":"uvx","args":["drive-mcp"],"title":"Drive"}}
628 }`)
629
630 pkg, warnings, err := ParseDir(root)
631 if err != nil {
632 t.Fatalf("ParseDir: %v", err)
633 }
634 if len(warnings) != 0 {
635 t.Fatalf("warnings = %v, want fully mapped package", warnings)
636 }
637 if pkg.Compatibility.Status != "full" || pkg.AgentCount() != 1 {
638 t.Fatalf("compatibility = %+v agents=%d", pkg.Compatibility, pkg.AgentCount())
639 }
640 agent := pkg.Inventory().Agents[0]
641 if agent.Name != "reviewer" || agent.Model != "sonnet" || strings.Join(agent.AllowedTools, ",") != "Read,Grep" {
642 t.Fatalf("agent = %+v", agent)
643 }
644 hook := pkg.Manifest.Hooks["SessionStart"][0]
645 if !hook.Async || hook.PayloadFormat != "claude" || strings.Join(hook.Args, ",") != "--hook" {
646 t.Fatalf("hook = %+v", hook)
647 }
648 if len(pkg.Manifest.MCPServers) != 1 {
649 t.Fatalf("MCP servers = %+v", pkg.Manifest.MCPServers)
650 }
651 for name, server := range pkg.Manifest.MCPServers {
652 if !IsValidName(name) || server.Type != "stdio" || server.DisplayName != "Drive" || server.AutoStart == nil || *server.AutoStart {
653 t.Fatalf("MCP %q = %+v", name, server)
654 }
655 }
656 }
657
658 func TestClaudeMCPServerIDUsesConnectionIdentityAndPreservesValidNames(t *testing.T) {
659 identity := claudeMCPIdentity{Type: "http", URL: "https://open.feishu.cn/mcp"}
660 if got := claudeMCPServerID("yuandian", identity); got != "yuandian" {
661 t.Fatalf("valid MCP ID changed to %q", got)
662 }
663 first := claudeMCPServerID("飞书", identity)
664 second := claudeMCPServerID("飞书", identity)
665 if first != second || !IsValidName(first) {
666 t.Fatalf("stable MCP IDs = %q / %q", first, second)
667 }
668 different := claudeMCPServerID("飞书", claudeMCPIdentity{Type: "http", URL: "https://example.com/other"})
669 if different == first {
670 t.Fatalf("different endpoints shared MCP ID %q", first)
671 }
672 }
673
674 // TestParseClaudePluginMapsCommandsDir pins the commands mapping: a Claude
675 // plugin's conventional commands/ dir becomes a Manifest.Commands root — even
676 // when the manifest declares skills explicitly — and its flat <name>.md
677 // templates surface in the inventory as /<name> invocations.
678 func TestParseClaudePluginMapsCommandsDir(t *testing.T) {
679 root := t.TempDir()
680 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "pwf-pack"}`)
681 writeTestFile(t, filepath.Join(root, "skills", "planner", "SKILL.md"), "---\ndescription: planner skill\n---\nbody")
682 writeTestFile(t, filepath.Join(root, "commands", "plan.md"), "---\ndescription: \"Start planning\"\nargument-hint: \"[task]\"\n---\nPlan: $ARGUMENTS")
683 writeTestFile(t, filepath.Join(root, "commands", "status.md"), "Show status")
684
685 pkg, warnings, err := ParseDir(root)
686 if err != nil {
687 t.Fatalf("ParseDir: %v", err)
688 }
689 if len(warnings) != 0 {
690 t.Fatalf("warnings = %v, want none for a fully mapped plugin", warnings)
691 }
692 if got := pkg.CommandRoots(); len(got) != 1 || got[0] != filepath.Join(root, "commands") {
693 t.Fatalf("CommandRoots = %#v, want the conventional commands dir", got)
694 }
695 inv := pkg.Inventory()
696 if len(inv.Commands) != 2 {
697 t.Fatalf("inventory commands = %#v, want plan and status", inv.Commands)
698 }
699 byName := map[string]CommandRef{}
700 for _, c := range inv.Commands {
701 byName[c.Name] = c
702 }
703 plan, ok := byName["plan"]
704 if !ok || plan.Invocation != "/plan" || plan.Description != "Start planning" || plan.ArgHint != "[task]" {
705 t.Fatalf("plan command = %+v, want /plan with description and arg hint", plan)
706 }
707 if _, ok := byName["status"]; !ok {
708 t.Fatalf("inventory commands = %#v, want frontmatter-less status command included", inv.Commands)
709 }
710 skills, commands, hooks, mcp := pkg.CapabilityCounts()
711 if skills != 1 || commands != 2 || hooks != 0 || mcp != 0 {
712 t.Fatalf("CapabilityCounts = %d skills %d commands %d hooks %d mcp, want 1/2/0/0", skills, commands, hooks, mcp)
713 }
714
715 // Explicit skills declaration must not disable command adoption.
716 root2 := t.TempDir()
717 writeTestFile(t, filepath.Join(root2, ClaudeManifest), `{"name": "explicit-pack", "skills": "./custom/"}`)
718 writeTestFile(t, filepath.Join(root2, "custom", "one", "SKILL.md"), "---\ndescription: one\n---\nbody")
719 writeTestFile(t, filepath.Join(root2, "commands", "go.md"), "go")
720 pkg2, _, err := ParseDir(root2)
721 if err != nil {
722 t.Fatalf("ParseDir explicit: %v", err)
723 }
724 if got := pkg2.CommandRoots(); len(got) != 1 || got[0] != filepath.Join(root2, "commands") {
725 t.Fatalf("CommandRoots = %#v, want commands adopted alongside explicit skills", got)
726 }
727
728 // A docs-only commands dir (no installable <name>.md) is not adopted.
729 root3 := t.TempDir()
730 writeTestFile(t, filepath.Join(root3, ClaudeManifest), `{"name": "docs-pack"}`)
731 writeTestFile(t, filepath.Join(root3, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
732 writeTestFile(t, filepath.Join(root3, "commands", "notes.txt"), "not a command")
733 pkg3, _, err := ParseDir(root3)
734 if err != nil {
735 t.Fatalf("ParseDir docs-only: %v", err)
736 }
737 if got := pkg3.CommandRoots(); len(got) != 0 {
738 t.Fatalf("CommandRoots = %#v, want none for a commands dir without .md files", got)
739 }
740 }
741
742 // TestNativeManifestCommandsField pins the explicit "commands" declaration in
743 // reasonix-plugin.json, including path validation.
744 func TestNativeManifestCommandsField(t *testing.T) {
745 root := t.TempDir()
746 writeTestFile(t, filepath.Join(root, NativeManifest), `{"apiVersion":"reasonix.io/plugin/v2","name": "native-pack", "commands": ["cmds"]}`)
747 writeTestFile(t, filepath.Join(root, "cmds", "ship.md"), "---\ndescription: ship it\n---\nShip $1")
748
749 pkg, _, err := ParseDir(root)
750 if err != nil {
751 t.Fatalf("ParseDir: %v", err)
752 }
753 if got := pkg.CommandRoots(); len(got) != 1 || got[0] != filepath.Join(root, "cmds") {
754 t.Fatalf("CommandRoots = %#v, want declared cmds dir", got)
755 }
756 inv := pkg.Inventory()
757 if len(inv.Commands) != 1 || inv.Commands[0].Name != "ship" {
758 t.Fatalf("inventory commands = %#v, want ship", inv.Commands)
759 }
760
761 rootBad := t.TempDir()
762 writeTestFile(t, filepath.Join(rootBad, NativeManifest), `{"apiVersion":"reasonix.io/plugin/v2","name": "bad-pack", "commands": ["../escape"]}`)
763 if _, _, err := ParseDir(rootBad); err == nil {
764 t.Fatal("ParseDir must reject a commands path escaping the plugin root")
765 }
766 }
767
768 // TestParseClaudePluginDoesNotRegisterCodexSessionStartHook pins the security
769 // boundary of the includeCodexSessionStartHook flag: a claude-kind package
770 // shipping a hooks/session-start-codex file must NOT get it registered as an
771 // executable SessionStart hook (that convention belongs to codex manifests).
772 func TestParseClaudePluginDoesNotRegisterCodexSessionStartHook(t *testing.T) {
773 root := t.TempDir()
774 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "sneaky-pack"}`)
775 writeTestFile(t, filepath.Join(root, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
776 writeTestFile(t, filepath.Join(root, "hooks", "session-start-codex"), "#!/bin/sh\necho pwned\n")
777
778 pkg, _, err := ParseDir(root)
779 if err != nil {
780 t.Fatalf("ParseDir: %v", err)
781 }
782 for _, h := range pkg.Manifest.Hooks["SessionStart"] {
783 if h.Command != "" {
784 t.Fatalf("claude package registered executable SessionStart hook: %+v", h)
785 }
786 }
787 }
788
789 // TestParseCodexManifestNotAffectedByClaudeFallback: the convention-dir
790 // fallback is claude-only; a codex manifest without a skills field keeps its
791 // existing "no skills" behavior even when a skills/ directory exists.
792 func TestParseCodexManifestNotAffectedByClaudeFallback(t *testing.T) {
793 root := t.TempDir()
794 writeTestFile(t, filepath.Join(root, CodexManifest), `{"name": "codex-pack"}`)
795 writeTestFile(t, filepath.Join(root, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
796
797 pkg, _, err := ParseDir(root)
798 if err != nil {
799 t.Fatalf("ParseDir: %v", err)
800 }
801 if pkg.ManifestKind != "codex" {
802 t.Fatalf("kind = %q", pkg.ManifestKind)
803 }
804 if got := pkg.SkillRoots(); len(got) != 0 {
805 t.Fatalf("SkillRoots = %#v, codex parsing must not adopt convention dirs", got)
806 }
807 }
808
809 // TestParseClaudePluginAdoptsNestedCommands pins that namespace layouts like
810 // commands/git/commit.md — which the runtime loader walks — also gate command
811 // root adoption, and surface in the inventory under their namespaced name.
812 func TestParseClaudePluginAdoptsNestedCommands(t *testing.T) {
813 root := t.TempDir()
814 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "nested-pack"}`)
815 writeTestFile(t, filepath.Join(root, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
816 writeTestFile(t, filepath.Join(root, "commands", "git", "commit.md"), "---\ndescription: commit helper\n---\nCommit: $ARGUMENTS")
817
818 pkg, warnings, err := ParseDir(root)
819 if err != nil {
820 t.Fatalf("ParseDir: %v", err)
821 }
822 if len(warnings) != 0 {
823 t.Fatalf("warnings = %v, want none", warnings)
824 }
825 if got := pkg.CommandRoots(); len(got) != 1 || got[0] != filepath.Join(root, "commands") {
826 t.Fatalf("CommandRoots = %#v, want commands adopted for nested-only layout", got)
827 }
828 inv := pkg.Inventory()
829 if len(inv.Commands) != 1 || inv.Commands[0].Name != "git:commit" || inv.Commands[0].Invocation != "/git:commit" {
830 t.Fatalf("inventory commands = %#v, want namespaced git:commit", inv.Commands)
831 }
832 }
833
834 // TestInventoryTextCommandsOnly pins that a commands-only inventory does not
835 // also claim "no detailed inventory available".
836 func TestInventoryTextCommandsOnly(t *testing.T) {
837 var b strings.Builder
838 appendInventoryText(&b, "superpowers", Inventory{Commands: []CommandRef{{Name: "plan", Invocation: "/plan", Description: "plan things"}}})
839 out := b.String()
840 if !strings.Contains(out, "commands:") || !strings.Contains(out, "/superpowers:plan") {
841 t.Fatalf("output = %q, want the commands listing", out)
842 }
843 if strings.Contains(out, "no detailed inventory available") {
844 t.Fatalf("output = %q, must not claim an empty inventory after listing commands", out)
845 }
846 }
847
848 // TestParseClaudePluginAdoptsDeeplyNestedCommands pins that adoption gating
849 // shares the runtime loader's discovery semantics with no depth ceiling: a
850 // plugin whose only command sits six levels deep is still adopted.
851 func TestParseClaudePluginAdoptsDeeplyNestedCommands(t *testing.T) {
852 root := t.TempDir()
853 writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "deep-pack"}`)
854 writeTestFile(t, filepath.Join(root, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
855 writeTestFile(t, filepath.Join(root, "commands", "a", "b", "c", "d", "e", "commit.md"), "---\ndescription: deep commit\n---\nCommit")
856 pkg, _, err := ParseDir(root)
857 if err != nil {
858 t.Fatalf("ParseDir: %v", err)
859 }
860 if got := pkg.CommandRoots(); len(got) != 1 || got[0] != filepath.Join(root, "commands") {
861 t.Fatalf("CommandRoots = %#v, want commands adopted for the deeply nested layout", got)
862 }
863 inv := pkg.Inventory()
864 if len(inv.Commands) != 1 || inv.Commands[0].Name != "a:b:c:d:e:commit" {
865 t.Fatalf("inventory commands = %#v, want the namespaced deep command", inv.Commands)
866 }
867 }
868
868 lines GO