返回 DeepSeek-Reasonix
install_source_test.go
根目录 / internal / installsource / install_source_test.go
1 package installsource
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "runtime"
13 "slices"
14 "strings"
15 "sync/atomic"
16 "testing"
17
18 "reasonix/internal/config"
19 "reasonix/internal/pluginpkg"
20 "reasonix/internal/skill"
21 "reasonix/internal/testenv"
22 "reasonix/internal/tool"
23 )
24
25 func TestMain(m *testing.M) {
26 testenv.RunWithIsolatedUserState(m)
27 }
28
29 func TestPluginGitCommandDisablesLineEndingConversion(t *testing.T) {
30 cmd := pluginGitCommand(context.Background(), "clone", "https://example.test/repo.git")
31 joined := strings.Join(cmd.Args, " ")
32 if !strings.Contains(joined, "-c core.autocrlf=false clone") {
33 t.Fatalf("plugin git command does not preserve approved source bytes: %v", cmd.Args)
34 }
35 }
36
37 // --- shared helpers ---------------------------------------------------------
38
39 // execInstall marshals args, calls Execute, and unmarshals the response.
40 // Failures in Execute bubble up as t.Fatal; the response is returned so the
41 // caller can assert on the JSON shape.
42 func execInstall(t *testing.T, tl tool.Tool, args map[string]any) response {
43 t.Helper()
44 raw, err := json.Marshal(args)
45 if err != nil {
46 t.Fatal(err)
47 }
48 out, err := tl.Execute(context.Background(), raw)
49 if err != nil {
50 t.Fatalf("Execute error: %v\nout=%s", err, out)
51 }
52 var resp response
53 if err := json.Unmarshal([]byte(out), &resp); err != nil {
54 t.Fatalf("response JSON %q: %v", out, err)
55 }
56 return resp
57 }
58
59 func writeFile(t *testing.T, path, content string) {
60 t.Helper()
61 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
62 t.Fatal(err)
63 }
64 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
65 t.Fatal(err)
66 }
67 }
68
69 func registeredRoots(actions []action) []string {
70 var roots []string
71 for _, a := range actions {
72 if a.Action == "register_skill_root" {
73 roots = append(roots, a.Source)
74 }
75 }
76 return roots
77 }
78
79 // stubConnector returns a ConnectMCP closure that records every entry and
80 // returns the configured tool count + Disconnect. disconnectCalls counts
81 // rollback invocations so tests can assert on ghost-install behavior.
82 type stubConnector struct {
83 connected []config.PluginEntry
84 toolCount int
85 failOnName string
86 disconnectCalls *atomic.Int32
87 }
88
89 func (s *stubConnector) connector() MCPConnector {
90 return func(e config.PluginEntry) (MCPConnectResult, error) {
91 if e.Name == s.failOnName {
92 return MCPConnectResult{}, errors.New("connect refused: " + e.Name)
93 }
94 s.connected = append(s.connected, e)
95 return MCPConnectResult{
96 ToolCount: s.toolCount,
97 Disconnect: func() {
98 if s.disconnectCalls != nil {
99 s.disconnectCalls.Add(1)
100 }
101 },
102 }, nil
103 }
104 }
105
106 // --- apply: skill paths -----------------------------------------------------
107
108 func TestApplyLocalSkillRootRegistersPath(t *testing.T) {
109 project := t.TempDir()
110 home := t.TempDir()
111 root := filepath.Join(t.TempDir(), "shared-skills")
112 if err := os.MkdirAll(root, 0o755); err != nil {
113 t.Fatal(err)
114 }
115 writeFile(t, filepath.Join(root, "alpha.md"), "---\nname: alpha\ndescription: Alpha helper\n---\nDo alpha work.")
116
117 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
118 resp := execInstall(t, tl, map[string]any{
119 "source": root,
120 "kind": "skill",
121 "apply": true,
122 "scope": "project",
123 })
124
125 if !resp.OK || resp.Status != "done" {
126 t.Fatalf("response = %+v", resp)
127 }
128 if len(resp.Actions) != 1 || resp.Actions[0].Action != "register_skill_root" {
129 t.Fatalf("actions = %+v", resp.Actions)
130 }
131 if resp.PlanID == "" {
132 t.Error("PlanID should be populated on apply")
133 }
134 cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
135 if len(cfg.Skills.Paths) != 1 || cfg.Skills.Paths[0] != root {
136 t.Fatalf("skills.paths = %v, want %q", cfg.Skills.Paths, root)
137 }
138 st := skill.New(skill.Options{HomeDir: home, ProjectRoot: project, CustomPaths: cfg.SkillCustomPaths(), DisableBuiltins: true})
139 if _, ok := st.Read("alpha"); !ok {
140 t.Fatal("alpha should be discoverable after registering the skill root")
141 }
142 }
143
144 func TestApplyLocalCodexPluginPackage(t *testing.T) {
145 project := t.TempDir()
146 home := t.TempDir()
147 src := filepath.Join(t.TempDir(), "superpowers")
148 writeFile(t, filepath.Join(src, ".codex-plugin", "plugin.json"), `{
149 "name": "superpowers",
150 "version": "6.1.0",
151 "description": "Planning workflows",
152 "skills": "./skills/"
153 }`)
154 writeFile(t, filepath.Join(src, "skills", "using-superpowers", "SKILL.md"), "---\nname: using-superpowers\ndescription: Use skills\n---\nUse skills.")
155 writeFile(t, filepath.Join(src, "hooks", "session-start-codex"), "#!/usr/bin/env bash\necho ok\n")
156
157 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
158 planned := execInstall(t, tl, map[string]any{
159 "source": src,
160 "kind": "plugin",
161 })
162 if planned.Kind != "plugin" || planned.Kinds.Plugin != 1 {
163 t.Fatalf("planned = %+v, want one plugin action", planned)
164 }
165 if planned.Actions[0].Name != "superpowers" || planned.Actions[0].SkillCount != 1 || planned.Actions[0].HookCount != 1 {
166 t.Fatalf("plugin action = %+v", planned.Actions[0])
167 }
168
169 done := execInstall(t, tl, map[string]any{
170 "source": src,
171 "kind": "plugin",
172 "apply": true,
173 })
174 if !done.OK || done.Status != "done" {
175 t.Fatalf("apply response = %+v", done)
176 }
177 statePath := filepath.Join(home, ".reasonix", "plugin-packages.json")
178 raw, err := os.ReadFile(statePath)
179 if err != nil {
180 t.Fatalf("state file missing: %v", err)
181 }
182 if !strings.Contains(string(raw), `"name": "superpowers"`) || !strings.Contains(string(raw), `"manifestKind": "codex"`) {
183 t.Fatalf("state file = %s", raw)
184 }
185 if _, err := os.Stat(filepath.Join(home, ".reasonix", "plugins", "superpowers", ".codex-plugin", "plugin.json")); err != nil {
186 t.Fatalf("installed plugin missing: %v", err)
187 }
188 }
189
190 func TestApplyLocalClaudePluginPackage(t *testing.T) {
191 project := t.TempDir()
192 home := t.TempDir()
193 src := filepath.Join(t.TempDir(), "ui-ux-pro-max")
194 writeFile(t, filepath.Join(src, ".claude-plugin", "plugin.json"), `{
195 "name": "ui-ux-pro-max",
196 "version": "2.6.2",
197 "description": "UI/UX design intelligence",
198 "skills": "./.claude/skills/"
199 }`)
200 writeFile(t, filepath.Join(src, ".claude", "skills", "ui-ux-pro-max", "SKILL.md"), "---\nname: ui-ux-pro-max\ndescription: UI design helper\n---\nUse design rules.")
201 writeFile(t, filepath.Join(src, "CLAUDE.md"), "Use the bundled UI workflow.")
202
203 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
204 planned := execInstall(t, tl, map[string]any{
205 "source": src,
206 "kind": "plugin",
207 })
208 if planned.Kind != "plugin" || planned.Kinds.Plugin != 1 {
209 t.Fatalf("planned = %+v, want one plugin action", planned)
210 }
211 if planned.Actions[0].Name != "ui-ux-pro-max" || planned.Actions[0].ManifestKind != "claude" || planned.Actions[0].SkillCount != 1 || planned.Actions[0].HookCount != 1 {
212 t.Fatalf("plugin action = %+v", planned.Actions[0])
213 }
214
215 done := execInstall(t, tl, map[string]any{
216 "source": src,
217 "kind": "plugin",
218 "apply": true,
219 })
220 if !done.OK || done.Status != "done" {
221 t.Fatalf("apply response = %+v", done)
222 }
223 statePath := filepath.Join(home, ".reasonix", "plugin-packages.json")
224 raw, err := os.ReadFile(statePath)
225 if err != nil {
226 t.Fatalf("state file missing: %v", err)
227 }
228 if !strings.Contains(string(raw), `"name": "ui-ux-pro-max"`) || !strings.Contains(string(raw), `"manifestKind": "claude"`) {
229 t.Fatalf("state file = %s", raw)
230 }
231 if _, err := os.Stat(filepath.Join(home, ".reasonix", "plugins", "ui-ux-pro-max", ".claude-plugin", "plugin.json")); err != nil {
232 t.Fatalf("installed plugin missing: %v", err)
233 }
234 }
235
236 func TestApplyCopiedPluginPreservesExecutableHookCommand(t *testing.T) {
237 if runtime.GOOS == "windows" {
238 t.Skip("POSIX executable bits are not available on Windows")
239 }
240 project := t.TempDir()
241 home := t.TempDir()
242 src := filepath.Join(t.TempDir(), "executable-plugin")
243 writeFile(t, filepath.Join(src, ".claude-plugin", "plugin.json"), `{"name":"executable-plugin"}`)
244 writeFile(t, filepath.Join(src, "hooks", "hooks.json"), `{
245 "hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"${CLAUDE_PLUGIN_ROOT}/bin/hook","args":["--hook"]}]}]}
246 }`)
247 hookPath := filepath.Join(src, "bin", "hook")
248 writeFile(t, hookPath, "#!/bin/sh\nexit 0\n")
249 if err := os.Chmod(hookPath, 0o755); err != nil {
250 t.Fatal(err)
251 }
252
253 resp := execInstall(t, NewTool(Options{ProjectRoot: project, HomeDir: home}), map[string]any{
254 "source": src, "kind": "plugin", "apply": true,
255 })
256 if !resp.OK {
257 t.Fatalf("response = %+v", resp)
258 }
259 installed := filepath.Join(home, ".reasonix", "plugins", "executable-plugin", "bin", "hook")
260 info, err := os.Stat(installed)
261 if err != nil {
262 t.Fatal(err)
263 }
264 if info.Mode().Perm()&0o111 == 0 {
265 t.Fatalf("installed hook mode = %o, want executable", info.Mode().Perm())
266 }
267 }
268
269 func TestPlanClaudeCompatibilityReportsAgentsHooksAndMCP(t *testing.T) {
270 project := t.TempDir()
271 home := t.TempDir()
272 src := filepath.Join(t.TempDir(), "claude-compat")
273 writeFile(t, filepath.Join(src, ".claude-plugin", "plugin.json"), `{"name":"claude-compat"}`)
274 writeFile(t, filepath.Join(src, "agents", "reviewer.md"), "---\ndescription: Review work\ntools: [Read, Grep]\n---\nReview carefully.")
275 writeFile(t, filepath.Join(src, "hooks", "hooks.json"), `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${CLAUDE_PLUGIN_ROOT}/bin/critter","args":["--hook"],"async":true}]}]}}`)
276 writeFile(t, filepath.Join(src, ".mcp.json"), `{"mcpServers":{"法律检索":{"command":"uvx","args":["legal-search"]}}}`)
277
278 planned := execInstall(t, NewTool(Options{ProjectRoot: project, HomeDir: home}), map[string]any{"source": src, "kind": "plugin"})
279 if len(planned.Actions) != 1 {
280 t.Fatalf("actions = %+v", planned.Actions)
281 }
282 a := planned.Actions[0]
283 // A Stop hook is imported best-effort, but Reasonix's Stop hook is
284 // observation-only and can't block the turn the way Claude's contract
285 // does, so this must report "partial" rather than silently claiming full
286 // compatibility for semantics it doesn't honor.
287 if a.AgentCount != 1 || a.HookCount != 1 || a.ToolCount != 1 || a.Compatibility != "partial" {
288 t.Fatalf("compatibility action = %+v", a)
289 }
290 if len(a.SkippedCapabilities) != 1 || a.SkippedCapabilities[0].Capability != "hooks" ||
291 !strings.Contains(a.SkippedCapabilities[0].Reason, "cannot block the turn") {
292 t.Fatalf("skipped capabilities = %+v, want a Stop-hook cannot-block warning", a.SkippedCapabilities)
293 }
294 if a.RiskLevel != RiskHigh {
295 t.Fatalf("risk = %s, want high", a.RiskLevel)
296 }
297 if !slices.Contains(a.MappedCapabilities, "agents") || !slices.Contains(a.MappedCapabilities, "hooks") || !slices.Contains(a.MappedCapabilities, "mcp") {
298 t.Fatalf("mapped capabilities = %v", a.MappedCapabilities)
299 }
300 }
301
302 func TestPlanClaudePluginWithNoMappedCapabilitiesIsBlocked(t *testing.T) {
303 src := t.TempDir()
304 writeFile(t, filepath.Join(src, ".claude-plugin", "plugin.json"), `{"name":"empty-claude"}`)
305 resp := execInstall(t, NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: t.TempDir()}), map[string]any{"source": src, "kind": "plugin"})
306 if resp.OK || resp.Status != "blocked" || !strings.Contains(resp.Error, "no compatible capabilities") {
307 t.Fatalf("response = %+v", resp)
308 }
309 }
310
311 func TestApplyLocalSkillFileCopiesToProject(t *testing.T) {
312 project := t.TempDir()
313 home := t.TempDir()
314 src := filepath.Join(t.TempDir(), "beta.md")
315 writeFile(t, src, "---\nname: beta\ndescription: Beta helper\n---\nDo beta work.")
316
317 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
318 resp := execInstall(t, tl, map[string]any{
319 "source": src,
320 "kind": "skill",
321 "apply": true,
322 "scope": "project",
323 })
324
325 if !resp.OK || resp.Actions[0].Action != "copy_skill" {
326 t.Fatalf("response = %+v", resp)
327 }
328 if resp.Actions[0].RiskLevel != RiskLow {
329 t.Errorf("copy of a single file should be RiskLow, got %q", resp.Actions[0].RiskLevel)
330 }
331 target := filepath.Join(project, ".reasonix", "skills", "beta", "SKILL.md")
332 if raw, err := os.ReadFile(target); err != nil || !strings.Contains(string(raw), "Beta helper") {
333 t.Fatalf("copied skill = %q err=%v", raw, err)
334 }
335 if resp.Actions[0].CanonicalPath != target || !resp.Actions[0].Discoverable || !resp.Actions[0].Indexed {
336 t.Fatalf("skill verification fields = %+v, want canonical/discoverable/indexed", resp.Actions[0])
337 }
338 }
339
340 func TestApplyLocalSkillFileDoesNotShadowFlatCompatInstall(t *testing.T) {
341 project := t.TempDir()
342 home := t.TempDir()
343 existing := filepath.Join(project, ".reasonix", "skills", "beta.md")
344 writeFile(t, existing, "---\nname: beta\ndescription: Existing beta\n---\nold")
345 src := filepath.Join(t.TempDir(), "beta.md")
346 writeFile(t, src, "---\nname: beta\ndescription: New beta\n---\nnew")
347
348 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
349 resp := execInstall(t, tl, map[string]any{
350 "source": src,
351 "kind": "skill",
352 "apply": true,
353 "scope": "project",
354 })
355
356 if resp.OK {
357 t.Fatalf("canonical install should not shadow existing flat skill, got %+v", resp)
358 }
359 if !strings.Contains(resp.Actions[0].Error, "already exists") {
360 t.Fatalf("error = %q, want duplicate guard", resp.Actions[0].Error)
361 }
362 }
363
364 func TestApplyLocalSKILLFileCopiesSiblingResources(t *testing.T) {
365 project := t.TempDir()
366 home := t.TempDir()
367 srcDir := filepath.Join(t.TempDir(), "frontend-design")
368 writeFile(t, filepath.Join(srcDir, "SKILL.md"), "---\nname: frontend-design\ndescription: Frontend helper\n---\nSee references/style.md")
369 writeFile(t, filepath.Join(srcDir, "references", "style.md"), "# Style\n\nUse crisp layouts.")
370 writeFile(t, filepath.Join(srcDir, "scripts", "lint.sh"), "#!/bin/sh\nexit 0\n")
371
372 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
373 resp := execInstall(t, tl, map[string]any{
374 "source": filepath.Join(srcDir, "SKILL.md"),
375 "kind": "skill",
376 "apply": true,
377 "scope": "project",
378 })
379
380 if !resp.OK {
381 t.Fatalf("response = %+v", resp)
382 }
383 if resp.Actions[0].RiskLevel != RiskMedium {
384 t.Fatalf("directory package copy should be RiskMedium, got %q", resp.Actions[0].RiskLevel)
385 }
386 target := filepath.Join(project, ".reasonix", "skills", "frontend-design", "SKILL.md")
387 if resp.Actions[0].CanonicalPath != target {
388 t.Fatalf("canonicalPath = %q, want %q", resp.Actions[0].CanonicalPath, target)
389 }
390 if _, err := os.Stat(filepath.Join(project, ".reasonix", "skills", "frontend-design", "references", "style.md")); err != nil {
391 t.Fatalf("reference file should be copied with SKILL.md source: %v", err)
392 }
393 if _, err := os.Stat(filepath.Join(project, ".reasonix", "skills", "frontend-design", "scripts", "lint.sh")); err != nil {
394 t.Fatalf("script file should be copied with SKILL.md source: %v", err)
395 }
396 st := skill.New(skill.Options{HomeDir: home, ProjectRoot: project, DisableBuiltins: true})
397 sk, ok := st.Read("frontend-design")
398 if !ok || !strings.Contains(sk.Body, "Use crisp layouts.") {
399 t.Fatalf("installed skill should load copied reference, ok=%v body=%q", ok, sk.Body)
400 }
401 }
402
403 func TestApplyLocalSkillLinkMode(t *testing.T) {
404 project := t.TempDir()
405 home := t.TempDir()
406 src := filepath.Join(project, "local-skills", "gamma.md")
407 writeFile(t, src, "---\nname: gamma\ndescription: Gamma helper\n---\nDo gamma work.")
408
409 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
410 resp := execInstall(t, tl, map[string]any{
411 "source": src,
412 "kind": "skill",
413 "apply": true,
414 "scope": "project",
415 "mode": "link",
416 })
417
418 if !resp.OK {
419 t.Fatalf("response = %+v", resp)
420 }
421 if runtime.GOOS == "windows" {
422 t.Skip("symlink semantics differ on Windows")
423 }
424 if resp.Actions[0].Action != "link_skill" {
425 t.Fatalf("action = %q, want link_skill", resp.Actions[0].Action)
426 }
427 if resp.Actions[0].RiskLevel != RiskMedium && resp.Actions[0].RiskLevel != RiskHigh {
428 t.Errorf("link mode should be at least RiskMedium, got %q", resp.Actions[0].RiskLevel)
429 }
430 target := filepath.Join(project, ".reasonix", "skills", "gamma", "SKILL.md")
431 if fi, err := os.Lstat(target); err != nil || fi.Mode()&os.ModeSymlink == 0 {
432 t.Fatalf("target should be a symlink: lstat err=%v mode=%v", err, fi.Mode())
433 }
434 }
435
436 func TestPlanNestedSkillRootRegistersContainingRoots(t *testing.T) {
437 project := t.TempDir()
438 home := t.TempDir()
439 root := filepath.Join(t.TempDir(), "skill-pack")
440 writeFile(t, filepath.Join(root, "top.md"), "---\nname: top\ndescription: Top helper\n---\nbody")
441 writeFile(t, filepath.Join(root, "superpower", "tool-a", "SKILL.md"), "---\nname: tool-a\ndescription: Tool A\n---\nbody")
442 writeFile(t, filepath.Join(root, "superpower", "tool-b", "SKILL.md"), "---\nname: tool-b\ndescription: Tool B\n---\nbody")
443
444 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
445 resp := execInstall(t, tl, map[string]any{
446 "source": root,
447 "kind": "skill",
448 "apply": true,
449 "scope": "project",
450 })
451
452 if !resp.OK {
453 t.Fatalf("response = %+v", resp)
454 }
455 if len(resp.Actions) != 2 {
456 t.Fatalf("actions = %+v, want root and nested containing-root registrations", resp.Actions)
457 }
458 registered := map[string][]string{}
459 for _, a := range resp.Actions {
460 registered[a.Source] = a.Skills
461 if !a.Discoverable || !a.Indexed {
462 t.Fatalf("registered action should be verified: %+v", a)
463 }
464 }
465 if got := strings.Join(registered[root], ","); got != "top" {
466 t.Fatalf("root skills = %q, want top", got)
467 }
468 if got := strings.Join(registered[filepath.Join(root, "superpower")], ","); got != "tool-a,tool-b" {
469 t.Fatalf("nested skills = %q, want tool-a,tool-b", got)
470 }
471 st := skill.New(skill.Options{HomeDir: home, ProjectRoot: project, CustomPaths: registeredRoots(resp.Actions), DisableBuiltins: true})
472 for _, name := range []string{"top", "tool-a", "tool-b"} {
473 if _, ok := st.Read(name); !ok {
474 t.Fatalf("%s should be discoverable after registering containing roots", name)
475 }
476 }
477 }
478
479 func TestPlanNestedSkillRootRespectsDepthLimit(t *testing.T) {
480 project := t.TempDir()
481 home := t.TempDir()
482 root := filepath.Join(t.TempDir(), "deep-pack")
483 writeFile(t, filepath.Join(root, "one", "two", "three", "deep", "SKILL.md"), "---\nname: deep\ndescription: Deep helper\n---\nbody")
484
485 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
486 raw, _ := json.Marshal(map[string]any{
487 "source": root,
488 "kind": "skill",
489 })
490 out, err := tl.Execute(context.Background(), raw)
491 if err == nil {
492 t.Fatalf("expected no manifest within depth limit, got %s", out)
493 }
494 if !errors.Is(err, ErrManifestMissing) {
495 t.Fatalf("error = %v, want ErrManifestMissing", err)
496 }
497 }
498
499 func TestApplyLinkSkillRejectsEscape(t *testing.T) {
500 if runtime.GOOS == "windows" {
501 t.Skip("absolute path semantics differ on Windows")
502 }
503 project := t.TempDir()
504 home := t.TempDir()
505
506 // Synthesize a candidate that points at /etc/passwd by calling the
507 // private helper directly. The link check runs before any disk write.
508 if isLinkTargetSafe("/etc/passwd", home, project) {
509 t.Fatal("/etc/passwd should be considered unsafe")
510 }
511 if !isLinkTargetSafe("./local-skill.md", home, project) {
512 t.Fatal("relative link target should be safe")
513 }
514 if !isLinkTargetSafe(filepath.Join(project, "skills/x.md"), home, project) {
515 t.Fatal("link under project root should be safe")
516 }
517
518 src := filepath.Join(t.TempDir(), "escape.md")
519 writeFile(t, src, "---\nname: escape\ndescription: Escape helper\n---\nbody")
520 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
521 resp := execInstall(t, tl, map[string]any{
522 "source": src,
523 "kind": "skill",
524 "apply": true,
525 "mode": "link",
526 })
527 if resp.OK {
528 t.Fatalf("unsafe link should fail, got %+v", resp)
529 }
530 if !strings.Contains(resp.Actions[0].Error, ErrUnsafeLinkTarget.Error()) {
531 t.Errorf("error = %q, want ErrUnsafeLinkTarget", resp.Actions[0].Error)
532 }
533 }
534
535 func TestParseSkillContentStrictAllowsMissingDescription(t *testing.T) {
536 // strict=false lets us install a raw SKILL.md that lacks a description.
537 // The model should still be told the description is empty so the
538 // Skills index entry is honest.
539 cand, err := parseSkillContent("---\nname: raw\n---\nBody without desc", "raw", "in-memory", false)
540 if err != nil {
541 t.Fatalf("strict=false should accept missing description: %v", err)
542 }
543 if cand.Description != "" {
544 t.Errorf("description should be empty, got %q", cand.Description)
545 }
546 if cand.Name != "raw" {
547 t.Errorf("name = %q, want raw", cand.Name)
548 }
549
550 if _, err := parseSkillContent("---\nname: strict\n---\nbody", "strict", "in-memory", true); err == nil {
551 t.Fatal("strict=true should reject missing description")
552 }
553 }
554
555 func TestParseSkillContentRejectsMalformedFrontmatter(t *testing.T) {
556 _, err := parseSkillContent("---\nname: [broken\n---\nbody", "broken", "in-memory", true)
557 if err == nil {
558 t.Fatal("malformed frontmatter should fail")
559 }
560 if !strings.Contains(err.Error(), "invalid YAML") || !strings.Contains(strings.ToLower(err.Error()), "line") {
561 t.Fatalf("error = %v, want invalid YAML with location", err)
562 }
563 }
564
565 func TestApplyStrictFalseWarnsWhenDescriptionMissing(t *testing.T) {
566 project := t.TempDir()
567 home := t.TempDir()
568 src := filepath.Join(t.TempDir(), "raw.md")
569 writeFile(t, src, "---\nname: raw\n---\nBody without desc")
570
571 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
572 resp := execInstall(t, tl, map[string]any{
573 "source": src,
574 "kind": "skill",
575 "apply": true,
576 "strict": false,
577 })
578
579 if !resp.OK {
580 t.Fatalf("response = %+v", resp)
581 }
582 if !resp.Actions[0].Discoverable || !resp.Actions[0].Indexed {
583 t.Fatalf("raw skill should still be discoverable/indexed with placeholder: %+v", resp.Actions[0])
584 }
585 found := false
586 for _, warning := range append(resp.Warnings, resp.Actions[0].Warnings...) {
587 if strings.Contains(warning, "no description") {
588 found = true
589 break
590 }
591 }
592 if !found {
593 t.Fatalf("warnings = %v action warnings = %v, want missing description warning", resp.Warnings, resp.Actions[0].Warnings)
594 }
595 }
596
597 // --- plan / apply: MCP paths -----------------------------------------------
598
599 func TestPlanLocalMCPJSON(t *testing.T) {
600 project := t.TempDir()
601 home := t.TempDir()
602 mcpPath := filepath.Join(t.TempDir(), ".mcp.json")
603 writeFile(t, mcpPath, `{
604 "mcpServers": {
605 "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] },
606 "remote": {
607 "type": "http",
608 "url": "https://mcp.example.com/mcp",
609 "headers": { "Authorization": "Bearer ${TOKEN}" },
610 "default_tools_approval_mode": "writes",
611 "tools": { "wipe": { "approval_mode": "prompt" } },
612 "approvals_reviewer": "auto_review"
613 }
614 }
615 }`)
616
617 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
618 resp := execInstall(t, tl, map[string]any{
619 "source": mcpPath,
620 "kind": "mcp",
621 })
622
623 if !resp.OK || resp.Status != "planned" {
624 t.Fatalf("response = %+v", resp)
625 }
626 if len(resp.Actions) != 2 {
627 t.Fatalf("actions = %+v", resp.Actions)
628 }
629 if resp.Actions[0].Name != "fs" || resp.Actions[0].Transport != "stdio" {
630 t.Fatalf("first action = %+v", resp.Actions[0])
631 }
632 if resp.Actions[1].Name != "remote" || resp.Actions[1].Transport != "http" {
633 t.Fatalf("second action = %+v", resp.Actions[1])
634 }
635 for _, action := range resp.Actions {
636 if action.Scope != "global" || action.ConfigPath != config.UserConfigPath() {
637 t.Fatalf("local .mcp.json outside project action scope/path = %q %q, want global %q", action.Scope, action.ConfigPath, config.UserConfigPath())
638 }
639 }
640 if resp.Kinds.MCP != 2 {
641 t.Errorf("Kinds.MCP = %d, want 2", resp.Kinds.MCP)
642 }
643 }
644
645 func TestPlanProjectMCPJSONDefaultsProject(t *testing.T) {
646 project := t.TempDir()
647 home := t.TempDir()
648 mcpPath := filepath.Join(project, ".mcp.json")
649 writeFile(t, mcpPath, `{
650 "mcpServers": {
651 "projectfs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }
652 }
653 }`)
654
655 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
656 resp := execInstall(t, tl, map[string]any{
657 "source": mcpPath,
658 "kind": "mcp",
659 })
660
661 if !resp.OK || len(resp.Actions) != 1 {
662 t.Fatalf("response = %+v", resp)
663 }
664 wantPath := filepath.Join(project, "reasonix.toml")
665 if resp.Scope != "project" || resp.Actions[0].Scope != "project" || resp.Actions[0].ConfigPath != wantPath {
666 t.Fatalf("project .mcp.json scope/path = response %q action %q path %q, want project %q", resp.Scope, resp.Actions[0].Scope, resp.Actions[0].ConfigPath, wantPath)
667 }
668 }
669
670 func TestPlanMCPJSONUnknownTierProducesWarning(t *testing.T) {
671 entries, warnings, err := parseMCPJSON([]byte(`{
672 "mcpServers": {
673 "x": { "command": "node", "tier": "absurd" }
674 }
675 }`))
676 if err != nil {
677 t.Fatalf("parseMCPJSON: %v", err)
678 }
679 if len(entries) != 1 || entries[0].Tier != "background" {
680 t.Fatalf("entries = %+v", entries)
681 }
682 found := false
683 for _, w := range warnings {
684 if strings.Contains(w, "absurd") {
685 found = true
686 }
687 }
688 if !found {
689 t.Errorf("expected warning about unknown tier, got %v", warnings)
690 }
691 }
692
693 func TestPlanMCPJSONDefaultTierIsBackground(t *testing.T) {
694 entries, warnings, err := parseMCPJSON([]byte(`{
695 "mcpServers": {
696 "x": { "command": "node" }
697 }
698 }`))
699 if err != nil {
700 t.Fatalf("parseMCPJSON: %v", err)
701 }
702 if len(warnings) != 0 {
703 t.Fatalf("warnings = %v, want none", warnings)
704 }
705 if len(entries) != 1 || entries[0].Tier != "background" {
706 t.Fatalf("entries = %+v, want default tier background", entries)
707 }
708 }
709
710 func TestPlanMCPJSONIgnoresRetiredApprovalPolicy(t *testing.T) {
711 entries, warnings, err := parseMCPJSON([]byte(`{
712 "mcpServers": {
713 "admin": {
714 "command": "admin-mcp",
715 "startup_timeout_seconds": 30,
716 "call_timeout_seconds": 45,
717 "tool_timeout_seconds": {"wipe": 120},
718 "trusted_read_only_tools": ["status"],
719 "default_tools_approval_mode": "writes",
720 "tools": {"wipe": {"approval_mode": "prompt"}, "external": {"enabled": false}},
721 "approvals_reviewer": "auto_review"
722 }
723 }
724 }`))
725 if err != nil || len(warnings) != 0 || len(entries) != 1 {
726 t.Fatalf("parseMCPJSON: entries=%+v warnings=%v err=%v", entries, warnings, err)
727 }
728 got := entries[0]
729 if got.StartupTimeoutSeconds != 30 || got.CallTimeoutSeconds != 45 || got.ToolTimeoutSeconds["wipe"] != 120 {
730 t.Fatalf("MCP timeout config was dropped: %+v", got)
731 }
732 }
733
734 func TestNormalizeTierDefaultBackgroundUnknownBackground(t *testing.T) {
735 if got, ok := normalizeTier(""); got != "background" || !ok {
736 t.Fatalf("normalizeTier(empty) = %q, %v; want background, true", got, ok)
737 }
738 if got, ok := normalizeTier("absurd"); got != "background" || ok {
739 t.Fatalf("normalizeTier(absurd) = %q, %v; want background, false", got, ok)
740 }
741 }
742
743 func TestPlanMCPJSONSplitsPastedCommandLine(t *testing.T) {
744 project := t.TempDir()
745 home := t.TempDir()
746 mcpPath := filepath.Join(t.TempDir(), ".mcp.json")
747 writeFile(t, mcpPath, `{
748 "mcpServers": {
749 "playwright": { "command": "npx -y @playwright/mcp" }
750 }
751 }`)
752
753 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
754 resp := execInstall(t, tl, map[string]any{
755 "source": mcpPath,
756 "kind": "mcp",
757 })
758
759 if !resp.OK || len(resp.Actions) != 1 {
760 t.Fatalf("response = %+v", resp)
761 }
762 a := resp.Actions[0]
763 if a.Command != "npx" || len(a.Args) != 2 || a.Args[0] != "-y" || a.Args[1] != "@playwright/mcp" {
764 t.Fatalf("action command/args = %q %v, want npx [-y @playwright/mcp]", a.Command, a.Args)
765 }
766 found := false
767 for _, warning := range resp.Warnings {
768 if strings.Contains(warning, "split a pasted MCP command line") {
769 found = true
770 }
771 }
772 if !found {
773 t.Fatalf("warnings = %v, want split warning", resp.Warnings)
774 }
775 }
776
777 func TestPlanMCPJSONRejectsInvalid(t *testing.T) {
778 cases := []struct {
779 name string
780 body string
781 }{
782 {"empty", `{"mcpServers": {}}`},
783 {"stdio no command", `{"mcpServers": {"x": {"type": "stdio"}}}`},
784 {"http no url", `{"mcpServers": {"x": {"type": "http"}}}`},
785 {"unknown transport", `{"mcpServers": {"x": {"type": "smoke", "command": "c"}}}`},
786 {"invalid name", `{"mcpServers": {"bad/name": {"command": "c"}}}`},
787 }
788 for _, tc := range cases {
789 t.Run(tc.name, func(t *testing.T) {
790 if _, _, err := parseMCPJSON([]byte(tc.body)); err == nil {
791 t.Fatalf("expected error for %s", tc.name)
792 }
793 })
794 }
795 }
796
797 func TestApplyRemoteMCPURLConnectsAndPersists(t *testing.T) {
798 project := t.TempDir()
799 home := t.TempDir()
800 stub := &stubConnector{toolCount: 3}
801 tl := NewTool(Options{
802 ProjectRoot: project,
803 HomeDir: home,
804 ConnectMCP: stub.connector(),
805 })
806
807 resp := execInstall(t, tl, map[string]any{
808 "source": "https://mcp.example.com/mcp",
809 "kind": "mcp",
810 "apply": true,
811 "scope": "project",
812 "name": "example",
813 "headers": map[string]string{"Authorization": "Bearer ${TOKEN}"},
814 })
815
816 if !resp.OK || resp.Status != "done" || resp.Actions[0].ToolCount != 3 {
817 t.Fatalf("response = %+v", resp)
818 }
819 if len(stub.connected) != 1 || stub.connected[0].Name != "example" {
820 t.Fatalf("connected = %+v", stub.connected)
821 }
822 if stub.connected[0].Source != config.MCPSourceProjectConfig {
823 t.Fatalf("project install live source = %q, want %q", stub.connected[0].Source, config.MCPSourceProjectConfig)
824 }
825 if resp.Actions[0].RiskLevel != RiskHigh {
826 t.Errorf("auth headers should produce RiskHigh, got %q", resp.Actions[0].RiskLevel)
827 }
828 cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
829 if len(cfg.Plugins) != 1 || cfg.Plugins[0].Headers["Authorization"] != "Bearer ${TOKEN}" {
830 t.Fatalf("plugins = %+v", cfg.Plugins)
831 }
832 }
833
834 func TestApplyRemoteMCPURLDefaultsGlobal(t *testing.T) {
835 project := t.TempDir()
836 home := t.TempDir()
837 stub := &stubConnector{toolCount: 1}
838 tl := NewTool(Options{
839 ProjectRoot: project,
840 HomeDir: home,
841 ConnectMCP: stub.connector(),
842 })
843
844 resp := execInstall(t, tl, map[string]any{
845 "source": "https://global.example.com/mcp",
846 "kind": "mcp",
847 "apply": true,
848 "name": "global-default",
849 })
850
851 if !resp.OK || resp.Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() {
852 t.Fatalf("response = %+v, want global user config %q", resp, config.UserConfigPath())
853 }
854 userCfg := config.LoadForEdit(config.UserConfigPath())
855 if p, ok := findPlugin(userCfg.Plugins, "global-default"); !ok || p.URL != "https://global.example.com/mcp" {
856 t.Fatalf("global config plugins = %+v, want global-default", userCfg.Plugins)
857 }
858 if len(stub.connected) != 1 || stub.connected[0].Source != config.MCPSourceUserConfig {
859 t.Fatalf("global install live source = %+v, want source %q", stub.connected, config.MCPSourceUserConfig)
860 }
861 projectCfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
862 if _, ok := findPlugin(projectCfg.Plugins, "global-default"); ok {
863 t.Fatalf("project config should not receive default-global MCP: %+v", projectCfg.Plugins)
864 }
865 }
866
867 func TestApplyMCPRejectsDuplicateByDefault(t *testing.T) {
868 project := t.TempDir()
869 home := t.TempDir()
870 // Seed an existing entry the same way the first install would have.
871 cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
872 if err := cfg.UpsertPlugin(config.PluginEntry{Name: "dup", Command: "x"}); err != nil {
873 t.Fatal(err)
874 }
875 if err := cfg.SaveTo(filepath.Join(project, "reasonix.toml")); err != nil {
876 t.Fatal(err)
877 }
878
879 stub := &stubConnector{toolCount: 1, failOnName: "dup"}
880 tl := NewTool(Options{ProjectRoot: project, HomeDir: home, ConnectMCP: stub.connector()})
881
882 resp := execInstall(t, tl, map[string]any{
883 "source": "https://mcp.example.com/mcp",
884 "kind": "mcp",
885 "apply": true,
886 "name": "dup",
887 "scope": "project",
888 })
889
890 if resp.OK {
891 t.Fatalf("expected duplicate rejection, got %+v", resp)
892 }
893 if !strings.Contains(resp.Actions[0].Error, "already exists") {
894 t.Errorf("expected ErrAlreadyExists text, got %q", resp.Actions[0].Error)
895 }
896 }
897
898 func TestApplyMCPReplaceOverwritesExisting(t *testing.T) {
899 project := t.TempDir()
900 home := t.TempDir()
901 cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
902 if err := cfg.UpsertPlugin(config.PluginEntry{Name: "editable", Command: "old"}); err != nil {
903 t.Fatal(err)
904 }
905 if err := cfg.SaveTo(filepath.Join(project, "reasonix.toml")); err != nil {
906 t.Fatal(err)
907 }
908
909 stub := &stubConnector{toolCount: 1}
910 tl := NewTool(Options{ProjectRoot: project, HomeDir: home, ConnectMCP: stub.connector()})
911
912 resp := execInstall(t, tl, map[string]any{
913 "source": "https://mcp.example.com/mcp",
914 "kind": "mcp",
915 "apply": true,
916 "replace": true,
917 "name": "editable",
918 "scope": "project",
919 })
920
921 if !resp.OK {
922 t.Fatalf("response = %+v", resp)
923 }
924 reloaded := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
925 if reloaded.Plugins[0].Command != "" || reloaded.Plugins[0].URL != "https://mcp.example.com/mcp" {
926 t.Errorf("replace did not update entry: %+v", reloaded.Plugins[0])
927 }
928 }
929
930 func TestApplyMCPReplaceDisconnectsLiveServerBeforeConnect(t *testing.T) {
931 project := t.TempDir()
932 home := t.TempDir()
933 cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
934 if err := cfg.UpsertPlugin(config.PluginEntry{Name: "live", Command: "old"}); err != nil {
935 t.Fatal(err)
936 }
937 if err := cfg.SaveTo(filepath.Join(project, "reasonix.toml")); err != nil {
938 t.Fatal(err)
939 }
940
941 var liveConnected atomic.Bool
942 liveConnected.Store(true)
943 var disconnects atomic.Int32
944 var connects atomic.Int32
945 tl := NewTool(Options{
946 ProjectRoot: project,
947 HomeDir: home,
948 ConnectMCP: func(e config.PluginEntry) (MCPConnectResult, error) {
949 if e.Name == "live" && liveConnected.Load() {
950 return MCPConnectResult{}, errors.New(`server "live" is already connected`)
951 }
952 liveConnected.Store(true)
953 connects.Add(1)
954 return MCPConnectResult{ToolCount: 1, Disconnect: func() { liveConnected.Store(false) }}, nil
955 },
956 OnDisconnect: func(name string) bool {
957 if name != "live" || !liveConnected.Load() {
958 return false
959 }
960 liveConnected.Store(false)
961 disconnects.Add(1)
962 return true
963 },
964 })
965
966 resp := execInstall(t, tl, map[string]any{
967 "source": "https://mcp.example.com/mcp",
968 "kind": "mcp",
969 "apply": true,
970 "replace": true,
971 "name": "live",
972 "scope": "project",
973 })
974
975 if !resp.OK {
976 t.Fatalf("replace should reconnect after disconnecting old live server: %+v", resp)
977 }
978 if disconnects.Load() != 1 || connects.Load() != 1 || !liveConnected.Load() {
979 t.Fatalf("disconnects=%d connects=%d live=%v", disconnects.Load(), connects.Load(), liveConnected.Load())
980 }
981 }
982
983 func TestApplyMCPRollsBackOnSaveFailure(t *testing.T) {
984 project := t.TempDir()
985 home := t.TempDir()
986 configPath := filepath.Join(project, "reasonix.toml")
987 if err := os.WriteFile(configPath, []byte("# valid before connect\n"), 0o644); err != nil {
988 t.Fatal(err)
989 }
990
991 var disconnects atomic.Int32
992 stub := &stubConnector{toolCount: 2, disconnectCalls: &disconnects}
993 tl := NewTool(Options{
994 ProjectRoot: project,
995 HomeDir: home,
996 ConnectMCP: func(entry config.PluginEntry) (MCPConnectResult, error) {
997 result, err := stub.connector()(entry)
998 if err != nil {
999 return result, err
1000 }
1001 // Simulate an external destructive change after the live connection
1002 // succeeds but before the strict commit-time reload.
1003 if err := os.Remove(configPath); err != nil {
1004 t.Fatal(err)
1005 }
1006 if err := os.Mkdir(configPath, 0o755); err != nil {
1007 t.Fatal(err)
1008 }
1009 writeFile(t, filepath.Join(configPath, "blocker"), "x")
1010 return result, nil
1011 },
1012 OnDisconnect: func(string) bool {
1013 disconnects.Add(1)
1014 return true
1015 },
1016 })
1017
1018 resp := execInstall(t, tl, map[string]any{
1019 "source": "https://mcp.example.com/mcp",
1020 "kind": "mcp",
1021 "apply": true,
1022 "name": "ghost",
1023 "scope": "project",
1024 })
1025
1026 if resp.OK {
1027 t.Fatalf("expected failure, got %+v", resp)
1028 }
1029 if got := disconnects.Load(); got != 1 {
1030 t.Errorf("rollback expected to call the new connection Disconnect once, got %d", got)
1031 }
1032 }
1033
1034 func TestApplyConnectFailureDoesNotPersist(t *testing.T) {
1035 project := t.TempDir()
1036 home := t.TempDir()
1037 stub := &stubConnector{failOnName: "broken"}
1038 tl := NewTool(Options{ProjectRoot: project, HomeDir: home, ConnectMCP: stub.connector()})
1039
1040 resp := execInstall(t, tl, map[string]any{
1041 "source": "https://mcp.example.com/mcp",
1042 "kind": "mcp",
1043 "apply": true,
1044 "name": "broken",
1045 "scope": "project",
1046 })
1047
1048 if resp.OK {
1049 t.Fatalf("expected connect failure, got %+v", resp)
1050 }
1051 cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
1052 if len(cfg.Plugins) != 0 {
1053 t.Errorf("no plugin should be persisted on connect failure, got %+v", cfg.Plugins)
1054 }
1055 }
1056
1057 func TestPackageActionUsesNpx(t *testing.T) {
1058 project := t.TempDir()
1059 home := t.TempDir()
1060 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1061 resp := execInstall(t, tl, map[string]any{
1062 "source": "@example/mcp-pkg",
1063 "kind": "mcp",
1064 })
1065
1066 if !resp.OK || len(resp.Actions) != 1 {
1067 t.Fatalf("response = %+v", resp)
1068 }
1069 if resp.Actions[0].Command != "npx" {
1070 t.Errorf("command = %q, want npx", resp.Actions[0].Command)
1071 }
1072 if len(resp.Actions[0].Args) != 2 || resp.Actions[0].Args[0] != "-y" || resp.Actions[0].Args[1] != "@example/mcp-pkg" {
1073 t.Errorf("args = %v, want [-y @example/mcp-pkg]", resp.Actions[0].Args)
1074 }
1075 if resp.Actions[0].Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() {
1076 t.Errorf("scope/path = %q %q, want global %q", resp.Actions[0].Scope, resp.Actions[0].ConfigPath, config.UserConfigPath())
1077 }
1078 }
1079
1080 func TestPlanURLBlobRewritesToRaw(t *testing.T) {
1081 got := rawGitHubBlobURL("https://github.com/foo/bar/blob/main/path/SKILL.md")
1082 want := "https://raw.githubusercontent.com/foo/bar/main/path/SKILL.md"
1083 if got != want {
1084 t.Errorf("rawGitHubBlobURL = %q, want %q", got, want)
1085 }
1086 }
1087
1088 func TestPlanURLRemoteEndpointAuto(t *testing.T) {
1089 project := t.TempDir()
1090 home := t.TempDir()
1091 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1092 resp := execInstall(t, tl, map[string]any{
1093 "source": "https://mcp.example.com/mcp",
1094 })
1095 if !resp.OK || len(resp.Actions) != 1 {
1096 t.Fatalf("response = %+v", resp)
1097 }
1098 if resp.Actions[0].Transport != "http" {
1099 t.Errorf("transport = %q, want http", resp.Actions[0].Transport)
1100 }
1101 if resp.Actions[0].Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() {
1102 t.Errorf("scope/path = %q %q, want global %q", resp.Actions[0].Scope, resp.Actions[0].ConfigPath, config.UserConfigPath())
1103 }
1104 }
1105
1106 func TestPlanURLRemoteMCPHostAuto(t *testing.T) {
1107 project := t.TempDir()
1108 home := t.TempDir()
1109 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1110 resp := execInstall(t, tl, map[string]any{
1111 "source": "https://mcp.stripe.com",
1112 })
1113 if !resp.OK || len(resp.Actions) != 1 {
1114 t.Fatalf("response = %+v", resp)
1115 }
1116 if resp.Actions[0].Name != "stripe" || resp.Actions[0].Transport != "http" {
1117 t.Errorf("action = %+v, want stripe http", resp.Actions[0])
1118 }
1119 }
1120
1121 func TestPlanURLSSEDefault(t *testing.T) {
1122 project := t.TempDir()
1123 home := t.TempDir()
1124 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1125 resp := execInstall(t, tl, map[string]any{
1126 "source": "https://example.com/sse/stream",
1127 })
1128 if !resp.OK {
1129 t.Fatalf("response = %+v", resp)
1130 }
1131 if resp.Actions[0].Transport != "sse" {
1132 t.Errorf("transport = %q, want sse (URL contains 'sse')", resp.Actions[0].Transport)
1133 }
1134 }
1135
1136 func TestPlanUnsupportedKindReturnsTypedError(t *testing.T) {
1137 project := t.TempDir()
1138 home := t.TempDir()
1139 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1140 raw, _ := json.Marshal(map[string]any{
1141 "source": "https://example.com/sse/stream",
1142 "kind": "skill",
1143 })
1144 out, err := tl.Execute(context.Background(), raw)
1145 if err == nil {
1146 t.Fatalf("expected typed error, got %s", out)
1147 }
1148 if !errors.Is(err, ErrUnsupportedKind) {
1149 t.Errorf("expected ErrUnsupportedKind, got %v", err)
1150 }
1151 }
1152
1153 func TestPlanGitHubRepoProbesMainAndMaster(t *testing.T) {
1154 // We can't easily stand up a fake github.com, so we exercise the URL
1155 // rewriting and probe selection separately. The hostname check is
1156 // `strings.EqualFold(u.Hostname(), "github.com")` — anything else is
1157 // treated as a remote URL, not a repo probe.
1158 if got := rawGitHubBlobURL("https://github.com/foo/bar/blob/main/path/SKILL.md"); got != "https://raw.githubusercontent.com/foo/bar/main/path/SKILL.md" {
1159 t.Errorf("blob rewrite = %q", got)
1160 }
1161 if got := rawGitHubBlobURL("https://github.com/foo/bar/raw/main/path/SKILL.md"); got != "https://raw.githubusercontent.com/foo/bar/main/path/SKILL.md" {
1162 t.Errorf("raw rewrite = %q", got)
1163 }
1164 if got := rawGitHubBlobURL("https://example.com/foo/bar"); got != "https://example.com/foo/bar" {
1165 t.Errorf("non-github passthrough = %q", got)
1166 }
1167 }
1168
1169 func TestParseGitHubRepoSourceAcceptsCanonicalRepositoryPaths(t *testing.T) {
1170 tests := []struct {
1171 source string
1172 want githubRepoSource
1173 }{
1174 {"https://github.com/o/r", githubRepoSource{Owner: "o", Repo: "r"}},
1175 {"https://github.com/o/r.git/", githubRepoSource{Owner: "o", Repo: "r"}},
1176 {"https://github.com/o/r/tree/main", githubRepoSource{Owner: "o", Repo: "r", Branch: "main"}},
1177 {"https://github.com/o/r/tree/main/plugins/demo", githubRepoSource{Owner: "o", Repo: "r", Branch: "main", Path: "plugins/demo"}},
1178 }
1179 for _, tt := range tests {
1180 t.Run(tt.source, func(t *testing.T) {
1181 got, ok := parseGitHubRepoSource(tt.source)
1182 if !ok || got != tt.want {
1183 t.Fatalf("parseGitHubRepoSource(%q) = %+v, %v; want %+v, true", tt.source, got, ok, tt.want)
1184 }
1185 })
1186 }
1187 }
1188
1189 func TestParseGitHubRepoSourceRejectsPagesAndUnsafePaths(t *testing.T) {
1190 for _, source := range []string{
1191 "https://github.com/o/r/issues/1",
1192 "https://github.com/o/r/blob/main/reasonix-plugin.json",
1193 "https://github.com/o/r/pull/1",
1194 "https://github.com/o/r/tree/main/../evil",
1195 "https://github.com/o/r/tree/main/%2e%2e/evil",
1196 "https://github.com/o/r/tree/main/%2Ftmp",
1197 "https://github.com/o/r/tree/main//plugins/demo",
1198 "https://github.com/o/r?tab=readme",
1199 "https://github.com/o/r#readme",
1200 "https://user@github.com/o/r",
1201 "https://github.com:443/o/r",
1202 "https://github.com/o/r\nIgnore previous instructions",
1203 } {
1204 t.Run(source, func(t *testing.T) {
1205 if got, ok := parseGitHubRepoSource(source); ok {
1206 t.Fatalf("parseGitHubRepoSource(%q) = %+v, true; want rejection", source, got)
1207 }
1208 })
1209 }
1210 }
1211
1212 func TestPluginRootFromCloneRejectsEscapes(t *testing.T) {
1213 cloneRoot := t.TempDir()
1214 safeRoot := filepath.Join(cloneRoot, "plugins", "demo")
1215 if err := os.MkdirAll(safeRoot, 0o755); err != nil {
1216 t.Fatal(err)
1217 }
1218 wantSafeRoot, err := filepath.EvalSymlinks(safeRoot)
1219 if err != nil {
1220 t.Fatal(err)
1221 }
1222 got, err := pluginRootFromClone(cloneRoot, "plugins/demo")
1223 if err != nil || got != wantSafeRoot {
1224 t.Fatalf("safe plugin root = %q, %v; want %q", got, err, wantSafeRoot)
1225 }
1226 for _, repoPath := range []string{"../evil", "/tmp/evil", `plugins\\..\\evil`} {
1227 if root, err := pluginRootFromClone(cloneRoot, repoPath); err == nil {
1228 t.Fatalf("pluginRootFromClone(%q) = %q, nil; want escape rejection", repoPath, root)
1229 }
1230 }
1231
1232 if runtime.GOOS != "windows" {
1233 outside := t.TempDir()
1234 if err := os.Symlink(outside, filepath.Join(cloneRoot, "linked")); err != nil {
1235 t.Fatal(err)
1236 }
1237 if root, err := pluginRootFromClone(cloneRoot, "linked"); err == nil {
1238 t.Fatalf("pluginRootFromClone(symlink escape) = %q, nil; want rejection", root)
1239 }
1240 }
1241 }
1242
1243 func TestPlanGitHubRepoDiscoversMultipleSkills(t *testing.T) {
1244 project := t.TempDir()
1245 home := t.TempDir()
1246
1247 var srv *httptest.Server
1248 srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1249 switch r.URL.Path {
1250 case "/repos/foo/bar/contents":
1251 if r.URL.Query().Get("ref") != "main" {
1252 t.Fatalf("ref = %q, want main", r.URL.Query().Get("ref"))
1253 }
1254 _, _ = fmt.Fprintf(w, `[
1255 {"name":"skills","path":"skills","type":"dir"}
1256 ]`)
1257 case "/repos/foo/bar/contents/skills":
1258 _, _ = fmt.Fprintf(w, `[
1259 {"name":"gsap-core","path":"skills/gsap-core","type":"dir"},
1260 {"name":"gsap-timeline","path":"skills/gsap-timeline","type":"dir"}
1261 ]`)
1262 case "/repos/foo/bar/contents/skills/gsap-core":
1263 _, _ = fmt.Fprintf(w, `[
1264 {"name":"SKILL.md","path":"skills/gsap-core/SKILL.md","type":"file","download_url":%q}
1265 ]`, srv.URL+"/raw/gsap-core/SKILL.md")
1266 case "/repos/foo/bar/contents/skills/gsap-timeline":
1267 _, _ = fmt.Fprintf(w, `[
1268 {"name":"SKILL.md","path":"skills/gsap-timeline/SKILL.md","type":"file","download_url":%q}
1269 ]`, srv.URL+"/raw/gsap-timeline/SKILL.md")
1270 case "/raw/gsap-core/SKILL.md":
1271 _, _ = w.Write([]byte("---\nname: gsap-core\ndescription: GSAP core helper\n---\ncore body"))
1272 case "/raw/gsap-timeline/SKILL.md":
1273 _, _ = w.Write([]byte("---\nname: gsap-timeline\ndescription: GSAP timeline helper\n---\ntimeline body"))
1274 default:
1275 http.NotFound(w, r)
1276 }
1277 }))
1278 defer srv.Close()
1279 oldAPIBase := githubAPIBaseURL
1280 githubAPIBaseURL = srv.URL
1281 defer func() { githubAPIBaseURL = oldAPIBase }()
1282
1283 tl := NewTool(Options{ProjectRoot: project, HomeDir: home, HTTPClient: srv.Client()})
1284 resp := execInstall(t, tl, map[string]any{
1285 "source": "https://github.com/foo/bar",
1286 "kind": "skill",
1287 })
1288
1289 if !resp.OK || resp.Status != "planned" {
1290 t.Fatalf("response = %+v", resp)
1291 }
1292 if len(resp.Actions) != 2 {
1293 t.Fatalf("actions = %+v, want two skills", resp.Actions)
1294 }
1295 if resp.Actions[0].Name != "gsap-core" || resp.Actions[1].Name != "gsap-timeline" {
1296 t.Fatalf("actions = %+v", resp.Actions)
1297 }
1298 for _, action := range resp.Actions {
1299 wantSuffix := filepath.Join(action.Name, skill.SkillFile)
1300 if action.Layout != "canonical_dir" || !strings.HasSuffix(action.CanonicalPath, wantSuffix) {
1301 t.Fatalf("action = %+v, want canonical layout ending in %s", action, wantSuffix)
1302 }
1303 }
1304 }
1305
1306 func TestFetchTextAppliesTimeoutAndUA(t *testing.T) {
1307 // Use a context with a tiny deadline to assert timeout behavior. We
1308 // can't easily test the UA from inside a HandlerFunc, so we just check
1309 // that a cancelled context propagates as ErrSourceUnreadable.
1310 project := t.TempDir()
1311 home := t.TempDir()
1312 tl := NewTool(Options{ProjectRoot: project, HomeDir: home}).(*installSourceTool)
1313 ctx, cancel := context.WithCancel(context.Background())
1314 cancel()
1315 _, err := tl.fetchText(ctx, "http://example.invalid")
1316 if !errors.Is(err, ErrSourceUnreadable) {
1317 t.Errorf("expected ErrSourceUnreadable, got %v", err)
1318 }
1319 }
1320
1321 func TestGlobalSkillInstallRootUsesReasonixHome(t *testing.T) {
1322 home := t.TempDir()
1323 reasonixHome := filepath.Join(t.TempDir(), "rx-home")
1324 t.Setenv("HOME", home)
1325 t.Setenv("USERPROFILE", home)
1326 t.Setenv("REASONIX_HOME", reasonixHome)
1327 oldUserHomeDir := userHomeDir
1328 userHomeDir = func() (string, error) { return home, nil }
1329 t.Cleanup(func() { userHomeDir = oldUserHomeDir })
1330
1331 tl := NewTool(Options{ProjectRoot: t.TempDir()}).(*installSourceTool)
1332 root, err := tl.skillInstallRoot("global")
1333 if err != nil {
1334 t.Fatalf("skillInstallRoot: %v", err)
1335 }
1336 want := filepath.Join(reasonixHome, skill.SkillsDirname)
1337 if root != want {
1338 t.Fatalf("global skill root = %q, want %q", root, want)
1339 }
1340 }
1341
1342 func TestFetchTextAuthMapsToErrAuthRequired(t *testing.T) {
1343 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1344 w.WriteHeader(http.StatusUnauthorized)
1345 }))
1346 defer srv.Close()
1347 project := t.TempDir()
1348 home := t.TempDir()
1349 tl := NewTool(Options{ProjectRoot: project, HomeDir: home, HTTPClient: srv.Client()}).(*installSourceTool)
1350 _, err := tl.fetchText(context.Background(), srv.URL)
1351 if !errors.Is(err, ErrAuthRequired) {
1352 t.Errorf("expected ErrAuthRequired, got %v", err)
1353 }
1354 }
1355
1356 func TestFetchTextRefusesInternalAddress(t *testing.T) {
1357 // SSRF guard: an install source pointed at cloud-metadata / internal IPs must
1358 // be refused at dial time, not fetched. These are IP literals so no real
1359 // network or DNS is involved — the guard blocks before connecting.
1360 tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: t.TempDir()}).(*installSourceTool)
1361 for _, target := range []string{
1362 "http://169.254.169.254/latest/meta-data/", // cloud metadata
1363 "http://10.0.0.1/", // RFC1918 internal
1364 } {
1365 if _, err := tl.fetchText(context.Background(), target); !errors.Is(err, ErrSourceUnreadable) {
1366 t.Errorf("fetchText(%q) err = %v, want ErrSourceUnreadable (SSRF-refused)", target, err)
1367 }
1368 }
1369 }
1370
1371 func TestPlanMarkdownSkillURL(t *testing.T) {
1372 project := t.TempDir()
1373 home := t.TempDir()
1374 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1375 _, _ = w.Write([]byte("---\nname: remote-skill\ndescription: Remote helper\n---\nUse the remote helper."))
1376 }))
1377 defer srv.Close()
1378
1379 tl := NewTool(Options{ProjectRoot: project, HomeDir: home, HTTPClient: srv.Client()})
1380 resp := execInstall(t, tl, map[string]any{
1381 "source": srv.URL + "/SKILL.md",
1382 })
1383
1384 if !resp.OK || resp.Status != "planned" {
1385 t.Fatalf("response = %+v", resp)
1386 }
1387 if len(resp.Actions) != 1 || resp.Actions[0].Kind != "skill" || resp.Actions[0].Name != "remote-skill" {
1388 t.Fatalf("actions = %+v", resp.Actions)
1389 }
1390 }
1391
1392 // --- uninstall --------------------------------------------------------------
1393
1394 func TestUninstallRemovesSkillByName(t *testing.T) {
1395 project := t.TempDir()
1396 home := t.TempDir()
1397 target := filepath.Join(project, ".reasonix", "skills", "doomed.md")
1398 writeFile(t, target, "---\nname: doomed\ndescription: Doomed\n---\nbody")
1399
1400 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1401 resp := execInstall(t, tl, map[string]any{
1402 "op": "uninstall",
1403 "name": "doomed",
1404 "scope": "project",
1405 })
1406
1407 if !resp.OK || resp.Status != "done" {
1408 t.Fatalf("response = %+v", resp)
1409 }
1410 if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) {
1411 t.Errorf("skill file should be gone, lstat err = %v", err)
1412 }
1413 }
1414
1415 func TestUninstallRemovesRegisteredSkillRootByContainedSkillName(t *testing.T) {
1416 project := t.TempDir()
1417 home := t.TempDir()
1418 root := filepath.Join(t.TempDir(), "shared-skills")
1419 writeFile(t, filepath.Join(root, "alpha.md"), "---\nname: alpha\ndescription: Alpha helper\n---\nbody")
1420 writeFile(t, filepath.Join(root, "beta.md"), "---\nname: beta\ndescription: Beta helper\n---\nbody")
1421
1422 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1423 install := execInstall(t, tl, map[string]any{
1424 "source": root,
1425 "kind": "skill",
1426 "apply": true,
1427 "scope": "project",
1428 })
1429 if !install.OK {
1430 t.Fatalf("install response = %+v", install)
1431 }
1432
1433 resp := execInstall(t, tl, map[string]any{
1434 "op": "uninstall",
1435 "name": "alpha",
1436 "scope": "project",
1437 })
1438 if !resp.OK || resp.Actions[0].Action != "remove_skill_root" {
1439 t.Fatalf("uninstall response = %+v", resp)
1440 }
1441 if resp.Actions[0].SkillCount != 2 {
1442 t.Errorf("SkillCount = %d, want 2", resp.Actions[0].SkillCount)
1443 }
1444 cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
1445 if len(cfg.Skills.Paths) != 0 {
1446 t.Fatalf("skills.paths should be empty after root uninstall, got %v", cfg.Skills.Paths)
1447 }
1448 }
1449
1450 func TestUninstallRemovesMCPAndDisconnects(t *testing.T) {
1451 project := t.TempDir()
1452 home := t.TempDir()
1453 cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
1454 if err := cfg.UpsertPlugin(config.PluginEntry{Name: "ed", Type: "http", URL: "https://mcp.example.com/mcp"}); err != nil {
1455 t.Fatal(err)
1456 }
1457 if err := cfg.SaveTo(filepath.Join(project, "reasonix.toml")); err != nil {
1458 t.Fatal(err)
1459 }
1460
1461 var disconnects atomic.Int32
1462 tl := NewTool(Options{
1463 ProjectRoot: project,
1464 HomeDir: home,
1465 OnDisconnect: func(string) bool {
1466 disconnects.Add(1)
1467 return true
1468 },
1469 })
1470 resp := execInstall(t, tl, map[string]any{
1471 "op": "uninstall",
1472 "name": "ed",
1473 "scope": "project",
1474 })
1475
1476 if !resp.OK {
1477 t.Fatalf("response = %+v", resp)
1478 }
1479 if disconnects.Load() != 1 {
1480 t.Errorf("OnDisconnect should fire once, got %d", disconnects.Load())
1481 }
1482 reloaded := config.LoadForEdit(filepath.Join(project, "reasonix.toml"))
1483 if len(reloaded.Plugins) != 0 {
1484 t.Errorf("plugin should be removed, got %+v", reloaded.Plugins)
1485 }
1486 }
1487
1488 func TestUninstallWithoutScopePrefersProjectSkill(t *testing.T) {
1489 project := t.TempDir()
1490 home := t.TempDir()
1491 projectTarget := filepath.Join(project, ".reasonix", "skills", "dupe.md")
1492 globalTarget := filepath.Join(home, ".reasonix", "skills", "dupe.md")
1493 writeFile(t, projectTarget, "---\nname: dupe\ndescription: Project\n---\nbody")
1494 writeFile(t, globalTarget, "---\nname: dupe\ndescription: Global\n---\nbody")
1495
1496 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1497 resp := execInstall(t, tl, map[string]any{
1498 "op": "uninstall",
1499 "name": "dupe",
1500 })
1501
1502 if !resp.OK || resp.Scope != "project" {
1503 t.Fatalf("response = %+v, want project uninstall", resp)
1504 }
1505 if _, err := os.Lstat(projectTarget); !errors.Is(err, os.ErrNotExist) {
1506 t.Errorf("project skill should be gone, lstat err = %v", err)
1507 }
1508 if _, err := os.Lstat(globalTarget); err != nil {
1509 t.Errorf("global skill should remain when project matched first, lstat err = %v", err)
1510 }
1511 }
1512
1513 func TestUninstallWithoutScopeFallsBackToGlobalMCP(t *testing.T) {
1514 project := t.TempDir()
1515 home := t.TempDir()
1516 name := "global-fallback"
1517 cfg := config.LoadForEdit(config.UserConfigPath())
1518 if err := cfg.UpsertPlugin(config.PluginEntry{Name: name, Type: "http", URL: "https://global.example.com/mcp"}); err != nil {
1519 t.Fatal(err)
1520 }
1521 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1522 t.Fatal(err)
1523 }
1524 t.Cleanup(func() {
1525 cleanup := config.LoadForEdit(config.UserConfigPath())
1526 if cleanup.RemovePlugin(name) {
1527 _ = cleanup.SaveTo(config.UserConfigPath())
1528 }
1529 })
1530
1531 var disconnects atomic.Int32
1532 tl := NewTool(Options{
1533 ProjectRoot: project,
1534 HomeDir: home,
1535 OnDisconnect: func(string) bool {
1536 disconnects.Add(1)
1537 return true
1538 },
1539 })
1540 resp := execInstall(t, tl, map[string]any{
1541 "op": "uninstall",
1542 "name": name,
1543 })
1544
1545 if !resp.OK || resp.Scope != "global" {
1546 t.Fatalf("response = %+v, want global fallback uninstall", resp)
1547 }
1548 if disconnects.Load() != 1 {
1549 t.Errorf("OnDisconnect should fire once, got %d", disconnects.Load())
1550 }
1551 reloaded := config.LoadForEdit(config.UserConfigPath())
1552 if _, ok := findPlugin(reloaded.Plugins, name); ok {
1553 t.Fatalf("global MCP should be removed, got %+v", reloaded.Plugins)
1554 }
1555 }
1556
1557 func TestUninstallUnknownNameIsBlocked(t *testing.T) {
1558 project := t.TempDir()
1559 home := t.TempDir()
1560 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1561 resp := execInstall(t, tl, map[string]any{
1562 "op": "uninstall",
1563 "name": "ghost",
1564 })
1565 if resp.OK || resp.Status != "blocked" {
1566 t.Fatalf("response = %+v", resp)
1567 }
1568 }
1569
1570 func TestUninstallRequiresName(t *testing.T) {
1571 project := t.TempDir()
1572 home := t.TempDir()
1573 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1574 raw, _ := json.Marshal(map[string]any{"op": "uninstall"})
1575 if _, err := tl.Execute(context.Background(), raw); err == nil {
1576 t.Fatal("expected error when name is missing for uninstall")
1577 }
1578 }
1579
1580 // --- approval hook ----------------------------------------------------------
1581
1582 func TestApprovalHookDeniesApply(t *testing.T) {
1583 project := t.TempDir()
1584 home := t.TempDir()
1585 src := filepath.Join(t.TempDir(), "zeta.md")
1586 writeFile(t, src, "---\nname: zeta\ndescription: Zeta helper\n---\nbody")
1587
1588 var seen []action
1589 tl := NewTool(Options{
1590 ProjectRoot: project,
1591 HomeDir: home,
1592 Approval: func(actions []action) error {
1593 seen = actions
1594 return errors.New("user said no")
1595 },
1596 })
1597
1598 resp := execInstall(t, tl, map[string]any{
1599 "source": src,
1600 "kind": "skill",
1601 "apply": true,
1602 })
1603 if resp.OK {
1604 t.Fatalf("expected denial, got %+v", resp)
1605 }
1606 if resp.Status != "denied" {
1607 t.Errorf("status = %q, want denied", resp.Status)
1608 }
1609 if len(seen) != 1 || seen[0].Name != "zeta" {
1610 t.Errorf("approval should see the planned actions, got %+v", seen)
1611 }
1612 }
1613
1614 func TestPlanIDMismatchRefusesApply(t *testing.T) {
1615 project := t.TempDir()
1616 home := t.TempDir()
1617 src := filepath.Join(t.TempDir(), "eta.md")
1618 writeFile(t, src, "---\nname: eta\ndescription: Eta helper\n---\nbody")
1619
1620 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1621 raw, _ := json.Marshal(map[string]any{
1622 "source": src,
1623 "kind": "skill",
1624 "apply": true,
1625 "planId": "sha256:00000000000000000000000000000000",
1626 })
1627 out, err := tl.Execute(context.Background(), raw)
1628 if err == nil {
1629 t.Fatalf("expected planId mismatch error, got %s", out)
1630 }
1631 if !errors.Is(err, ErrApprovalDenied) {
1632 t.Errorf("expected ErrApprovalDenied, got %v", err)
1633 }
1634 }
1635
1636 func TestPlanIDIncludesActionDetails(t *testing.T) {
1637 req := request{
1638 Op: "install",
1639 Source: "/tmp/example/.mcp.json",
1640 Kind: "mcp",
1641 Scope: "project",
1642 Mode: "auto",
1643 }
1644 a := action{Kind: "mcp", Action: "install_mcp_server", Name: "same", URL: "https://mcp.one.example/mcp", Transport: "http", ConfigPath: "/repo/reasonix.toml"}
1645 b := a
1646 b.URL = "https://mcp.two.example/mcp"
1647 if computePlanID(req, []action{a}) == computePlanID(req, []action{b}) {
1648 t.Fatal("planId should change when action URL changes")
1649 }
1650 }
1651
1652 // --- sanitizers / parsers ---------------------------------------------------
1653
1654 func TestSanitizeNameEdges(t *testing.T) {
1655 cases := map[string]string{
1656 "": "mcp",
1657 " ": "mcp",
1658 "@@leading": "leading", // leading @ is stripped before the prefix rule
1659 "_underscore": "underscore", // config requires [a-zA-Z0-9] as first char
1660 "foo bar baz": "foo-bar-baz",
1661 "foo/bar": "foo-bar",
1662 "FOO": "foo",
1663 "a.b-c_d": "a.b-c_d",
1664 strings.Repeat("x", 100): strings.Repeat("x", 64),
1665 }
1666 for in, want := range cases {
1667 if got := sanitizeName(in); got != want {
1668 t.Errorf("sanitizeName(%q) = %q, want %q", in, got, want)
1669 }
1670 }
1671 }
1672
1673 func TestMCPNameFromURL(t *testing.T) {
1674 cases := map[string]string{
1675 "https://mcp.stripe.com/mcp": "stripe",
1676 "https://api.example.com/mcp": "example",
1677 "https://www.foo.com/mcp": "foo",
1678 "http://localhost:3000/mcp": "local-3000",
1679 "https://mcp.example.co.uk/agent": "example",
1680 "https://api.mcp.openai.com/v1/mcp": "openai",
1681 }
1682 for in, want := range cases {
1683 if got := mcpNameFromURL(in); got != want {
1684 t.Errorf("mcpNameFromURL(%q) = %q, want %q", in, got, want)
1685 }
1686 }
1687 }
1688
1689 func TestValidateMCPEntry(t *testing.T) {
1690 if err := validateMCPEntry(config.PluginEntry{Name: "x", Command: "y"}); err != nil {
1691 t.Errorf("stdio with command should validate, got %v", err)
1692 }
1693 if err := validateMCPEntry(config.PluginEntry{Name: "x", Type: "http", URL: "http://x"}); err != nil {
1694 t.Errorf("http with url should validate, got %v", err)
1695 }
1696 if err := validateMCPEntry(config.PluginEntry{Name: ""}); err == nil {
1697 t.Error("empty name should fail")
1698 }
1699 if err := validateMCPEntry(config.PluginEntry{Name: "bad/name", Command: "y"}); err == nil {
1700 t.Error("invalid name should fail")
1701 }
1702 if err := validateMCPEntry(config.PluginEntry{Name: "x", Type: "carrier-pigeon", Command: "c"}); err == nil {
1703 t.Error("unknown transport should fail")
1704 }
1705 }
1706
1707 func TestComputePlanIDStable(t *testing.T) {
1708 req := request{Op: "install", Source: "x", Scope: "project", Kind: "skill"}
1709 actions := []action{{Kind: "skill", Name: "a", Action: "copy_skill"}}
1710 id1 := computePlanID(req, actions)
1711 id2 := computePlanID(req, actions)
1712 if id1 != id2 {
1713 t.Errorf("planId should be stable, got %q vs %q", id1, id2)
1714 }
1715 id3 := computePlanID(request{Op: "install", Source: "y", Scope: "project", Kind: "skill"}, actions)
1716 if id3 == id1 {
1717 t.Errorf("planId should change with source")
1718 }
1719 }
1720
1721 func TestPlanIDUsesResolvedActionScope(t *testing.T) {
1722 reqOmitted := request{Op: "install", Source: "https://mcp.example.com/mcp", Kind: "mcp"}
1723 reqGlobal := request{Op: "install", Source: "https://mcp.example.com/mcp", Kind: "mcp", Scope: "global", scopeExplicit: true}
1724 actions := []action{{
1725 Kind: "mcp",
1726 Action: "install_mcp_server",
1727 Name: "example",
1728 URL: "https://mcp.example.com/mcp",
1729 Transport: "http",
1730 Scope: "global",
1731 ConfigPath: config.UserConfigPath(),
1732 }}
1733 if got, want := computePlanID(reqOmitted, actions), computePlanID(reqGlobal, actions); got != want {
1734 t.Fatalf("planId with omitted scope = %q, explicit global = %q; want same resolved plan", got, want)
1735 }
1736 }
1737
1738 // --- local executable -------------------------------------------------------
1739
1740 func TestPlanLocalExecutableDetected(t *testing.T) {
1741 project := t.TempDir()
1742 home := t.TempDir()
1743 bin := filepath.Join(t.TempDir(), "bin")
1744 if err := os.MkdirAll(bin, 0o755); err != nil {
1745 t.Fatal(err)
1746 }
1747 exe := writeLocalExecutable(t, bin, "mcp-x")
1748 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1749 resp := execInstall(t, tl, map[string]any{
1750 "source": exe,
1751 "kind": "mcp",
1752 })
1753 if !resp.OK || len(resp.Actions) != 1 {
1754 t.Fatalf("response = %+v", resp)
1755 }
1756 if resp.Actions[0].Command != exe {
1757 t.Errorf("command = %q, want the executable path", resp.Actions[0].Command)
1758 }
1759 }
1760
1761 func TestApplyLocalExecutableHonorsCommandOverride(t *testing.T) {
1762 project := t.TempDir()
1763 home := t.TempDir()
1764 bin := filepath.Join(t.TempDir(), "bin")
1765 if err := os.MkdirAll(bin, 0o755); err != nil {
1766 t.Fatal(err)
1767 }
1768 server := writeLocalExecutable(t, bin, "server")
1769
1770 stub := &stubConnector{toolCount: 1}
1771 tl := NewTool(Options{ProjectRoot: project, HomeDir: home, ConnectMCP: stub.connector()})
1772 resp := execInstall(t, tl, map[string]any{
1773 "source": server,
1774 "kind": "mcp",
1775 "apply": true,
1776 "name": "wrapped",
1777 "command": "node",
1778 "args": []string{server},
1779 })
1780
1781 if !resp.OK || len(resp.Actions) != 1 {
1782 t.Fatalf("response = %+v", resp)
1783 }
1784 if resp.Actions[0].Command != "node" || len(resp.Actions[0].Args) != 1 || resp.Actions[0].Args[0] != server {
1785 t.Fatalf("action command/args = %q %v, want node [%s]", resp.Actions[0].Command, resp.Actions[0].Args, server)
1786 }
1787 if len(stub.connected) != 1 || stub.connected[0].Command != "node" || len(stub.connected[0].Args) != 1 || stub.connected[0].Args[0] != server {
1788 t.Fatalf("connected entry = %+v, want node [%s]", stub.connected, server)
1789 }
1790 cfg := config.LoadForEdit(config.UserConfigPath())
1791 p, ok := findPlugin(cfg.Plugins, "wrapped")
1792 if !ok || p.Command != "node" || len(p.Args) != 1 || p.Args[0] != server {
1793 t.Fatalf("persisted plugins = %+v, want wrapped node [%s]", cfg.Plugins, server)
1794 }
1795 }
1796
1797 func findPlugin(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
1798 for _, entry := range entries {
1799 if entry.Name == name {
1800 return entry, true
1801 }
1802 }
1803 return config.PluginEntry{}, false
1804 }
1805
1806 func writeLocalExecutable(t *testing.T, dir, name string) string {
1807 t.Helper()
1808 if runtime.GOOS == "windows" {
1809 path := filepath.Join(dir, name+".cmd")
1810 writeFile(t, path, "@echo off\r\nexit /b 0\r\n")
1811 return path
1812 }
1813 path := filepath.Join(dir, name)
1814 writeFile(t, path, "#!/bin/sh\nexit 0\n")
1815 if err := os.Chmod(path, 0o755); err != nil {
1816 t.Fatal(err)
1817 }
1818 return path
1819 }
1820
1821 // --- plan-only: RiskLevel surfacing -----------------------------------------
1822
1823 func TestLinkRiskIsMedium(t *testing.T) {
1824 if level, _ := skillActionRisk("link", skillCandidate{SourcePath: "x"}); level != RiskMedium {
1825 t.Errorf("link mode should be RiskMedium, got %q", level)
1826 }
1827 if level, _ := skillActionRisk("copy", skillCandidate{SourcePath: "x"}); level != RiskLow {
1828 t.Errorf("copy mode should be RiskLow, got %q", level)
1829 }
1830 }
1831
1832 func TestEagerTierEscalatesRisk(t *testing.T) {
1833 level, _ := mcpActionRisk(config.PluginEntry{Name: "x", Tier: "eager", URL: "http://x"}, nil)
1834 if level != RiskHigh {
1835 t.Errorf("eager tier should escalate to RiskHigh, got %q", level)
1836 }
1837 }
1838
1839 // --- helpers ----------------------------------------------------------------
1840
1841 // ExampleNewTool is a godoc example that exercises the public surface
1842 // without touching the filesystem. It also serves as smoke coverage that
1843 // the Schema() output is valid JSON and the tool does not panic on a
1844 // well-formed call.
1845 func ExampleNewTool() {
1846 tl := NewTool(Options{
1847 ProjectRoot: "/tmp/example",
1848 HomeDir: "/tmp/example-home",
1849 })
1850 raw, _ := json.Marshal(map[string]any{"source": "https://example.com/mcp"})
1851 out, _ := tl.Execute(context.Background(), raw)
1852 var resp response
1853 _ = json.Unmarshal([]byte(out), &resp)
1854 fmt.Printf("status=%s kind=%s skill=%d mcp=%d\n",
1855 resp.Status, resp.Kind, resp.Kinds.Skill, resp.Kinds.MCP)
1856 // Output: status=planned kind=mcp skill=0 mcp=1
1857 }
1858
1859 // TestGitHubPluginPlanMatchesApply pins the approval contract: the plan the
1860 // user approves must describe exactly the capability set apply installs. Both
1861 // phases resolve the source through pluginSource, so convention-discovered
1862 // capabilities (skills/, commands/ — including nested namespaces) appear in
1863 // the plan, not only after installation.
1864 func TestGitHubPluginPlanMatchesApply(t *testing.T) {
1865 src := t.TempDir()
1866 writeFile(t, filepath.Join(src, ".claude-plugin", "plugin.json"), `{"name": "pwf", "version": "1.0.0"}`)
1867 writeFile(t, filepath.Join(src, "skills", "planner", "SKILL.md"), "---\ndescription: planner\n---\nbody")
1868 writeFile(t, filepath.Join(src, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan: $ARGUMENTS")
1869 writeFile(t, filepath.Join(src, "commands", "git", "commit.md"), "---\ndescription: commit\n---\nCommit")
1870
1871 project := t.TempDir()
1872 home := t.TempDir()
1873 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1874 tool := tl.(*installSourceTool)
1875 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
1876 return src, "cafe0001", func() {}, nil
1877 }
1878
1879 plan := execInstall(t, tl, map[string]any{
1880 "source": "https://github.com/acme/pwf",
1881 "kind": "plugin",
1882 })
1883 if !plan.OK || plan.Status != "planned" || len(plan.Actions) != 1 {
1884 t.Fatalf("plan response = %+v", plan)
1885 }
1886 planned := plan.Actions[0]
1887 if planned.SkillCount != 1 || planned.CommandCount != 2 {
1888 t.Fatalf("planned counts = %d skills / %d commands, want 1/2 (plan must see convention dirs)", planned.SkillCount, planned.CommandCount)
1889 }
1890
1891 applied := execInstall(t, tl, map[string]any{
1892 "source": "https://github.com/acme/pwf",
1893 "kind": "plugin",
1894 "apply": true,
1895 })
1896 if !applied.OK || applied.Status != "done" || len(applied.Actions) != 1 {
1897 t.Fatalf("apply response = %+v", applied)
1898 }
1899 got := applied.Actions[0]
1900 if got.SkillCount != planned.SkillCount || got.CommandCount != planned.CommandCount ||
1901 got.HookCount != planned.HookCount || got.ToolCount != planned.ToolCount {
1902 t.Fatalf("apply counts (%d/%d/%d/%d) diverge from approved plan (%d/%d/%d/%d)",
1903 got.SkillCount, got.CommandCount, got.HookCount, got.ToolCount,
1904 planned.SkillCount, planned.CommandCount, planned.HookCount, planned.ToolCount)
1905 }
1906 }
1907
1908 // TestGitHubClaudeMarketplacePlansAndAppliesRelativePlugins pins the desktop
1909 // workflow reported by users: entering a GitHub marketplace root should plan
1910 // each relative-path plugin, then install all approved entries from one clone.
1911 func TestGitHubClaudeMarketplacePlansAndAppliesRelativePlugins(t *testing.T) {
1912 marketplaceRoot := t.TempDir()
1913 writeFile(t, filepath.Join(marketplaceRoot, ".claude-plugin", "marketplace.json"), `{
1914 "name": "legal-tools",
1915 "owner": {"name": "Legal Team"},
1916 "plugins": [
1917 {"name": "beta-legal", "source": "./plugins/beta"},
1918 {"name": "alpha-legal", "source": "./plugins/alpha"}
1919 ]
1920 }`)
1921 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "alpha", ".claude-plugin", "plugin.json"), `{"name":"alpha-legal","version":"1.0.0"}`)
1922 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "alpha", "skills", "alpha", "SKILL.md"), "---\ndescription: alpha\n---\nAlpha")
1923 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "beta", ".claude-plugin", "plugin.json"), `{"name":"beta-legal","version":"2.0.0"}`)
1924 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "beta", "commands", "review.md"), "---\ndescription: review\n---\nReview")
1925
1926 project := t.TempDir()
1927 home := t.TempDir()
1928 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
1929 tool := tl.(*installSourceTool)
1930 cloneCalls := 0
1931 cleanupCalls := 0
1932 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
1933 cloneCalls++
1934 if source != "https://github.com/acme/legal-tools" {
1935 t.Fatalf("unexpected extra clone for %q", source)
1936 }
1937 return marketplaceRoot, "cafe0001", func() { cleanupCalls++ }, nil
1938 }
1939
1940 plan := execInstall(t, tl, map[string]any{
1941 "source": "https://github.com/acme/legal-tools",
1942 "kind": "plugin",
1943 })
1944 if !plan.OK || plan.Status != "planned" || len(plan.Actions) != 2 {
1945 t.Fatalf("plan response = %+v", plan)
1946 }
1947 if plan.Actions[0].Name != "alpha-legal" || plan.Actions[1].Name != "beta-legal" {
1948 t.Fatalf("actions = %+v, want stable marketplace-name order", plan.Actions)
1949 }
1950 if plan.Actions[0].Source != "https://github.com/acme/legal-tools/tree/main/plugins/alpha" ||
1951 plan.Actions[1].Source != "https://github.com/acme/legal-tools/tree/main/plugins/beta" {
1952 t.Fatalf("marketplace action sources = %q / %q", plan.Actions[0].Source, plan.Actions[1].Source)
1953 }
1954 if cloneCalls != 1 || cleanupCalls != 1 {
1955 t.Fatalf("preview clone/cleanup calls = %d/%d, want 1/1", cloneCalls, cleanupCalls)
1956 }
1957
1958 applied := execInstall(t, tl, map[string]any{
1959 "source": "https://github.com/acme/legal-tools",
1960 "kind": "plugin",
1961 "apply": true,
1962 })
1963 if !applied.OK || applied.Status != "done" || len(applied.Actions) != 2 {
1964 t.Fatalf("apply response = %+v", applied)
1965 }
1966 if cloneCalls != 2 || cleanupCalls != 2 {
1967 t.Fatalf("preview+apply clone/cleanup calls = %d/%d, want 2/2 (one clone per phase)", cloneCalls, cleanupCalls)
1968 }
1969 for _, name := range []string{"alpha-legal", "beta-legal"} {
1970 if _, ok, err := pluginpkg.FindInstalled(filepath.Join(home, ".reasonix"), name); err != nil || !ok {
1971 t.Fatalf("installed plugin %q missing: ok=%v err=%v", name, ok, err)
1972 }
1973 }
1974 }
1975
1976 func TestGitHubClaudeMarketplaceNameSelectsOnePlugin(t *testing.T) {
1977 marketplaceRoot := t.TempDir()
1978 writeFile(t, filepath.Join(marketplaceRoot, ".claude-plugin", "marketplace.json"), `{
1979 "name": "legal-tools",
1980 "plugins": [
1981 {"name": "alpha-legal", "source": "./alpha"},
1982 {"name": "beta-legal", "source": "./beta"}
1983 ]
1984 }`)
1985 for _, name := range []string{"alpha-legal", "beta-legal"} {
1986 dir := strings.TrimSuffix(name, "-legal")
1987 writeFile(t, filepath.Join(marketplaceRoot, dir, ".claude-plugin", "plugin.json"), fmt.Sprintf(`{"name":%q}`, name))
1988 writeFile(t, filepath.Join(marketplaceRoot, dir, "CLAUDE.md"), "Plugin context")
1989 }
1990
1991 tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: t.TempDir()})
1992 tool := tl.(*installSourceTool)
1993 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
1994 return marketplaceRoot, "cafe0001", func() {}, nil
1995 }
1996 plan := execInstall(t, tl, map[string]any{
1997 "source": "https://github.com/acme/legal-tools",
1998 "kind": "plugin",
1999 "name": "beta-legal",
2000 })
2001 if len(plan.Actions) != 1 || plan.Actions[0].Name != "beta-legal" {
2002 t.Fatalf("selected plan = %+v, want only beta-legal", plan.Actions)
2003 }
2004 }
2005
2006 func TestGitHubClaudeMarketplaceRejectsEscapingRelativeSource(t *testing.T) {
2007 marketplaceRoot := t.TempDir()
2008 writeFile(t, filepath.Join(marketplaceRoot, ".claude-plugin", "marketplace.json"), `{
2009 "name": "unsafe-tools",
2010 "plugins": [{"name": "escape", "source": "./../escape"}]
2011 }`)
2012
2013 tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: t.TempDir()})
2014 tool := tl.(*installSourceTool)
2015 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
2016 return marketplaceRoot, "cafe0001", func() {}, nil
2017 }
2018 raw, _ := json.Marshal(map[string]any{
2019 "source": "https://github.com/acme/unsafe-tools",
2020 "kind": "plugin",
2021 })
2022 _, err := tl.Execute(context.Background(), raw)
2023 if err == nil || !strings.Contains(err.Error(), "escapes") {
2024 t.Fatalf("error = %v, want marketplace path escape rejection", err)
2025 }
2026 }
2027
2028 func TestGitHubClaudeMarketplaceCleansCloneWhenApprovalIsDenied(t *testing.T) {
2029 marketplaceRoot := t.TempDir()
2030 writeFile(t, filepath.Join(marketplaceRoot, ".claude-plugin", "marketplace.json"), `{
2031 "name": "one-tool",
2032 "plugins": [{"name": "alpha", "source": "./alpha"}]
2033 }`)
2034 writeFile(t, filepath.Join(marketplaceRoot, "alpha", ".claude-plugin", "plugin.json"), `{"name":"alpha"}`)
2035 writeFile(t, filepath.Join(marketplaceRoot, "alpha", "CLAUDE.md"), "Plugin context")
2036
2037 cleanupCalls := 0
2038 tl := NewTool(Options{
2039 ProjectRoot: t.TempDir(),
2040 HomeDir: t.TempDir(),
2041 Approval: func(actions []action) error {
2042 return errors.New("not approved")
2043 },
2044 })
2045 tool := tl.(*installSourceTool)
2046 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
2047 return marketplaceRoot, "cafe0001", func() { cleanupCalls++ }, nil
2048 }
2049 resp := execInstall(t, tl, map[string]any{
2050 "source": "https://github.com/acme/one-tool",
2051 "kind": "plugin",
2052 "apply": true,
2053 })
2054 if resp.Status != "denied" || cleanupCalls != 1 {
2055 t.Fatalf("response=%+v cleanupCalls=%d, want denied and one cleanup", resp, cleanupCalls)
2056 }
2057 }
2058
2059 func TestGitHubClaudeMarketplaceCleansPreparedPinnedEntryWhenLaterEntryFails(t *testing.T) {
2060 marketplaceRoot := t.TempDir()
2061 externalRoot := t.TempDir()
2062 pinnedSHA := strings.Repeat("a", 40)
2063 writeFile(t, filepath.Join(marketplaceRoot, ".claude-plugin", "marketplace.json"), `{
2064 "name":"mixed-tools",
2065 "plugins":[
2066 {"name":"external","source":{"source":"url","url":"https://github.com/acme/external","sha":"`+pinnedSHA+`"}},
2067 {"name":"broken","source":"./missing"}
2068 ]
2069 }`)
2070 writeFile(t, filepath.Join(externalRoot, ".claude-plugin", "plugin.json"), `{"name":"external"}`)
2071 writeFile(t, filepath.Join(externalRoot, "CLAUDE.md"), "External context")
2072
2073 mainCleanup, pinnedCleanup := 0, 0
2074 tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: t.TempDir()})
2075 tool := tl.(*installSourceTool)
2076 tool.preparePlugin = func(_ context.Context, source, _ string) (string, string, func(), error) {
2077 if strings.Contains(source, "acme/external") {
2078 return externalRoot, pinnedSHA, func() { pinnedCleanup++ }, nil
2079 }
2080 return marketplaceRoot, strings.Repeat("b", 40), func() { mainCleanup++ }, nil
2081 }
2082 raw, _ := json.Marshal(map[string]any{
2083 "source": "https://github.com/acme/mixed-tools", "kind": "plugin", "apply": true,
2084 })
2085 if _, err := tl.Execute(context.Background(), raw); err == nil {
2086 t.Fatal("expected the later broken marketplace entry to fail planning")
2087 }
2088 if mainCleanup != 1 || pinnedCleanup != 1 {
2089 t.Fatalf("cleanup main=%d pinned=%d, want 1/1", mainCleanup, pinnedCleanup)
2090 }
2091 }
2092
2093 // TestGitHubClaudeMarketplaceAcceptsBarePathsAndSkipsUnsupported pins the
2094 // widened source subset: bare relative paths ("plugins/alpha") plan like
2095 // "./"-prefixed ones, while object sources, external URLs, and invalid names
2096 // skip with a warning instead of failing the whole plan.
2097 func TestGitHubClaudeMarketplaceAcceptsBarePathsAndSkipsUnsupported(t *testing.T) {
2098 marketplaceRoot := t.TempDir()
2099 writeFile(t, filepath.Join(marketplaceRoot, ".claude-plugin", "marketplace.json"), `{
2100 "name": "legal-tools",
2101 "metadata": {"pluginRoot": "plugins"},
2102 "plugins": [
2103 {"name": "alpha-legal", "source": "alpha"},
2104 {"name": "beta-legal", "source": "./beta"},
2105 {"name": "external", "source": "https://github.com/acme/elsewhere"},
2106 {"name": "object", "source": {"source": "github", "repo": "acme/elsewhere"}},
2107 {"name": "bad/name", "source": "./bad"}
2108 ]
2109 }`)
2110 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "alpha", ".claude-plugin", "plugin.json"), `{"name":"alpha-legal"}`)
2111 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "beta", ".claude-plugin", "plugin.json"), `{"name":"beta-legal"}`)
2112 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "alpha", "CLAUDE.md"), "Plugin context")
2113 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "beta", "CLAUDE.md"), "Plugin context")
2114
2115 tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: t.TempDir()})
2116 tool := tl.(*installSourceTool)
2117 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
2118 return marketplaceRoot, "cafe0001", func() {}, nil
2119 }
2120 plan := execInstall(t, tl, map[string]any{
2121 "source": "https://github.com/acme/legal-tools",
2122 "kind": "plugin",
2123 })
2124 if !plan.OK || plan.Status != "planned" || len(plan.Actions) != 2 {
2125 t.Fatalf("plan response = %+v", plan)
2126 }
2127 if plan.Actions[0].Name != "alpha-legal" || plan.Actions[1].Name != "beta-legal" {
2128 t.Fatalf("actions = %+v, want alpha-legal and beta-legal", plan.Actions)
2129 }
2130 if plan.Actions[0].Source != "https://github.com/acme/legal-tools/tree/main/plugins/alpha" ||
2131 plan.Actions[1].Source != "https://github.com/acme/legal-tools/tree/main/plugins/beta" {
2132 t.Fatalf("action sources = %q / %q", plan.Actions[0].Source, plan.Actions[1].Source)
2133 }
2134 joined := strings.Join(plan.Warnings, "\n")
2135 for _, fragment := range []string{
2136 `"external": external source`,
2137 `"object": object source is not a pinned GitHub URL`,
2138 `"bad/name": not a valid plugin name`,
2139 } {
2140 if !strings.Contains(joined, fragment) {
2141 t.Fatalf("warnings %q missing skip notice %q", plan.Warnings, fragment)
2142 }
2143 }
2144 }
2145
2146 // TestGitHubClaudeMarketplaceSelectedUnsupportedSourceFails pins the selection
2147 // contract: skipping is only for bulk installs — when the user names exactly
2148 // one plugin and its source shape is unsupported, the plan must fail loudly.
2149 func TestGitHubClaudeMarketplaceSelectedUnsupportedSourceFails(t *testing.T) {
2150 marketplaceRoot := t.TempDir()
2151 writeFile(t, filepath.Join(marketplaceRoot, ".claude-plugin", "marketplace.json"), `{
2152 "name": "legal-tools",
2153 "plugins": [
2154 {"name": "alpha-legal", "source": "./alpha"},
2155 {"name": "external", "source": "https://github.com/acme/elsewhere"}
2156 ]
2157 }`)
2158 writeFile(t, filepath.Join(marketplaceRoot, "alpha", ".claude-plugin", "plugin.json"), `{"name":"alpha-legal"}`)
2159
2160 tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: t.TempDir()})
2161 tool := tl.(*installSourceTool)
2162 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
2163 return marketplaceRoot, "cafe0001", func() {}, nil
2164 }
2165 raw, _ := json.Marshal(map[string]any{
2166 "source": "https://github.com/acme/legal-tools",
2167 "kind": "plugin",
2168 "name": "external",
2169 })
2170 _, err := tl.Execute(context.Background(), raw)
2171 if err == nil || !strings.Contains(err.Error(), "external source") {
2172 t.Fatalf("error = %v, want external-source rejection for the selected plugin", err)
2173 }
2174 }
2175
2176 func TestGitHubClaudeMarketplaceAcceptsPinnedGitHubURLObject(t *testing.T) {
2177 marketplaceRoot := t.TempDir()
2178 pluginRoot := t.TempDir()
2179 sha := strings.Repeat("a", 40)
2180 writeFile(t, filepath.Join(marketplaceRoot, ".claude-plugin", "marketplace.json"), fmt.Sprintf(`{
2181 "name":"critter-marketplace",
2182 "plugins":[{"name":"agent-critter","source":{"source":"url","url":"https://github.com/Jedeiah/agent-critter.git","sha":%q}}]
2183 }`, sha))
2184 writeFile(t, filepath.Join(pluginRoot, ".claude-plugin", "plugin.json"), `{"name":"agent-critter"}`)
2185 writeFile(t, filepath.Join(pluginRoot, "hooks", "hooks.json"), `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${CLAUDE_PLUGIN_ROOT}/bin/agent-critter","args":["--hook"],"async":true}]}]}}`)
2186
2187 cleanupCalls := 0
2188 tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: t.TempDir()})
2189 tool := tl.(*installSourceTool)
2190 tool.preparePlugin = func(_ context.Context, source, _ string) (string, string, func(), error) {
2191 if source == "https://github.com/acme/critter-marketplace" {
2192 return marketplaceRoot, "parent", func() {}, nil
2193 }
2194 if source == "https://github.com/Jedeiah/agent-critter.git" {
2195 return pluginRoot, sha, func() { cleanupCalls++ }, nil
2196 }
2197 return "", "", func() {}, fmt.Errorf("unexpected source %s", source)
2198 }
2199 plan := execInstall(t, tl, map[string]any{"source": "https://github.com/acme/critter-marketplace", "kind": "plugin"})
2200 if len(plan.Actions) != 1 || plan.Actions[0].Commit != sha || plan.Actions[0].HookCount != 1 {
2201 t.Fatalf("pinned plan = %+v", plan)
2202 }
2203 if cleanupCalls != 1 {
2204 t.Fatalf("external preview cleanup calls = %d", cleanupCalls)
2205 }
2206 }
2207
2208 // TestGitHubClaudeMarketplacePlanIDStableAcrossPlanAndApply pins the approval
2209 // contract the desktop host relies on: the planId returned by the preview must
2210 // match the planId recomputed by the apply call, or every marketplace apply
2211 // with an echoed planId would be refused.
2212 func TestGitHubClaudeMarketplacePlanIDStableAcrossPlanAndApply(t *testing.T) {
2213 marketplaceRoot := t.TempDir()
2214 writeFile(t, filepath.Join(marketplaceRoot, ".claude-plugin", "marketplace.json"), `{
2215 "name": "legal-tools",
2216 "metadata": {"pluginRoot": "plugins"},
2217 "plugins": [
2218 {"name": "alpha-legal", "source": "alpha"},
2219 {"name": "beta-legal", "source": "beta"}
2220 ]
2221 }`)
2222 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "alpha", ".claude-plugin", "plugin.json"), `{"name":"alpha-legal"}`)
2223 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "beta", ".claude-plugin", "plugin.json"), `{"name":"beta-legal"}`)
2224 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "alpha", "CLAUDE.md"), "Plugin context")
2225 writeFile(t, filepath.Join(marketplaceRoot, "plugins", "beta", "CLAUDE.md"), "Plugin context")
2226
2227 home := t.TempDir()
2228 tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: home})
2229 tool := tl.(*installSourceTool)
2230 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
2231 return marketplaceRoot, "cafe0001", func() {}, nil
2232 }
2233 plan := execInstall(t, tl, map[string]any{
2234 "source": "https://github.com/acme/legal-tools",
2235 "kind": "plugin",
2236 })
2237 if plan.Status != "planned" || len(plan.Actions) != 2 || plan.PlanID == "" {
2238 t.Fatalf("plan = %+v", plan)
2239 }
2240 applied := execInstall(t, tl, map[string]any{
2241 "source": "https://github.com/acme/legal-tools",
2242 "kind": "plugin",
2243 "apply": true,
2244 "planId": plan.PlanID,
2245 })
2246 if !applied.OK || applied.Status != "done" {
2247 t.Fatalf("apply with echoed planId = %+v", applied)
2248 }
2249 if applied.PlanID != plan.PlanID {
2250 t.Fatalf("plan ID drifted between plan (%s) and apply (%s)", plan.PlanID, applied.PlanID)
2251 }
2252 for _, name := range []string{"alpha-legal", "beta-legal"} {
2253 if _, ok, err := pluginpkg.FindInstalled(filepath.Join(home, ".reasonix"), name); err != nil || !ok {
2254 t.Fatalf("installed plugin %q missing: ok=%v err=%v", name, ok, err)
2255 }
2256 }
2257 }
2258
2259 // TestGitHubPluginApplyRefusesUnpinnableDrift pins the snapshot contract: when
2260 // the source resolves to a different commit than the plan approved and the
2261 // approved snapshot cannot be restored, apply must refuse instead of
2262 // installing content the approval never covered.
2263 func TestGitHubPluginApplyRefusesUnpinnableDrift(t *testing.T) {
2264 tree1 := t.TempDir()
2265 writeFile(t, filepath.Join(tree1, ".claude-plugin", "plugin.json"), `{"name": "pwf", "version": "1.0.0"}`)
2266 writeFile(t, filepath.Join(tree1, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan")
2267 tree2 := t.TempDir()
2268 writeFile(t, filepath.Join(tree2, ".claude-plugin", "plugin.json"), `{"name": "pwf", "version": "1.0.1"}`)
2269 writeFile(t, filepath.Join(tree2, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan")
2270 writeFile(t, filepath.Join(tree2, "commands", "extra.md"), "---\ndescription: extra\n---\nExtra")
2271
2272 project := t.TempDir()
2273 home := t.TempDir()
2274 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
2275 tool := tl.(*installSourceTool)
2276 calls := 0
2277 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
2278 calls++
2279 if calls == 1 {
2280 return tree1, "cafe0001", func() {}, nil // plan recompute inside the apply call
2281 }
2282 return tree2, "cafe0002", func() {}, nil // apply resolution: source moved
2283 }
2284
2285 resp := execInstall(t, tl, map[string]any{
2286 "source": "https://github.com/acme/pwf",
2287 "kind": "plugin",
2288 "apply": true,
2289 })
2290 if resp.OK || resp.Status != "failed" {
2291 t.Fatalf("response = %+v, want a failed apply when the source drifted past the approved commit", resp)
2292 }
2293 if len(resp.Actions) != 1 || resp.Actions[0].Status != "failed" {
2294 t.Fatalf("actions = %+v, want the single install action failed", resp.Actions)
2295 }
2296 if !strings.Contains(resp.Actions[0].Error, "approved commit cafe0001") {
2297 t.Fatalf("action error = %q, want the approved-commit drift refusal", resp.Actions[0].Error)
2298 }
2299 if _, ok, _ := pluginpkg.FindInstalled(filepath.Join(home, ".reasonix"), "pwf"); ok {
2300 t.Fatal("drifted plugin must not be installed")
2301 }
2302 }
2303
2304 // TestCopyMaterializesInRootSymlinkedCommands pins that a command alias
2305 // symlinked to a file inside the package survives copy-mode installs: the
2306 // installed tree must resolve to the same capability set the plan counted.
2307 func TestCopyMaterializesInRootSymlinkedCommands(t *testing.T) {
2308 src := t.TempDir()
2309 writeFile(t, filepath.Join(src, ".claude-plugin", "plugin.json"), `{"name": "aliases"}`)
2310 writeFile(t, filepath.Join(src, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody")
2311 writeFile(t, filepath.Join(src, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan")
2312 if err := os.Symlink(filepath.Join(src, "commands", "plan.md"), filepath.Join(src, "commands", "pwf.md")); err != nil {
2313 t.Skipf("symlinks unavailable on this platform: %v", err)
2314 }
2315
2316 project := t.TempDir()
2317 home := t.TempDir()
2318 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
2319 tool := tl.(*installSourceTool)
2320 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
2321 return src, "cafe0001", func() {}, nil
2322 }
2323
2324 resp := execInstall(t, tl, map[string]any{
2325 "source": "https://github.com/acme/aliases",
2326 "kind": "plugin",
2327 "apply": true,
2328 })
2329 if !resp.OK || resp.Status != "done" || len(resp.Actions) != 1 {
2330 t.Fatalf("response = %+v", resp)
2331 }
2332 if resp.Actions[0].CommandCount != 2 {
2333 t.Fatalf("planned commands = %d, want 2 (alias followed)", resp.Actions[0].CommandCount)
2334 }
2335 installedRoot := filepath.Join(home, ".reasonix", "plugins", "aliases")
2336 pkg, _, err := pluginpkg.ParseDir(installedRoot)
2337 if err != nil {
2338 t.Fatalf("ParseDir installed: %v", err)
2339 }
2340 if _, commands, _, _ := pkg.CapabilityCounts(); commands != 2 {
2341 t.Fatalf("installed commands = %d, want the symlinked alias materialized", commands)
2342 }
2343 }
2344
2345 // TestCopyRefusesUnmaterializableSymlinkCommands pins the fail-closed path: a
2346 // command symlinked to a file OUTSIDE the package counts during planning but
2347 // cannot be materialized by copy mode, so apply must refuse (and clean up)
2348 // rather than silently install fewer commands than approved.
2349 func TestCopyRefusesUnmaterializableSymlinkCommands(t *testing.T) {
2350 outside := t.TempDir()
2351 writeFile(t, filepath.Join(outside, "evil.md"), "---\ndescription: evil\n---\nEvil")
2352 src := t.TempDir()
2353 writeFile(t, filepath.Join(src, ".claude-plugin", "plugin.json"), `{"name": "escapes"}`)
2354 writeFile(t, filepath.Join(src, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan")
2355 if err := os.Symlink(filepath.Join(outside, "evil.md"), filepath.Join(src, "commands", "evil.md")); err != nil {
2356 t.Skipf("symlinks unavailable on this platform: %v", err)
2357 }
2358
2359 project := t.TempDir()
2360 home := t.TempDir()
2361 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
2362 tool := tl.(*installSourceTool)
2363 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
2364 return src, "cafe0001", func() {}, nil
2365 }
2366
2367 resp := execInstall(t, tl, map[string]any{
2368 "source": "https://github.com/acme/escapes",
2369 "kind": "plugin",
2370 "apply": true,
2371 })
2372 if resp.OK || resp.Status != "failed" || len(resp.Actions) != 1 || resp.Actions[0].Status != "failed" {
2373 t.Fatalf("response = %+v, want a failed apply for the unmaterializable symlink", resp)
2374 }
2375 if !strings.Contains(resp.Actions[0].Error, "approved plan counted") {
2376 t.Fatalf("action error = %q, want the capability-verification refusal", resp.Actions[0].Error)
2377 }
2378 if _, err := os.Stat(filepath.Join(home, ".reasonix", "plugins", "escapes")); !os.IsNotExist(err) {
2379 t.Fatal("failed install must not leave the copied tree behind")
2380 }
2381 if _, ok, _ := pluginpkg.FindInstalled(filepath.Join(home, ".reasonix"), "escapes"); ok {
2382 t.Fatal("failed install must not be registered")
2383 }
2384 }
2385
2386 // TestFailedReplaceKeepsExistingPluginInstall pins the update-safety contract:
2387 // when a replace=true update fails capability verification (e.g. the new
2388 // version ships an unmaterializable symlink), the previously installed
2389 // version must survive on disk and stay registered — a failed update may
2390 // never leave an enabled plugin pointing at a missing or gutted root.
2391 func TestFailedReplaceKeepsExistingPluginInstall(t *testing.T) {
2392 v1 := t.TempDir()
2393 writeFile(t, filepath.Join(v1, ".claude-plugin", "plugin.json"), `{"name": "pwf", "version": "1.0.0"}`)
2394 writeFile(t, filepath.Join(v1, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan")
2395 outside := t.TempDir()
2396 writeFile(t, filepath.Join(outside, "evil.md"), "---\ndescription: evil\n---\nEvil")
2397 v2 := t.TempDir()
2398 writeFile(t, filepath.Join(v2, ".claude-plugin", "plugin.json"), `{"name": "pwf", "version": "2.0.0"}`)
2399 writeFile(t, filepath.Join(v2, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan")
2400 if err := os.Symlink(filepath.Join(outside, "evil.md"), filepath.Join(v2, "commands", "evil.md")); err != nil {
2401 t.Skipf("symlinks unavailable on this platform: %v", err)
2402 }
2403
2404 project := t.TempDir()
2405 home := t.TempDir()
2406 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
2407 tool := tl.(*installSourceTool)
2408 current, commit := v1, "cafe0001"
2409 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
2410 return current, commit, func() {}, nil
2411 }
2412
2413 install := execInstall(t, tl, map[string]any{
2414 "source": "https://github.com/acme/pwf",
2415 "kind": "plugin",
2416 "apply": true,
2417 })
2418 if !install.OK || install.Status != "done" {
2419 t.Fatalf("initial install = %+v", install)
2420 }
2421
2422 current, commit = v2, "cafe0002"
2423 update := execInstall(t, tl, map[string]any{
2424 "source": "https://github.com/acme/pwf",
2425 "kind": "plugin",
2426 "apply": true,
2427 "replace": true,
2428 })
2429 if update.OK || update.Status != "failed" {
2430 t.Fatalf("update = %+v, want a failed apply for the unmaterializable symlink", update)
2431 }
2432
2433 installedRoot := filepath.Join(home, ".reasonix", "plugins", "pwf")
2434 if _, err := os.Stat(filepath.Join(installedRoot, "commands", "plan.md")); err != nil {
2435 t.Fatalf("previous install must survive a failed update: %v", err)
2436 }
2437 pkg, _, err := pluginpkg.ParseDir(installedRoot)
2438 if err != nil {
2439 t.Fatalf("ParseDir installed: %v", err)
2440 }
2441 if pkg.Manifest.Version != "1.0.0" {
2442 t.Fatalf("installed version = %q, want the previous 1.0.0 kept", pkg.Manifest.Version)
2443 }
2444 if p, ok, _ := pluginpkg.FindInstalled(filepath.Join(home, ".reasonix"), "pwf"); !ok || !p.Enabled {
2445 t.Fatal("previous registration must survive a failed update")
2446 }
2447 if _, err := os.Stat(installedRoot + ".pre-replace"); !os.IsNotExist(err) {
2448 t.Fatal("failed update must not leave a backup tree behind")
2449 }
2450 entries, err := os.ReadDir(filepath.Dir(installedRoot))
2451 if err != nil {
2452 t.Fatal(err)
2453 }
2454 for _, e := range entries {
2455 if strings.Contains(e.Name(), ".staging-") {
2456 t.Fatalf("failed update must not leave staging dir %q behind", e.Name())
2457 }
2458 }
2459 }
2460
2461 // TestBackupPathCannotCollideWithSiblingPlugin pins the backup-naming
2462 // contract: plugin names may legally contain dots, so a plugin literally
2463 // named "foo.pre-replace" must survive an update of plugin "foo" — the swap
2464 // backup must use a name no valid plugin can occupy.
2465 func TestBackupPathCannotCollideWithSiblingPlugin(t *testing.T) {
2466 fooV1 := t.TempDir()
2467 writeFile(t, filepath.Join(fooV1, ".claude-plugin", "plugin.json"), `{"name": "foo", "version": "1.0.0"}`)
2468 writeFile(t, filepath.Join(fooV1, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan")
2469 fooV2 := t.TempDir()
2470 writeFile(t, filepath.Join(fooV2, ".claude-plugin", "plugin.json"), `{"name": "foo", "version": "2.0.0"}`)
2471 writeFile(t, filepath.Join(fooV2, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan v2")
2472 sibling := t.TempDir()
2473 writeFile(t, filepath.Join(sibling, ".claude-plugin", "plugin.json"), `{"name": "foo.pre-replace", "version": "1.0.0"}`)
2474 writeFile(t, filepath.Join(sibling, "commands", "keep.md"), "---\ndescription: keep\n---\nKeep")
2475
2476 project := t.TempDir()
2477 home := t.TempDir()
2478 tl := NewTool(Options{ProjectRoot: project, HomeDir: home})
2479 tool := tl.(*installSourceTool)
2480 sources := map[string]string{
2481 "https://github.com/acme/foo": fooV1,
2482 "https://github.com/acme/sibling": sibling,
2483 }
2484 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) {
2485 return sources[source], "cafe-" + source, func() {}, nil
2486 }
2487
2488 for _, source := range []string{"https://github.com/acme/foo", "https://github.com/acme/sibling"} {
2489 resp := execInstall(t, tl, map[string]any{"source": source, "kind": "plugin", "apply": true})
2490 if !resp.OK || resp.Status != "done" {
2491 t.Fatalf("install %s = %+v", source, resp)
2492 }
2493 }
2494
2495 sources["https://github.com/acme/foo"] = fooV2
2496 update := execInstall(t, tl, map[string]any{
2497 "source": "https://github.com/acme/foo",
2498 "kind": "plugin",
2499 "apply": true,
2500 "replace": true,
2501 })
2502 if !update.OK || update.Status != "done" {
2503 t.Fatalf("update = %+v", update)
2504 }
2505
2506 siblingRoot := filepath.Join(home, ".reasonix", "plugins", "foo.pre-replace")
2507 if _, err := os.Stat(filepath.Join(siblingRoot, "commands", "keep.md")); err != nil {
2508 t.Fatalf("sibling plugin's files must survive the update of foo: %v", err)
2509 }
2510 if _, ok, _ := pluginpkg.FindInstalled(filepath.Join(home, ".reasonix"), "foo.pre-replace"); !ok {
2511 t.Fatal("sibling plugin must stay registered")
2512 }
2513 pkg, _, err := pluginpkg.ParseDir(filepath.Join(home, ".reasonix", "plugins", "foo"))
2514 if err != nil {
2515 t.Fatalf("ParseDir foo: %v", err)
2516 }
2517 if pkg.Manifest.Version != "2.0.0" {
2518 t.Fatalf("foo version = %q, want the update applied", pkg.Manifest.Version)
2519 }
2520 }
2521
2521 lines GO