package installsource import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "sync/atomic" "testing" "reasonix/internal/config" "reasonix/internal/pluginpkg" "reasonix/internal/skill" "reasonix/internal/tool" ) func TestMain(m *testing.M) { dir, err := os.MkdirTemp("", "reasonix-installsource-test-*") if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } _ = os.Setenv("HOME", dir) _ = os.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, ".config")) _ = os.Setenv("AppData", filepath.Join(dir, "AppData")) code := m.Run() _ = os.RemoveAll(dir) os.Exit(code) } // --- shared helpers --------------------------------------------------------- // execInstall marshals args, calls Execute, and unmarshals the response. // Failures in Execute bubble up as t.Fatal; the response is returned so the // caller can assert on the JSON shape. func execInstall(t *testing.T, tl tool.Tool, args map[string]any) response { t.Helper() raw, err := json.Marshal(args) if err != nil { t.Fatal(err) } out, err := tl.Execute(context.Background(), raw) if err != nil { t.Fatalf("Execute error: %v\nout=%s", err, out) } var resp response if err := json.Unmarshal([]byte(out), &resp); err != nil { t.Fatalf("response JSON %q: %v", out, err) } return resp } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func registeredRoots(actions []action) []string { var roots []string for _, a := range actions { if a.Action == "register_skill_root" { roots = append(roots, a.Source) } } return roots } // stubConnector returns a ConnectMCP closure that records every entry and // returns the configured tool count + Disconnect. disconnectCalls counts // rollback invocations so tests can assert on ghost-install behavior. type stubConnector struct { connected []config.PluginEntry toolCount int failOnName string disconnectCalls *atomic.Int32 } func (s *stubConnector) connector() MCPConnector { return func(e config.PluginEntry) (MCPConnectResult, error) { if e.Name == s.failOnName { return MCPConnectResult{}, errors.New("connect refused: " + e.Name) } s.connected = append(s.connected, e) return MCPConnectResult{ ToolCount: s.toolCount, Disconnect: func() { if s.disconnectCalls != nil { s.disconnectCalls.Add(1) } }, }, nil } } // --- apply: skill paths ----------------------------------------------------- func TestApplyLocalSkillRootRegistersPath(t *testing.T) { project := t.TempDir() home := t.TempDir() root := filepath.Join(t.TempDir(), "shared-skills") if err := os.MkdirAll(root, 0o755); err != nil { t.Fatal(err) } writeFile(t, filepath.Join(root, "alpha.md"), "---\nname: alpha\ndescription: Alpha helper\n---\nDo alpha work.") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": root, "kind": "skill", "apply": true, "scope": "project", }) if !resp.OK || resp.Status != "done" { t.Fatalf("response = %+v", resp) } if len(resp.Actions) != 1 || resp.Actions[0].Action != "register_skill_root" { t.Fatalf("actions = %+v", resp.Actions) } if resp.PlanID == "" { t.Error("PlanID should be populated on apply") } cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml")) if len(cfg.Skills.Paths) != 1 || cfg.Skills.Paths[0] != root { t.Fatalf("skills.paths = %v, want %q", cfg.Skills.Paths, root) } st := skill.New(skill.Options{HomeDir: home, ProjectRoot: project, CustomPaths: cfg.SkillCustomPaths(), DisableBuiltins: true}) if _, ok := st.Read("alpha"); !ok { t.Fatal("alpha should be discoverable after registering the skill root") } } func TestApplyLocalCodexPluginPackage(t *testing.T) { project := t.TempDir() home := t.TempDir() src := filepath.Join(t.TempDir(), "superpowers") writeFile(t, filepath.Join(src, ".codex-plugin", "plugin.json"), `{ "name": "superpowers", "version": "6.1.0", "description": "Planning workflows", "skills": "./skills/" }`) writeFile(t, filepath.Join(src, "skills", "using-superpowers", "SKILL.md"), "---\nname: using-superpowers\ndescription: Use skills\n---\nUse skills.") writeFile(t, filepath.Join(src, "hooks", "session-start-codex"), "#!/usr/bin/env bash\necho ok\n") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) planned := execInstall(t, tl, map[string]any{ "source": src, "kind": "plugin", }) if planned.Kind != "plugin" || planned.Kinds.Plugin != 1 { t.Fatalf("planned = %+v, want one plugin action", planned) } if planned.Actions[0].Name != "superpowers" || planned.Actions[0].SkillCount != 1 || planned.Actions[0].HookCount != 1 { t.Fatalf("plugin action = %+v", planned.Actions[0]) } done := execInstall(t, tl, map[string]any{ "source": src, "kind": "plugin", "apply": true, }) if !done.OK || done.Status != "done" { t.Fatalf("apply response = %+v", done) } statePath := filepath.Join(home, ".reasonix", "plugin-packages.json") raw, err := os.ReadFile(statePath) if err != nil { t.Fatalf("state file missing: %v", err) } if !strings.Contains(string(raw), `"name": "superpowers"`) || !strings.Contains(string(raw), `"manifestKind": "codex"`) { t.Fatalf("state file = %s", raw) } if _, err := os.Stat(filepath.Join(home, ".reasonix", "plugins", "superpowers", ".codex-plugin", "plugin.json")); err != nil { t.Fatalf("installed plugin missing: %v", err) } } func TestApplyLocalClaudePluginPackage(t *testing.T) { project := t.TempDir() home := t.TempDir() src := filepath.Join(t.TempDir(), "ui-ux-pro-max") writeFile(t, filepath.Join(src, ".claude-plugin", "plugin.json"), `{ "name": "ui-ux-pro-max", "version": "2.6.2", "description": "UI/UX design intelligence", "skills": "./.claude/skills/" }`) 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.") writeFile(t, filepath.Join(src, "CLAUDE.md"), "Use the bundled UI workflow.") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) planned := execInstall(t, tl, map[string]any{ "source": src, "kind": "plugin", }) if planned.Kind != "plugin" || planned.Kinds.Plugin != 1 { t.Fatalf("planned = %+v, want one plugin action", planned) } if planned.Actions[0].Name != "ui-ux-pro-max" || planned.Actions[0].ManifestKind != "claude" || planned.Actions[0].SkillCount != 1 || planned.Actions[0].HookCount != 1 { t.Fatalf("plugin action = %+v", planned.Actions[0]) } done := execInstall(t, tl, map[string]any{ "source": src, "kind": "plugin", "apply": true, }) if !done.OK || done.Status != "done" { t.Fatalf("apply response = %+v", done) } statePath := filepath.Join(home, ".reasonix", "plugin-packages.json") raw, err := os.ReadFile(statePath) if err != nil { t.Fatalf("state file missing: %v", err) } if !strings.Contains(string(raw), `"name": "ui-ux-pro-max"`) || !strings.Contains(string(raw), `"manifestKind": "claude"`) { t.Fatalf("state file = %s", raw) } if _, err := os.Stat(filepath.Join(home, ".reasonix", "plugins", "ui-ux-pro-max", ".claude-plugin", "plugin.json")); err != nil { t.Fatalf("installed plugin missing: %v", err) } } func TestApplyLocalSkillFileCopiesToProject(t *testing.T) { project := t.TempDir() home := t.TempDir() src := filepath.Join(t.TempDir(), "beta.md") writeFile(t, src, "---\nname: beta\ndescription: Beta helper\n---\nDo beta work.") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": src, "kind": "skill", "apply": true, "scope": "project", }) if !resp.OK || resp.Actions[0].Action != "copy_skill" { t.Fatalf("response = %+v", resp) } if resp.Actions[0].RiskLevel != RiskLow { t.Errorf("copy of a single file should be RiskLow, got %q", resp.Actions[0].RiskLevel) } target := filepath.Join(project, ".reasonix", "skills", "beta", "SKILL.md") if raw, err := os.ReadFile(target); err != nil || !strings.Contains(string(raw), "Beta helper") { t.Fatalf("copied skill = %q err=%v", raw, err) } if resp.Actions[0].CanonicalPath != target || !resp.Actions[0].Discoverable || !resp.Actions[0].Indexed { t.Fatalf("skill verification fields = %+v, want canonical/discoverable/indexed", resp.Actions[0]) } } func TestApplyLocalSkillFileDoesNotShadowFlatCompatInstall(t *testing.T) { project := t.TempDir() home := t.TempDir() existing := filepath.Join(project, ".reasonix", "skills", "beta.md") writeFile(t, existing, "---\nname: beta\ndescription: Existing beta\n---\nold") src := filepath.Join(t.TempDir(), "beta.md") writeFile(t, src, "---\nname: beta\ndescription: New beta\n---\nnew") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": src, "kind": "skill", "apply": true, "scope": "project", }) if resp.OK { t.Fatalf("canonical install should not shadow existing flat skill, got %+v", resp) } if !strings.Contains(resp.Actions[0].Error, "already exists") { t.Fatalf("error = %q, want duplicate guard", resp.Actions[0].Error) } } func TestApplyLocalSKILLFileCopiesSiblingResources(t *testing.T) { project := t.TempDir() home := t.TempDir() srcDir := filepath.Join(t.TempDir(), "frontend-design") writeFile(t, filepath.Join(srcDir, "SKILL.md"), "---\nname: frontend-design\ndescription: Frontend helper\n---\nSee references/style.md") writeFile(t, filepath.Join(srcDir, "references", "style.md"), "# Style\n\nUse crisp layouts.") writeFile(t, filepath.Join(srcDir, "scripts", "lint.sh"), "#!/bin/sh\nexit 0\n") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": filepath.Join(srcDir, "SKILL.md"), "kind": "skill", "apply": true, "scope": "project", }) if !resp.OK { t.Fatalf("response = %+v", resp) } if resp.Actions[0].RiskLevel != RiskMedium { t.Fatalf("directory package copy should be RiskMedium, got %q", resp.Actions[0].RiskLevel) } target := filepath.Join(project, ".reasonix", "skills", "frontend-design", "SKILL.md") if resp.Actions[0].CanonicalPath != target { t.Fatalf("canonicalPath = %q, want %q", resp.Actions[0].CanonicalPath, target) } if _, err := os.Stat(filepath.Join(project, ".reasonix", "skills", "frontend-design", "references", "style.md")); err != nil { t.Fatalf("reference file should be copied with SKILL.md source: %v", err) } if _, err := os.Stat(filepath.Join(project, ".reasonix", "skills", "frontend-design", "scripts", "lint.sh")); err != nil { t.Fatalf("script file should be copied with SKILL.md source: %v", err) } st := skill.New(skill.Options{HomeDir: home, ProjectRoot: project, DisableBuiltins: true}) sk, ok := st.Read("frontend-design") if !ok || !strings.Contains(sk.Body, "Use crisp layouts.") { t.Fatalf("installed skill should load copied reference, ok=%v body=%q", ok, sk.Body) } } func TestApplyLocalSkillLinkMode(t *testing.T) { project := t.TempDir() home := t.TempDir() src := filepath.Join(project, "local-skills", "gamma.md") writeFile(t, src, "---\nname: gamma\ndescription: Gamma helper\n---\nDo gamma work.") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": src, "kind": "skill", "apply": true, "scope": "project", "mode": "link", }) if !resp.OK { t.Fatalf("response = %+v", resp) } if runtime.GOOS == "windows" { t.Skip("symlink semantics differ on Windows") } if resp.Actions[0].Action != "link_skill" { t.Fatalf("action = %q, want link_skill", resp.Actions[0].Action) } if resp.Actions[0].RiskLevel != RiskMedium && resp.Actions[0].RiskLevel != RiskHigh { t.Errorf("link mode should be at least RiskMedium, got %q", resp.Actions[0].RiskLevel) } target := filepath.Join(project, ".reasonix", "skills", "gamma", "SKILL.md") if fi, err := os.Lstat(target); err != nil || fi.Mode()&os.ModeSymlink == 0 { t.Fatalf("target should be a symlink: lstat err=%v mode=%v", err, fi.Mode()) } } func TestPlanNestedSkillRootRegistersContainingRoots(t *testing.T) { project := t.TempDir() home := t.TempDir() root := filepath.Join(t.TempDir(), "skill-pack") writeFile(t, filepath.Join(root, "top.md"), "---\nname: top\ndescription: Top helper\n---\nbody") writeFile(t, filepath.Join(root, "superpower", "tool-a", "SKILL.md"), "---\nname: tool-a\ndescription: Tool A\n---\nbody") writeFile(t, filepath.Join(root, "superpower", "tool-b", "SKILL.md"), "---\nname: tool-b\ndescription: Tool B\n---\nbody") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": root, "kind": "skill", "apply": true, "scope": "project", }) if !resp.OK { t.Fatalf("response = %+v", resp) } if len(resp.Actions) != 2 { t.Fatalf("actions = %+v, want root and nested containing-root registrations", resp.Actions) } registered := map[string][]string{} for _, a := range resp.Actions { registered[a.Source] = a.Skills if !a.Discoverable || !a.Indexed { t.Fatalf("registered action should be verified: %+v", a) } } if got := strings.Join(registered[root], ","); got != "top" { t.Fatalf("root skills = %q, want top", got) } if got := strings.Join(registered[filepath.Join(root, "superpower")], ","); got != "tool-a,tool-b" { t.Fatalf("nested skills = %q, want tool-a,tool-b", got) } st := skill.New(skill.Options{HomeDir: home, ProjectRoot: project, CustomPaths: registeredRoots(resp.Actions), DisableBuiltins: true}) for _, name := range []string{"top", "tool-a", "tool-b"} { if _, ok := st.Read(name); !ok { t.Fatalf("%s should be discoverable after registering containing roots", name) } } } func TestPlanNestedSkillRootRespectsDepthLimit(t *testing.T) { project := t.TempDir() home := t.TempDir() root := filepath.Join(t.TempDir(), "deep-pack") writeFile(t, filepath.Join(root, "one", "two", "three", "deep", "SKILL.md"), "---\nname: deep\ndescription: Deep helper\n---\nbody") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) raw, _ := json.Marshal(map[string]any{ "source": root, "kind": "skill", }) out, err := tl.Execute(context.Background(), raw) if err == nil { t.Fatalf("expected no manifest within depth limit, got %s", out) } if !errors.Is(err, ErrManifestMissing) { t.Fatalf("error = %v, want ErrManifestMissing", err) } } func TestApplyLinkSkillRejectsEscape(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("absolute path semantics differ on Windows") } project := t.TempDir() home := t.TempDir() // Synthesize a candidate that points at /etc/passwd by calling the // private helper directly. The link check runs before any disk write. if isLinkTargetSafe("/etc/passwd", home, project) { t.Fatal("/etc/passwd should be considered unsafe") } if !isLinkTargetSafe("./local-skill.md", home, project) { t.Fatal("relative link target should be safe") } if !isLinkTargetSafe(filepath.Join(project, "skills/x.md"), home, project) { t.Fatal("link under project root should be safe") } src := filepath.Join(t.TempDir(), "escape.md") writeFile(t, src, "---\nname: escape\ndescription: Escape helper\n---\nbody") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": src, "kind": "skill", "apply": true, "mode": "link", }) if resp.OK { t.Fatalf("unsafe link should fail, got %+v", resp) } if !strings.Contains(resp.Actions[0].Error, ErrUnsafeLinkTarget.Error()) { t.Errorf("error = %q, want ErrUnsafeLinkTarget", resp.Actions[0].Error) } } func TestParseSkillContentStrictAllowsMissingDescription(t *testing.T) { // strict=false lets us install a raw SKILL.md that lacks a description. // The model should still be told the description is empty so the // Skills index entry is honest. cand, err := parseSkillContent("---\nname: raw\n---\nBody without desc", "raw", "in-memory", false) if err != nil { t.Fatalf("strict=false should accept missing description: %v", err) } if cand.Description != "" { t.Errorf("description should be empty, got %q", cand.Description) } if cand.Name != "raw" { t.Errorf("name = %q, want raw", cand.Name) } if _, err := parseSkillContent("---\nname: strict\n---\nbody", "strict", "in-memory", true); err == nil { t.Fatal("strict=true should reject missing description") } } func TestParseSkillContentRejectsMalformedFrontmatter(t *testing.T) { _, err := parseSkillContent("---\nname: [broken\n---\nbody", "broken", "in-memory", true) if err == nil { t.Fatal("malformed frontmatter should fail") } if !strings.Contains(err.Error(), "invalid YAML") || !strings.Contains(strings.ToLower(err.Error()), "line") { t.Fatalf("error = %v, want invalid YAML with location", err) } } func TestApplyStrictFalseWarnsWhenDescriptionMissing(t *testing.T) { project := t.TempDir() home := t.TempDir() src := filepath.Join(t.TempDir(), "raw.md") writeFile(t, src, "---\nname: raw\n---\nBody without desc") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": src, "kind": "skill", "apply": true, "strict": false, }) if !resp.OK { t.Fatalf("response = %+v", resp) } if !resp.Actions[0].Discoverable || !resp.Actions[0].Indexed { t.Fatalf("raw skill should still be discoverable/indexed with placeholder: %+v", resp.Actions[0]) } found := false for _, warning := range append(resp.Warnings, resp.Actions[0].Warnings...) { if strings.Contains(warning, "no description") { found = true break } } if !found { t.Fatalf("warnings = %v action warnings = %v, want missing description warning", resp.Warnings, resp.Actions[0].Warnings) } } // --- plan / apply: MCP paths ----------------------------------------------- func TestPlanLocalMCPJSON(t *testing.T) { project := t.TempDir() home := t.TempDir() mcpPath := filepath.Join(t.TempDir(), ".mcp.json") writeFile(t, mcpPath, `{ "mcpServers": { "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }, "remote": { "type": "http", "url": "https://mcp.example.com/mcp", "headers": { "Authorization": "Bearer ${TOKEN}" } } } }`) tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": mcpPath, "kind": "mcp", }) if !resp.OK || resp.Status != "planned" { t.Fatalf("response = %+v", resp) } if len(resp.Actions) != 2 { t.Fatalf("actions = %+v", resp.Actions) } if resp.Actions[0].Name != "fs" || resp.Actions[0].Transport != "stdio" { t.Fatalf("first action = %+v", resp.Actions[0]) } if resp.Actions[1].Name != "remote" || resp.Actions[1].Transport != "http" { t.Fatalf("second action = %+v", resp.Actions[1]) } for _, action := range resp.Actions { if action.Scope != "global" || action.ConfigPath != config.UserConfigPath() { t.Fatalf("local .mcp.json outside project action scope/path = %q %q, want global %q", action.Scope, action.ConfigPath, config.UserConfigPath()) } } if resp.Kinds.MCP != 2 { t.Errorf("Kinds.MCP = %d, want 2", resp.Kinds.MCP) } } func TestPlanProjectMCPJSONDefaultsProject(t *testing.T) { project := t.TempDir() home := t.TempDir() mcpPath := filepath.Join(project, ".mcp.json") writeFile(t, mcpPath, `{ "mcpServers": { "projectfs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] } } }`) tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": mcpPath, "kind": "mcp", }) if !resp.OK || len(resp.Actions) != 1 { t.Fatalf("response = %+v", resp) } wantPath := filepath.Join(project, "reasonix.toml") if resp.Scope != "project" || resp.Actions[0].Scope != "project" || resp.Actions[0].ConfigPath != wantPath { 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) } } func TestPlanMCPJSONUnknownTierProducesWarning(t *testing.T) { entries, warnings, err := parseMCPJSON([]byte(`{ "mcpServers": { "x": { "command": "node", "tier": "absurd" } } }`)) if err != nil { t.Fatalf("parseMCPJSON: %v", err) } if len(entries) != 1 || entries[0].Tier != "background" { t.Fatalf("entries = %+v", entries) } found := false for _, w := range warnings { if strings.Contains(w, "absurd") { found = true } } if !found { t.Errorf("expected warning about unknown tier, got %v", warnings) } } func TestPlanMCPJSONDefaultTierIsBackground(t *testing.T) { entries, warnings, err := parseMCPJSON([]byte(`{ "mcpServers": { "x": { "command": "node" } } }`)) if err != nil { t.Fatalf("parseMCPJSON: %v", err) } if len(warnings) != 0 { t.Fatalf("warnings = %v, want none", warnings) } if len(entries) != 1 || entries[0].Tier != "background" { t.Fatalf("entries = %+v, want default tier background", entries) } } func TestNormalizeTierDefaultBackgroundUnknownBackground(t *testing.T) { if got, ok := normalizeTier(""); got != "background" || !ok { t.Fatalf("normalizeTier(empty) = %q, %v; want background, true", got, ok) } if got, ok := normalizeTier("absurd"); got != "background" || ok { t.Fatalf("normalizeTier(absurd) = %q, %v; want background, false", got, ok) } } func TestPlanMCPJSONSplitsPastedCommandLine(t *testing.T) { project := t.TempDir() home := t.TempDir() mcpPath := filepath.Join(t.TempDir(), ".mcp.json") writeFile(t, mcpPath, `{ "mcpServers": { "playwright": { "command": "npx -y @playwright/mcp" } } }`) tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": mcpPath, "kind": "mcp", }) if !resp.OK || len(resp.Actions) != 1 { t.Fatalf("response = %+v", resp) } a := resp.Actions[0] if a.Command != "npx" || len(a.Args) != 2 || a.Args[0] != "-y" || a.Args[1] != "@playwright/mcp" { t.Fatalf("action command/args = %q %v, want npx [-y @playwright/mcp]", a.Command, a.Args) } found := false for _, warning := range resp.Warnings { if strings.Contains(warning, "split a pasted MCP command line") { found = true } } if !found { t.Fatalf("warnings = %v, want split warning", resp.Warnings) } } func TestPlanMCPJSONRejectsInvalid(t *testing.T) { cases := []struct { name string body string }{ {"empty", `{"mcpServers": {}}`}, {"stdio no command", `{"mcpServers": {"x": {"type": "stdio"}}}`}, {"http no url", `{"mcpServers": {"x": {"type": "http"}}}`}, {"unknown transport", `{"mcpServers": {"x": {"type": "smoke", "command": "c"}}}`}, {"invalid name", `{"mcpServers": {"bad/name": {"command": "c"}}}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if _, _, err := parseMCPJSON([]byte(tc.body)); err == nil { t.Fatalf("expected error for %s", tc.name) } }) } } func TestApplyRemoteMCPURLConnectsAndPersists(t *testing.T) { project := t.TempDir() home := t.TempDir() stub := &stubConnector{toolCount: 3} tl := NewTool(Options{ ProjectRoot: project, HomeDir: home, ConnectMCP: stub.connector(), }) resp := execInstall(t, tl, map[string]any{ "source": "https://mcp.example.com/mcp", "kind": "mcp", "apply": true, "scope": "project", "name": "example", "headers": map[string]string{"Authorization": "Bearer ${TOKEN}"}, }) if !resp.OK || resp.Status != "done" || resp.Actions[0].ToolCount != 3 { t.Fatalf("response = %+v", resp) } if len(stub.connected) != 1 || stub.connected[0].Name != "example" { t.Fatalf("connected = %+v", stub.connected) } if resp.Actions[0].RiskLevel != RiskHigh { t.Errorf("auth headers should produce RiskHigh, got %q", resp.Actions[0].RiskLevel) } cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml")) if len(cfg.Plugins) != 1 || cfg.Plugins[0].Headers["Authorization"] != "Bearer ${TOKEN}" { t.Fatalf("plugins = %+v", cfg.Plugins) } } func TestApplyRemoteMCPURLDefaultsGlobal(t *testing.T) { project := t.TempDir() home := t.TempDir() stub := &stubConnector{toolCount: 1} tl := NewTool(Options{ ProjectRoot: project, HomeDir: home, ConnectMCP: stub.connector(), }) resp := execInstall(t, tl, map[string]any{ "source": "https://global.example.com/mcp", "kind": "mcp", "apply": true, "name": "global-default", }) if !resp.OK || resp.Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() { t.Fatalf("response = %+v, want global user config %q", resp, config.UserConfigPath()) } userCfg := config.LoadForEdit(config.UserConfigPath()) if p, ok := findPlugin(userCfg.Plugins, "global-default"); !ok || p.URL != "https://global.example.com/mcp" { t.Fatalf("global config plugins = %+v, want global-default", userCfg.Plugins) } projectCfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml")) if _, ok := findPlugin(projectCfg.Plugins, "global-default"); ok { t.Fatalf("project config should not receive default-global MCP: %+v", projectCfg.Plugins) } } func TestApplyMCPRejectsDuplicateByDefault(t *testing.T) { project := t.TempDir() home := t.TempDir() // Seed an existing entry the same way the first install would have. cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml")) if err := cfg.UpsertPlugin(config.PluginEntry{Name: "dup", Command: "x"}); err != nil { t.Fatal(err) } if err := cfg.SaveTo(filepath.Join(project, "reasonix.toml")); err != nil { t.Fatal(err) } stub := &stubConnector{toolCount: 1, failOnName: "dup"} tl := NewTool(Options{ProjectRoot: project, HomeDir: home, ConnectMCP: stub.connector()}) resp := execInstall(t, tl, map[string]any{ "source": "https://mcp.example.com/mcp", "kind": "mcp", "apply": true, "name": "dup", "scope": "project", }) if resp.OK { t.Fatalf("expected duplicate rejection, got %+v", resp) } if !strings.Contains(resp.Actions[0].Error, "already exists") { t.Errorf("expected ErrAlreadyExists text, got %q", resp.Actions[0].Error) } } func TestApplyMCPReplaceOverwritesExisting(t *testing.T) { project := t.TempDir() home := t.TempDir() cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml")) if err := cfg.UpsertPlugin(config.PluginEntry{Name: "editable", Command: "old"}); err != nil { t.Fatal(err) } if err := cfg.SaveTo(filepath.Join(project, "reasonix.toml")); err != nil { t.Fatal(err) } stub := &stubConnector{toolCount: 1} tl := NewTool(Options{ProjectRoot: project, HomeDir: home, ConnectMCP: stub.connector()}) resp := execInstall(t, tl, map[string]any{ "source": "https://mcp.example.com/mcp", "kind": "mcp", "apply": true, "replace": true, "name": "editable", "scope": "project", }) if !resp.OK { t.Fatalf("response = %+v", resp) } reloaded := config.LoadForEdit(filepath.Join(project, "reasonix.toml")) if reloaded.Plugins[0].Command != "" || reloaded.Plugins[0].URL != "https://mcp.example.com/mcp" { t.Errorf("replace did not update entry: %+v", reloaded.Plugins[0]) } } func TestApplyMCPReplaceDisconnectsLiveServerBeforeConnect(t *testing.T) { project := t.TempDir() home := t.TempDir() cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml")) if err := cfg.UpsertPlugin(config.PluginEntry{Name: "live", Command: "old"}); err != nil { t.Fatal(err) } if err := cfg.SaveTo(filepath.Join(project, "reasonix.toml")); err != nil { t.Fatal(err) } var liveConnected atomic.Bool liveConnected.Store(true) var disconnects atomic.Int32 var connects atomic.Int32 tl := NewTool(Options{ ProjectRoot: project, HomeDir: home, ConnectMCP: func(e config.PluginEntry) (MCPConnectResult, error) { if e.Name == "live" && liveConnected.Load() { return MCPConnectResult{}, errors.New(`server "live" is already connected`) } liveConnected.Store(true) connects.Add(1) return MCPConnectResult{ToolCount: 1, Disconnect: func() { liveConnected.Store(false) }}, nil }, OnDisconnect: func(name string) bool { if name != "live" || !liveConnected.Load() { return false } liveConnected.Store(false) disconnects.Add(1) return true }, }) resp := execInstall(t, tl, map[string]any{ "source": "https://mcp.example.com/mcp", "kind": "mcp", "apply": true, "replace": true, "name": "live", "scope": "project", }) if !resp.OK { t.Fatalf("replace should reconnect after disconnecting old live server: %+v", resp) } if disconnects.Load() != 1 || connects.Load() != 1 || !liveConnected.Load() { t.Fatalf("disconnects=%d connects=%d live=%v", disconnects.Load(), connects.Load(), liveConnected.Load()) } } func TestApplyMCPRollsBackOnSaveFailure(t *testing.T) { project := t.TempDir() home := t.TempDir() // Pre-create a directory at the config path so cfg.SaveTo will fail // (it cannot overwrite a non-empty directory with the file it wants). if err := os.MkdirAll(filepath.Join(project, "reasonix.toml"), 0o755); err != nil { t.Fatal(err) } writeFile(t, filepath.Join(project, "reasonix.toml", "blocker"), "x") var disconnects atomic.Int32 stub := &stubConnector{toolCount: 2, disconnectCalls: &disconnects} tl := NewTool(Options{ ProjectRoot: project, HomeDir: home, ConnectMCP: stub.connector(), OnDisconnect: func(string) bool { disconnects.Add(1) return true }, }) resp := execInstall(t, tl, map[string]any{ "source": "https://mcp.example.com/mcp", "kind": "mcp", "apply": true, "name": "ghost", "scope": "project", }) if resp.OK { t.Fatalf("expected failure, got %+v", resp) } if got := disconnects.Load(); got != 1 { t.Errorf("rollback expected to call the new connection Disconnect once, got %d", got) } } func TestApplyConnectFailureDoesNotPersist(t *testing.T) { project := t.TempDir() home := t.TempDir() stub := &stubConnector{failOnName: "broken"} tl := NewTool(Options{ProjectRoot: project, HomeDir: home, ConnectMCP: stub.connector()}) resp := execInstall(t, tl, map[string]any{ "source": "https://mcp.example.com/mcp", "kind": "mcp", "apply": true, "name": "broken", "scope": "project", }) if resp.OK { t.Fatalf("expected connect failure, got %+v", resp) } cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml")) if len(cfg.Plugins) != 0 { t.Errorf("no plugin should be persisted on connect failure, got %+v", cfg.Plugins) } } func TestPackageActionUsesNpx(t *testing.T) { project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": "@example/mcp-pkg", "kind": "mcp", }) if !resp.OK || len(resp.Actions) != 1 { t.Fatalf("response = %+v", resp) } if resp.Actions[0].Command != "npx" { t.Errorf("command = %q, want npx", resp.Actions[0].Command) } if len(resp.Actions[0].Args) != 2 || resp.Actions[0].Args[0] != "-y" || resp.Actions[0].Args[1] != "@example/mcp-pkg" { t.Errorf("args = %v, want [-y @example/mcp-pkg]", resp.Actions[0].Args) } if resp.Actions[0].Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() { t.Errorf("scope/path = %q %q, want global %q", resp.Actions[0].Scope, resp.Actions[0].ConfigPath, config.UserConfigPath()) } } func TestPlanURLBlobRewritesToRaw(t *testing.T) { got := rawGitHubBlobURL("https://github.com/foo/bar/blob/main/path/SKILL.md") want := "https://raw.githubusercontent.com/foo/bar/main/path/SKILL.md" if got != want { t.Errorf("rawGitHubBlobURL = %q, want %q", got, want) } } func TestPlanURLRemoteEndpointAuto(t *testing.T) { project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": "https://mcp.example.com/mcp", }) if !resp.OK || len(resp.Actions) != 1 { t.Fatalf("response = %+v", resp) } if resp.Actions[0].Transport != "http" { t.Errorf("transport = %q, want http", resp.Actions[0].Transport) } if resp.Actions[0].Scope != "global" || resp.Actions[0].ConfigPath != config.UserConfigPath() { t.Errorf("scope/path = %q %q, want global %q", resp.Actions[0].Scope, resp.Actions[0].ConfigPath, config.UserConfigPath()) } } func TestPlanURLRemoteMCPHostAuto(t *testing.T) { project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": "https://mcp.stripe.com", }) if !resp.OK || len(resp.Actions) != 1 { t.Fatalf("response = %+v", resp) } if resp.Actions[0].Name != "stripe" || resp.Actions[0].Transport != "http" { t.Errorf("action = %+v, want stripe http", resp.Actions[0]) } } func TestPlanURLSSEDefault(t *testing.T) { project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": "https://example.com/sse/stream", }) if !resp.OK { t.Fatalf("response = %+v", resp) } if resp.Actions[0].Transport != "sse" { t.Errorf("transport = %q, want sse (URL contains 'sse')", resp.Actions[0].Transport) } } func TestPlanUnsupportedKindReturnsTypedError(t *testing.T) { project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) raw, _ := json.Marshal(map[string]any{ "source": "https://example.com/sse/stream", "kind": "skill", }) out, err := tl.Execute(context.Background(), raw) if err == nil { t.Fatalf("expected typed error, got %s", out) } if !errors.Is(err, ErrUnsupportedKind) { t.Errorf("expected ErrUnsupportedKind, got %v", err) } } func TestPlanGitHubRepoProbesMainAndMaster(t *testing.T) { // We can't easily stand up a fake github.com, so we exercise the URL // rewriting and probe selection separately. The hostname check is // `strings.EqualFold(u.Hostname(), "github.com")` — anything else is // treated as a remote URL, not a repo probe. if got := rawGitHubBlobURL("https://github.com/foo/bar/blob/main/path/SKILL.md"); got != "https://raw.githubusercontent.com/foo/bar/main/path/SKILL.md" { t.Errorf("blob rewrite = %q", got) } if got := rawGitHubBlobURL("https://github.com/foo/bar/raw/main/path/SKILL.md"); got != "https://raw.githubusercontent.com/foo/bar/main/path/SKILL.md" { t.Errorf("raw rewrite = %q", got) } if got := rawGitHubBlobURL("https://example.com/foo/bar"); got != "https://example.com/foo/bar" { t.Errorf("non-github passthrough = %q", got) } } func TestPlanGitHubRepoDiscoversMultipleSkills(t *testing.T) { project := t.TempDir() home := t.TempDir() var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/repos/foo/bar/contents": if r.URL.Query().Get("ref") != "main" { t.Fatalf("ref = %q, want main", r.URL.Query().Get("ref")) } _, _ = fmt.Fprintf(w, `[ {"name":"skills","path":"skills","type":"dir"} ]`) case "/repos/foo/bar/contents/skills": _, _ = fmt.Fprintf(w, `[ {"name":"gsap-core","path":"skills/gsap-core","type":"dir"}, {"name":"gsap-timeline","path":"skills/gsap-timeline","type":"dir"} ]`) case "/repos/foo/bar/contents/skills/gsap-core": _, _ = fmt.Fprintf(w, `[ {"name":"SKILL.md","path":"skills/gsap-core/SKILL.md","type":"file","download_url":%q} ]`, srv.URL+"/raw/gsap-core/SKILL.md") case "/repos/foo/bar/contents/skills/gsap-timeline": _, _ = fmt.Fprintf(w, `[ {"name":"SKILL.md","path":"skills/gsap-timeline/SKILL.md","type":"file","download_url":%q} ]`, srv.URL+"/raw/gsap-timeline/SKILL.md") case "/raw/gsap-core/SKILL.md": _, _ = w.Write([]byte("---\nname: gsap-core\ndescription: GSAP core helper\n---\ncore body")) case "/raw/gsap-timeline/SKILL.md": _, _ = w.Write([]byte("---\nname: gsap-timeline\ndescription: GSAP timeline helper\n---\ntimeline body")) default: http.NotFound(w, r) } })) defer srv.Close() oldAPIBase := githubAPIBaseURL githubAPIBaseURL = srv.URL defer func() { githubAPIBaseURL = oldAPIBase }() tl := NewTool(Options{ProjectRoot: project, HomeDir: home, HTTPClient: srv.Client()}) resp := execInstall(t, tl, map[string]any{ "source": "https://github.com/foo/bar", "kind": "skill", }) if !resp.OK || resp.Status != "planned" { t.Fatalf("response = %+v", resp) } if len(resp.Actions) != 2 { t.Fatalf("actions = %+v, want two skills", resp.Actions) } if resp.Actions[0].Name != "gsap-core" || resp.Actions[1].Name != "gsap-timeline" { t.Fatalf("actions = %+v", resp.Actions) } for _, action := range resp.Actions { wantSuffix := filepath.Join(action.Name, skill.SkillFile) if action.Layout != "canonical_dir" || !strings.HasSuffix(action.CanonicalPath, wantSuffix) { t.Fatalf("action = %+v, want canonical layout ending in %s", action, wantSuffix) } } } func TestFetchTextAppliesTimeoutAndUA(t *testing.T) { // Use a context with a tiny deadline to assert timeout behavior. We // can't easily test the UA from inside a HandlerFunc, so we just check // that a cancelled context propagates as ErrSourceUnreadable. project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}).(*installSourceTool) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := tl.fetchText(ctx, "http://example.invalid") if !errors.Is(err, ErrSourceUnreadable) { t.Errorf("expected ErrSourceUnreadable, got %v", err) } } func TestGlobalSkillInstallRootUsesReasonixHome(t *testing.T) { home := t.TempDir() reasonixHome := filepath.Join(t.TempDir(), "rx-home") t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("REASONIX_HOME", reasonixHome) oldUserHomeDir := userHomeDir userHomeDir = func() (string, error) { return home, nil } t.Cleanup(func() { userHomeDir = oldUserHomeDir }) tl := NewTool(Options{ProjectRoot: t.TempDir()}).(*installSourceTool) root, err := tl.skillInstallRoot("global") if err != nil { t.Fatalf("skillInstallRoot: %v", err) } want := filepath.Join(reasonixHome, skill.SkillsDirname) if root != want { t.Fatalf("global skill root = %q, want %q", root, want) } } func TestFetchTextAuthMapsToErrAuthRequired(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer srv.Close() project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home, HTTPClient: srv.Client()}).(*installSourceTool) _, err := tl.fetchText(context.Background(), srv.URL) if !errors.Is(err, ErrAuthRequired) { t.Errorf("expected ErrAuthRequired, got %v", err) } } func TestFetchTextRefusesInternalAddress(t *testing.T) { // SSRF guard: an install source pointed at cloud-metadata / internal IPs must // be refused at dial time, not fetched. These are IP literals so no real // network or DNS is involved — the guard blocks before connecting. tl := NewTool(Options{ProjectRoot: t.TempDir(), HomeDir: t.TempDir()}).(*installSourceTool) for _, target := range []string{ "http://169.254.169.254/latest/meta-data/", // cloud metadata "http://10.0.0.1/", // RFC1918 internal } { if _, err := tl.fetchText(context.Background(), target); !errors.Is(err, ErrSourceUnreadable) { t.Errorf("fetchText(%q) err = %v, want ErrSourceUnreadable (SSRF-refused)", target, err) } } } func TestPlanMarkdownSkillURL(t *testing.T) { project := t.TempDir() home := t.TempDir() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("---\nname: remote-skill\ndescription: Remote helper\n---\nUse the remote helper.")) })) defer srv.Close() tl := NewTool(Options{ProjectRoot: project, HomeDir: home, HTTPClient: srv.Client()}) resp := execInstall(t, tl, map[string]any{ "source": srv.URL + "/SKILL.md", }) if !resp.OK || resp.Status != "planned" { t.Fatalf("response = %+v", resp) } if len(resp.Actions) != 1 || resp.Actions[0].Kind != "skill" || resp.Actions[0].Name != "remote-skill" { t.Fatalf("actions = %+v", resp.Actions) } } // --- uninstall -------------------------------------------------------------- func TestUninstallRemovesSkillByName(t *testing.T) { project := t.TempDir() home := t.TempDir() target := filepath.Join(project, ".reasonix", "skills", "doomed.md") writeFile(t, target, "---\nname: doomed\ndescription: Doomed\n---\nbody") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "op": "uninstall", "name": "doomed", "scope": "project", }) if !resp.OK || resp.Status != "done" { t.Fatalf("response = %+v", resp) } if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) { t.Errorf("skill file should be gone, lstat err = %v", err) } } func TestUninstallRemovesRegisteredSkillRootByContainedSkillName(t *testing.T) { project := t.TempDir() home := t.TempDir() root := filepath.Join(t.TempDir(), "shared-skills") writeFile(t, filepath.Join(root, "alpha.md"), "---\nname: alpha\ndescription: Alpha helper\n---\nbody") writeFile(t, filepath.Join(root, "beta.md"), "---\nname: beta\ndescription: Beta helper\n---\nbody") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) install := execInstall(t, tl, map[string]any{ "source": root, "kind": "skill", "apply": true, "scope": "project", }) if !install.OK { t.Fatalf("install response = %+v", install) } resp := execInstall(t, tl, map[string]any{ "op": "uninstall", "name": "alpha", "scope": "project", }) if !resp.OK || resp.Actions[0].Action != "remove_skill_root" { t.Fatalf("uninstall response = %+v", resp) } if resp.Actions[0].SkillCount != 2 { t.Errorf("SkillCount = %d, want 2", resp.Actions[0].SkillCount) } cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml")) if len(cfg.Skills.Paths) != 0 { t.Fatalf("skills.paths should be empty after root uninstall, got %v", cfg.Skills.Paths) } } func TestUninstallRemovesMCPAndDisconnects(t *testing.T) { project := t.TempDir() home := t.TempDir() cfg := config.LoadForEdit(filepath.Join(project, "reasonix.toml")) if err := cfg.UpsertPlugin(config.PluginEntry{Name: "ed", Type: "http", URL: "https://mcp.example.com/mcp"}); err != nil { t.Fatal(err) } if err := cfg.SaveTo(filepath.Join(project, "reasonix.toml")); err != nil { t.Fatal(err) } var disconnects atomic.Int32 tl := NewTool(Options{ ProjectRoot: project, HomeDir: home, OnDisconnect: func(string) bool { disconnects.Add(1) return true }, }) resp := execInstall(t, tl, map[string]any{ "op": "uninstall", "name": "ed", "scope": "project", }) if !resp.OK { t.Fatalf("response = %+v", resp) } if disconnects.Load() != 1 { t.Errorf("OnDisconnect should fire once, got %d", disconnects.Load()) } reloaded := config.LoadForEdit(filepath.Join(project, "reasonix.toml")) if len(reloaded.Plugins) != 0 { t.Errorf("plugin should be removed, got %+v", reloaded.Plugins) } } func TestUninstallWithoutScopePrefersProjectSkill(t *testing.T) { project := t.TempDir() home := t.TempDir() projectTarget := filepath.Join(project, ".reasonix", "skills", "dupe.md") globalTarget := filepath.Join(home, ".reasonix", "skills", "dupe.md") writeFile(t, projectTarget, "---\nname: dupe\ndescription: Project\n---\nbody") writeFile(t, globalTarget, "---\nname: dupe\ndescription: Global\n---\nbody") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "op": "uninstall", "name": "dupe", }) if !resp.OK || resp.Scope != "project" { t.Fatalf("response = %+v, want project uninstall", resp) } if _, err := os.Lstat(projectTarget); !errors.Is(err, os.ErrNotExist) { t.Errorf("project skill should be gone, lstat err = %v", err) } if _, err := os.Lstat(globalTarget); err != nil { t.Errorf("global skill should remain when project matched first, lstat err = %v", err) } } func TestUninstallWithoutScopeFallsBackToGlobalMCP(t *testing.T) { project := t.TempDir() home := t.TempDir() name := "global-fallback" cfg := config.LoadForEdit(config.UserConfigPath()) if err := cfg.UpsertPlugin(config.PluginEntry{Name: name, Type: "http", URL: "https://global.example.com/mcp"}); err != nil { t.Fatal(err) } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatal(err) } t.Cleanup(func() { cleanup := config.LoadForEdit(config.UserConfigPath()) if cleanup.RemovePlugin(name) { _ = cleanup.SaveTo(config.UserConfigPath()) } }) var disconnects atomic.Int32 tl := NewTool(Options{ ProjectRoot: project, HomeDir: home, OnDisconnect: func(string) bool { disconnects.Add(1) return true }, }) resp := execInstall(t, tl, map[string]any{ "op": "uninstall", "name": name, }) if !resp.OK || resp.Scope != "global" { t.Fatalf("response = %+v, want global fallback uninstall", resp) } if disconnects.Load() != 1 { t.Errorf("OnDisconnect should fire once, got %d", disconnects.Load()) } reloaded := config.LoadForEdit(config.UserConfigPath()) if _, ok := findPlugin(reloaded.Plugins, name); ok { t.Fatalf("global MCP should be removed, got %+v", reloaded.Plugins) } } func TestUninstallUnknownNameIsBlocked(t *testing.T) { project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "op": "uninstall", "name": "ghost", }) if resp.OK || resp.Status != "blocked" { t.Fatalf("response = %+v", resp) } } func TestUninstallRequiresName(t *testing.T) { project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) raw, _ := json.Marshal(map[string]any{"op": "uninstall"}) if _, err := tl.Execute(context.Background(), raw); err == nil { t.Fatal("expected error when name is missing for uninstall") } } // --- approval hook ---------------------------------------------------------- func TestApprovalHookDeniesApply(t *testing.T) { project := t.TempDir() home := t.TempDir() src := filepath.Join(t.TempDir(), "zeta.md") writeFile(t, src, "---\nname: zeta\ndescription: Zeta helper\n---\nbody") var seen []action tl := NewTool(Options{ ProjectRoot: project, HomeDir: home, Approval: func(actions []action) error { seen = actions return errors.New("user said no") }, }) resp := execInstall(t, tl, map[string]any{ "source": src, "kind": "skill", "apply": true, }) if resp.OK { t.Fatalf("expected denial, got %+v", resp) } if resp.Status != "denied" { t.Errorf("status = %q, want denied", resp.Status) } if len(seen) != 1 || seen[0].Name != "zeta" { t.Errorf("approval should see the planned actions, got %+v", seen) } } func TestPlanIDMismatchRefusesApply(t *testing.T) { project := t.TempDir() home := t.TempDir() src := filepath.Join(t.TempDir(), "eta.md") writeFile(t, src, "---\nname: eta\ndescription: Eta helper\n---\nbody") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) raw, _ := json.Marshal(map[string]any{ "source": src, "kind": "skill", "apply": true, "planId": "sha256:00000000000000000000000000000000", }) out, err := tl.Execute(context.Background(), raw) if err == nil { t.Fatalf("expected planId mismatch error, got %s", out) } if !errors.Is(err, ErrApprovalDenied) { t.Errorf("expected ErrApprovalDenied, got %v", err) } } func TestPlanIDIncludesActionDetails(t *testing.T) { req := request{ Op: "install", Source: "/tmp/example/.mcp.json", Kind: "mcp", Scope: "project", Mode: "auto", } a := action{Kind: "mcp", Action: "install_mcp_server", Name: "same", URL: "https://mcp.one.example/mcp", Transport: "http", ConfigPath: "/repo/reasonix.toml"} b := a b.URL = "https://mcp.two.example/mcp" if computePlanID(req, []action{a}) == computePlanID(req, []action{b}) { t.Fatal("planId should change when action URL changes") } } // --- sanitizers / parsers --------------------------------------------------- func TestSanitizeNameEdges(t *testing.T) { cases := map[string]string{ "": "mcp", " ": "mcp", "@@leading": "leading", // leading @ is stripped before the prefix rule "_underscore": "underscore", // config requires [a-zA-Z0-9] as first char "foo bar baz": "foo-bar-baz", "foo/bar": "foo-bar", "FOO": "foo", "a.b-c_d": "a.b-c_d", strings.Repeat("x", 100): strings.Repeat("x", 64), } for in, want := range cases { if got := sanitizeName(in); got != want { t.Errorf("sanitizeName(%q) = %q, want %q", in, got, want) } } } func TestMCPNameFromURL(t *testing.T) { cases := map[string]string{ "https://mcp.stripe.com/mcp": "stripe", "https://api.example.com/mcp": "example", "https://www.foo.com/mcp": "foo", "http://localhost:3000/mcp": "local-3000", "https://mcp.example.co.uk/agent": "example", "https://api.mcp.openai.com/v1/mcp": "openai", } for in, want := range cases { if got := mcpNameFromURL(in); got != want { t.Errorf("mcpNameFromURL(%q) = %q, want %q", in, got, want) } } } func TestValidateMCPEntry(t *testing.T) { if err := validateMCPEntry(config.PluginEntry{Name: "x", Command: "y"}); err != nil { t.Errorf("stdio with command should validate, got %v", err) } if err := validateMCPEntry(config.PluginEntry{Name: "x", Type: "http", URL: "http://x"}); err != nil { t.Errorf("http with url should validate, got %v", err) } if err := validateMCPEntry(config.PluginEntry{Name: ""}); err == nil { t.Error("empty name should fail") } if err := validateMCPEntry(config.PluginEntry{Name: "bad/name", Command: "y"}); err == nil { t.Error("invalid name should fail") } if err := validateMCPEntry(config.PluginEntry{Name: "x", Type: "carrier-pigeon", Command: "c"}); err == nil { t.Error("unknown transport should fail") } } func TestComputePlanIDStable(t *testing.T) { req := request{Op: "install", Source: "x", Scope: "project", Kind: "skill"} actions := []action{{Kind: "skill", Name: "a", Action: "copy_skill"}} id1 := computePlanID(req, actions) id2 := computePlanID(req, actions) if id1 != id2 { t.Errorf("planId should be stable, got %q vs %q", id1, id2) } id3 := computePlanID(request{Op: "install", Source: "y", Scope: "project", Kind: "skill"}, actions) if id3 == id1 { t.Errorf("planId should change with source") } } func TestPlanIDUsesResolvedActionScope(t *testing.T) { reqOmitted := request{Op: "install", Source: "https://mcp.example.com/mcp", Kind: "mcp"} reqGlobal := request{Op: "install", Source: "https://mcp.example.com/mcp", Kind: "mcp", Scope: "global", scopeExplicit: true} actions := []action{{ Kind: "mcp", Action: "install_mcp_server", Name: "example", URL: "https://mcp.example.com/mcp", Transport: "http", Scope: "global", ConfigPath: config.UserConfigPath(), }} if got, want := computePlanID(reqOmitted, actions), computePlanID(reqGlobal, actions); got != want { t.Fatalf("planId with omitted scope = %q, explicit global = %q; want same resolved plan", got, want) } } // --- local executable ------------------------------------------------------- func TestPlanLocalExecutableDetected(t *testing.T) { project := t.TempDir() home := t.TempDir() bin := filepath.Join(t.TempDir(), "bin") if err := os.MkdirAll(bin, 0o755); err != nil { t.Fatal(err) } exe := writeLocalExecutable(t, bin, "mcp-x") tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) resp := execInstall(t, tl, map[string]any{ "source": exe, "kind": "mcp", }) if !resp.OK || len(resp.Actions) != 1 { t.Fatalf("response = %+v", resp) } if resp.Actions[0].Command != exe { t.Errorf("command = %q, want the executable path", resp.Actions[0].Command) } } func TestApplyLocalExecutableHonorsCommandOverride(t *testing.T) { project := t.TempDir() home := t.TempDir() bin := filepath.Join(t.TempDir(), "bin") if err := os.MkdirAll(bin, 0o755); err != nil { t.Fatal(err) } server := writeLocalExecutable(t, bin, "server") stub := &stubConnector{toolCount: 1} tl := NewTool(Options{ProjectRoot: project, HomeDir: home, ConnectMCP: stub.connector()}) resp := execInstall(t, tl, map[string]any{ "source": server, "kind": "mcp", "apply": true, "name": "wrapped", "command": "node", "args": []string{server}, }) if !resp.OK || len(resp.Actions) != 1 { t.Fatalf("response = %+v", resp) } if resp.Actions[0].Command != "node" || len(resp.Actions[0].Args) != 1 || resp.Actions[0].Args[0] != server { t.Fatalf("action command/args = %q %v, want node [%s]", resp.Actions[0].Command, resp.Actions[0].Args, server) } if len(stub.connected) != 1 || stub.connected[0].Command != "node" || len(stub.connected[0].Args) != 1 || stub.connected[0].Args[0] != server { t.Fatalf("connected entry = %+v, want node [%s]", stub.connected, server) } cfg := config.LoadForEdit(config.UserConfigPath()) p, ok := findPlugin(cfg.Plugins, "wrapped") if !ok || p.Command != "node" || len(p.Args) != 1 || p.Args[0] != server { t.Fatalf("persisted plugins = %+v, want wrapped node [%s]", cfg.Plugins, server) } } func findPlugin(entries []config.PluginEntry, name string) (config.PluginEntry, bool) { for _, entry := range entries { if entry.Name == name { return entry, true } } return config.PluginEntry{}, false } func writeLocalExecutable(t *testing.T, dir, name string) string { t.Helper() if runtime.GOOS == "windows" { path := filepath.Join(dir, name+".cmd") writeFile(t, path, "@echo off\r\nexit /b 0\r\n") return path } path := filepath.Join(dir, name) writeFile(t, path, "#!/bin/sh\nexit 0\n") if err := os.Chmod(path, 0o755); err != nil { t.Fatal(err) } return path } // --- plan-only: RiskLevel surfacing ----------------------------------------- func TestLinkRiskIsMedium(t *testing.T) { if level, _ := skillActionRisk("link", skillCandidate{SourcePath: "x"}); level != RiskMedium { t.Errorf("link mode should be RiskMedium, got %q", level) } if level, _ := skillActionRisk("copy", skillCandidate{SourcePath: "x"}); level != RiskLow { t.Errorf("copy mode should be RiskLow, got %q", level) } } func TestEagerTierEscalatesRisk(t *testing.T) { level, _ := mcpActionRisk(config.PluginEntry{Name: "x", Tier: "eager", URL: "http://x"}, nil) if level != RiskHigh { t.Errorf("eager tier should escalate to RiskHigh, got %q", level) } } // --- helpers ---------------------------------------------------------------- // ExampleNewTool is a godoc example that exercises the public surface // without touching the filesystem. It also serves as smoke coverage that // the Schema() output is valid JSON and the tool does not panic on a // well-formed call. func ExampleNewTool() { tl := NewTool(Options{ ProjectRoot: "/tmp/example", HomeDir: "/tmp/example-home", }) raw, _ := json.Marshal(map[string]any{"source": "https://example.com/mcp"}) out, _ := tl.Execute(context.Background(), raw) var resp response _ = json.Unmarshal([]byte(out), &resp) fmt.Printf("status=%s kind=%s skill=%d mcp=%d\n", resp.Status, resp.Kind, resp.Kinds.Skill, resp.Kinds.MCP) // Output: status=planned kind=mcp skill=0 mcp=1 } // TestGitHubPluginPlanMatchesApply pins the approval contract: the plan the // user approves must describe exactly the capability set apply installs. Both // phases resolve the source through pluginSource, so convention-discovered // capabilities (skills/, commands/ — including nested namespaces) appear in // the plan, not only after installation. func TestGitHubPluginPlanMatchesApply(t *testing.T) { src := t.TempDir() writeFile(t, filepath.Join(src, ".claude-plugin", "plugin.json"), `{"name": "pwf", "version": "1.0.0"}`) writeFile(t, filepath.Join(src, "skills", "planner", "SKILL.md"), "---\ndescription: planner\n---\nbody") writeFile(t, filepath.Join(src, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan: $ARGUMENTS") writeFile(t, filepath.Join(src, "commands", "git", "commit.md"), "---\ndescription: commit\n---\nCommit") project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) tool := tl.(*installSourceTool) tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) { return src, "cafe0001", func() {}, nil } plan := execInstall(t, tl, map[string]any{ "source": "https://github.com/acme/pwf", "kind": "plugin", }) if !plan.OK || plan.Status != "planned" || len(plan.Actions) != 1 { t.Fatalf("plan response = %+v", plan) } planned := plan.Actions[0] if planned.SkillCount != 1 || planned.CommandCount != 2 { t.Fatalf("planned counts = %d skills / %d commands, want 1/2 (plan must see convention dirs)", planned.SkillCount, planned.CommandCount) } applied := execInstall(t, tl, map[string]any{ "source": "https://github.com/acme/pwf", "kind": "plugin", "apply": true, }) if !applied.OK || applied.Status != "done" || len(applied.Actions) != 1 { t.Fatalf("apply response = %+v", applied) } got := applied.Actions[0] if got.SkillCount != planned.SkillCount || got.CommandCount != planned.CommandCount || got.HookCount != planned.HookCount || got.ToolCount != planned.ToolCount { t.Fatalf("apply counts (%d/%d/%d/%d) diverge from approved plan (%d/%d/%d/%d)", got.SkillCount, got.CommandCount, got.HookCount, got.ToolCount, planned.SkillCount, planned.CommandCount, planned.HookCount, planned.ToolCount) } } // TestGitHubPluginApplyRefusesUnpinnableDrift pins the snapshot contract: when // the source resolves to a different commit than the plan approved and the // approved snapshot cannot be restored, apply must refuse instead of // installing content the approval never covered. func TestGitHubPluginApplyRefusesUnpinnableDrift(t *testing.T) { tree1 := t.TempDir() writeFile(t, filepath.Join(tree1, ".claude-plugin", "plugin.json"), `{"name": "pwf", "version": "1.0.0"}`) writeFile(t, filepath.Join(tree1, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan") tree2 := t.TempDir() writeFile(t, filepath.Join(tree2, ".claude-plugin", "plugin.json"), `{"name": "pwf", "version": "1.0.1"}`) writeFile(t, filepath.Join(tree2, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan") writeFile(t, filepath.Join(tree2, "commands", "extra.md"), "---\ndescription: extra\n---\nExtra") project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) tool := tl.(*installSourceTool) calls := 0 tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) { calls++ if calls == 1 { return tree1, "cafe0001", func() {}, nil // plan recompute inside the apply call } return tree2, "cafe0002", func() {}, nil // apply resolution: source moved } resp := execInstall(t, tl, map[string]any{ "source": "https://github.com/acme/pwf", "kind": "plugin", "apply": true, }) if resp.OK || resp.Status != "failed" { t.Fatalf("response = %+v, want a failed apply when the source drifted past the approved commit", resp) } if len(resp.Actions) != 1 || resp.Actions[0].Status != "failed" { t.Fatalf("actions = %+v, want the single install action failed", resp.Actions) } if !strings.Contains(resp.Actions[0].Error, "approved commit cafe0001") { t.Fatalf("action error = %q, want the approved-commit drift refusal", resp.Actions[0].Error) } if _, ok, _ := pluginpkg.FindInstalled(filepath.Join(home, ".reasonix"), "pwf"); ok { t.Fatal("drifted plugin must not be installed") } } // TestCopyMaterializesInRootSymlinkedCommands pins that a command alias // symlinked to a file inside the package survives copy-mode installs: the // installed tree must resolve to the same capability set the plan counted. func TestCopyMaterializesInRootSymlinkedCommands(t *testing.T) { src := t.TempDir() writeFile(t, filepath.Join(src, ".claude-plugin", "plugin.json"), `{"name": "aliases"}`) writeFile(t, filepath.Join(src, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nbody") writeFile(t, filepath.Join(src, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan") if err := os.Symlink(filepath.Join(src, "commands", "plan.md"), filepath.Join(src, "commands", "pwf.md")); err != nil { t.Skipf("symlinks unavailable on this platform: %v", err) } project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) tool := tl.(*installSourceTool) tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) { return src, "cafe0001", func() {}, nil } resp := execInstall(t, tl, map[string]any{ "source": "https://github.com/acme/aliases", "kind": "plugin", "apply": true, }) if !resp.OK || resp.Status != "done" || len(resp.Actions) != 1 { t.Fatalf("response = %+v", resp) } if resp.Actions[0].CommandCount != 2 { t.Fatalf("planned commands = %d, want 2 (alias followed)", resp.Actions[0].CommandCount) } installedRoot := filepath.Join(home, ".reasonix", "plugins", "aliases") pkg, _, err := pluginpkg.ParseDir(installedRoot) if err != nil { t.Fatalf("ParseDir installed: %v", err) } if _, commands, _, _ := pkg.CapabilityCounts(); commands != 2 { t.Fatalf("installed commands = %d, want the symlinked alias materialized", commands) } } // TestCopyRefusesUnmaterializableSymlinkCommands pins the fail-closed path: a // command symlinked to a file OUTSIDE the package counts during planning but // cannot be materialized by copy mode, so apply must refuse (and clean up) // rather than silently install fewer commands than approved. func TestCopyRefusesUnmaterializableSymlinkCommands(t *testing.T) { outside := t.TempDir() writeFile(t, filepath.Join(outside, "evil.md"), "---\ndescription: evil\n---\nEvil") src := t.TempDir() writeFile(t, filepath.Join(src, ".claude-plugin", "plugin.json"), `{"name": "escapes"}`) writeFile(t, filepath.Join(src, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan") if err := os.Symlink(filepath.Join(outside, "evil.md"), filepath.Join(src, "commands", "evil.md")); err != nil { t.Skipf("symlinks unavailable on this platform: %v", err) } project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) tool := tl.(*installSourceTool) tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) { return src, "cafe0001", func() {}, nil } resp := execInstall(t, tl, map[string]any{ "source": "https://github.com/acme/escapes", "kind": "plugin", "apply": true, }) if resp.OK || resp.Status != "failed" || len(resp.Actions) != 1 || resp.Actions[0].Status != "failed" { t.Fatalf("response = %+v, want a failed apply for the unmaterializable symlink", resp) } if !strings.Contains(resp.Actions[0].Error, "approved plan counted") { t.Fatalf("action error = %q, want the capability-verification refusal", resp.Actions[0].Error) } if _, err := os.Stat(filepath.Join(home, ".reasonix", "plugins", "escapes")); !os.IsNotExist(err) { t.Fatal("failed install must not leave the copied tree behind") } if _, ok, _ := pluginpkg.FindInstalled(filepath.Join(home, ".reasonix"), "escapes"); ok { t.Fatal("failed install must not be registered") } } // TestFailedReplaceKeepsExistingPluginInstall pins the update-safety contract: // when a replace=true update fails capability verification (e.g. the new // version ships an unmaterializable symlink), the previously installed // version must survive on disk and stay registered — a failed update may // never leave an enabled plugin pointing at a missing or gutted root. func TestFailedReplaceKeepsExistingPluginInstall(t *testing.T) { v1 := t.TempDir() writeFile(t, filepath.Join(v1, ".claude-plugin", "plugin.json"), `{"name": "pwf", "version": "1.0.0"}`) writeFile(t, filepath.Join(v1, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan") outside := t.TempDir() writeFile(t, filepath.Join(outside, "evil.md"), "---\ndescription: evil\n---\nEvil") v2 := t.TempDir() writeFile(t, filepath.Join(v2, ".claude-plugin", "plugin.json"), `{"name": "pwf", "version": "2.0.0"}`) writeFile(t, filepath.Join(v2, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan") if err := os.Symlink(filepath.Join(outside, "evil.md"), filepath.Join(v2, "commands", "evil.md")); err != nil { t.Skipf("symlinks unavailable on this platform: %v", err) } project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) tool := tl.(*installSourceTool) current, commit := v1, "cafe0001" tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) { return current, commit, func() {}, nil } install := execInstall(t, tl, map[string]any{ "source": "https://github.com/acme/pwf", "kind": "plugin", "apply": true, }) if !install.OK || install.Status != "done" { t.Fatalf("initial install = %+v", install) } current, commit = v2, "cafe0002" update := execInstall(t, tl, map[string]any{ "source": "https://github.com/acme/pwf", "kind": "plugin", "apply": true, "replace": true, }) if update.OK || update.Status != "failed" { t.Fatalf("update = %+v, want a failed apply for the unmaterializable symlink", update) } installedRoot := filepath.Join(home, ".reasonix", "plugins", "pwf") if _, err := os.Stat(filepath.Join(installedRoot, "commands", "plan.md")); err != nil { t.Fatalf("previous install must survive a failed update: %v", err) } pkg, _, err := pluginpkg.ParseDir(installedRoot) if err != nil { t.Fatalf("ParseDir installed: %v", err) } if pkg.Manifest.Version != "1.0.0" { t.Fatalf("installed version = %q, want the previous 1.0.0 kept", pkg.Manifest.Version) } if p, ok, _ := pluginpkg.FindInstalled(filepath.Join(home, ".reasonix"), "pwf"); !ok || !p.Enabled { t.Fatal("previous registration must survive a failed update") } if _, err := os.Stat(installedRoot + ".pre-replace"); !os.IsNotExist(err) { t.Fatal("failed update must not leave a backup tree behind") } entries, err := os.ReadDir(filepath.Dir(installedRoot)) if err != nil { t.Fatal(err) } for _, e := range entries { if strings.Contains(e.Name(), ".staging-") { t.Fatalf("failed update must not leave staging dir %q behind", e.Name()) } } } // TestBackupPathCannotCollideWithSiblingPlugin pins the backup-naming // contract: plugin names may legally contain dots, so a plugin literally // named "foo.pre-replace" must survive an update of plugin "foo" — the swap // backup must use a name no valid plugin can occupy. func TestBackupPathCannotCollideWithSiblingPlugin(t *testing.T) { fooV1 := t.TempDir() writeFile(t, filepath.Join(fooV1, ".claude-plugin", "plugin.json"), `{"name": "foo", "version": "1.0.0"}`) writeFile(t, filepath.Join(fooV1, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan") fooV2 := t.TempDir() writeFile(t, filepath.Join(fooV2, ".claude-plugin", "plugin.json"), `{"name": "foo", "version": "2.0.0"}`) writeFile(t, filepath.Join(fooV2, "commands", "plan.md"), "---\ndescription: plan\n---\nPlan v2") sibling := t.TempDir() writeFile(t, filepath.Join(sibling, ".claude-plugin", "plugin.json"), `{"name": "foo.pre-replace", "version": "1.0.0"}`) writeFile(t, filepath.Join(sibling, "commands", "keep.md"), "---\ndescription: keep\n---\nKeep") project := t.TempDir() home := t.TempDir() tl := NewTool(Options{ProjectRoot: project, HomeDir: home}) tool := tl.(*installSourceTool) sources := map[string]string{ "https://github.com/acme/foo": fooV1, "https://github.com/acme/sibling": sibling, } tool.preparePlugin = func(ctx context.Context, source, mode string) (string, string, func(), error) { return sources[source], "cafe-" + source, func() {}, nil } for _, source := range []string{"https://github.com/acme/foo", "https://github.com/acme/sibling"} { resp := execInstall(t, tl, map[string]any{"source": source, "kind": "plugin", "apply": true}) if !resp.OK || resp.Status != "done" { t.Fatalf("install %s = %+v", source, resp) } } sources["https://github.com/acme/foo"] = fooV2 update := execInstall(t, tl, map[string]any{ "source": "https://github.com/acme/foo", "kind": "plugin", "apply": true, "replace": true, }) if !update.OK || update.Status != "done" { t.Fatalf("update = %+v", update) } siblingRoot := filepath.Join(home, ".reasonix", "plugins", "foo.pre-replace") if _, err := os.Stat(filepath.Join(siblingRoot, "commands", "keep.md")); err != nil { t.Fatalf("sibling plugin's files must survive the update of foo: %v", err) } if _, ok, _ := pluginpkg.FindInstalled(filepath.Join(home, ".reasonix"), "foo.pre-replace"); !ok { t.Fatal("sibling plugin must stay registered") } pkg, _, err := pluginpkg.ParseDir(filepath.Join(home, ".reasonix", "plugins", "foo")) if err != nil { t.Fatalf("ParseDir foo: %v", err) } if pkg.Manifest.Version != "2.0.0" { t.Fatalf("foo version = %q, want the update applied", pkg.Manifest.Version) } }