diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index dfa9e4f57..31037cf77 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -14,7 +14,7 @@ "!src/contexts/tools/domain/profiles/cursor/**/*.ts", "!src/contexts/tools/domain/profiles/opencode/**/*.ts" ], - "break": 76 + "break": 94 }, "telemetry": { "mutate": "src/contexts/telemetry/**/*.ts", diff --git a/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts index 5b736f58b..ca835c969 100644 --- a/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts @@ -105,3 +105,168 @@ describe("AgentsCapability", () => { }); }); }); + +describe("AgentsCapability install paths", () => { + const markdown = new AgentsCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + format: "markdown", + }); + const toml = new AgentsCapability({ + directory: ".codex/", + toolSuffix: ".codex.md", + format: "toml", + }); + + it("installs a markdown agent under agents/ by its file name alone, the tool suffix dropped", () => { + expect(markdown.buildInstallPath("a/b/planner.claude.md")).toBe(".claude/agents/planner.md"); + }); + + it("leaves a markdown file carrying another tool's suffix as it is named", () => { + expect(markdown.buildInstallPath("planner.cursor.md")).toBe(".claude/agents/planner.cursor.md"); + }); + + it("installs a toml agent as .toml whether the source carried the tool suffix, .md, or neither", () => { + expect(toml.buildInstallPath("sub/planner.codex.md")).toBe(".codex/agents/planner.toml"); + expect(toml.buildInstallPath("planner.md")).toBe(".codex/agents/planner.toml"); + expect(toml.buildInstallPath("planner.txt")).toBe(".codex/agents/planner.txt.toml"); + }); + + it("asks the installer the tool declares before deriving a path itself", () => { + const delegating = new AgentsCapability({ + directory: ".x/", + toolSuffix: ".x.md", + format: "markdown", + buildInstallPath: (fileName) => `custom/${fileName}`, + }); + + expect(delegating.buildInstallPath("planner.x.md")).toBe("custom/planner.x.md"); + }); +}); + +describe("AgentsCapability file acceptance", () => { + const cap = new AgentsCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + format: "markdown", + }); + const suffixes = [".claude.md", ".cursor.md", ".codex.md"]; + + it("accepts its own suffix and a suffix belonging to no tool", () => { + expect(cap.acceptsFileName("planner.claude.md", suffixes)).toBe(true); + expect(cap.acceptsFileName("planner.md", suffixes)).toBe(true); + }); + + it("refuses another tool's suffix wherever the file sits", () => { + expect(cap.acceptsFileName("a/b/planner.cursor.md", suffixes)).toBe(false); + }); +}); + +describe("AgentsCapability frontmatter conversion", () => { + const markdown = new AgentsCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + format: "markdown", + }); + const toml = new AgentsCapability({ + directory: ".codex/", + toolSuffix: ".codex.md", + format: "toml", + }); + + it("keeps the declared name and description, nothing else", () => { + expect( + markdown.convertFrontmatter({ name: "planner", description: "d", tools: ["x"] }) + ).toStrictEqual({ name: "planner", description: "d" }); + }); + + it("derives the name from the file's own base name when the frontmatter declares none", () => { + expect( + markdown.convertFrontmatter({ description: "d" }, "agents/sub/planner.md") + ).toStrictEqual({ name: "planner", description: "d" }); + }); + + it("leaves the name undefined when neither the frontmatter nor a file name gives one", () => { + expect(markdown.convertFrontmatter({ description: "d" })).toStrictEqual({ + name: undefined, + description: "d", + }); + }); + + it("refuses a declared name that is not a string", () => { + expect(markdown.convertFrontmatter({ name: 7, description: "d" })).toStrictEqual({ + name: undefined, + description: "d", + }); + }); + + it("carries the model into a toml agent only when one is declared", () => { + expect(toml.convertFrontmatter({ name: "p", description: "d", model: "m" })).toStrictEqual({ + name: "p", + description: "d", + model: "m", + }); + expect(toml.convertFrontmatter({ name: "p", description: "d" })).toStrictEqual({ + name: "p", + description: "d", + }); + }); + + it("asks the converter the tool declares before converting itself", () => { + const delegating = new AgentsCapability({ + directory: ".x/", + toolSuffix: ".x.md", + format: "markdown", + convertFrontmatter: (fm, fileName) => ({ file: fileName, keys: Object.keys(fm) }), + }); + + expect(delegating.convertFrontmatter({ name: "p" }, "p.md")).toStrictEqual({ + file: "p.md", + keys: ["name"], + }); + }); +}); + +describe("AgentsCapability toml serialization", () => { + const toml = new AgentsCapability({ + directory: ".codex/", + toolSuffix: ".codex.md", + format: "toml", + }); + + it("writes name, description and the body as developer instructions, one key per line", () => { + expect(toml.serialize({ name: "p", description: "d" }, "body")).toBe( + 'name = "p"\ndescription = "d"\ndeveloper_instructions = """\nbody\n"""\n' + ); + }); + + it("writes an empty name and description when the frontmatter declares none", () => { + expect(toml.serialize({}, "body")).toBe( + 'name = ""\ndescription = ""\ndeveloper_instructions = """\nbody\n"""\n' + ); + }); + + it("escapes a quote and a backslash inside a value", () => { + expect(toml.serialize({ name: 'a"b\\c', description: "d" }, "body")).toBe( + 'name = "a\\"b\\\\c"\ndescription = "d"\ndeveloper_instructions = """\nbody\n"""\n' + ); + }); +}); + +describe("AgentsCapability equality", () => { + const base = { directory: ".claude/", toolSuffix: ".claude.md", format: "markdown" as const }; + + it("differs on the tool suffix", () => { + expect( + new AgentsCapability(base).equals(new AgentsCapability({ ...base, toolSuffix: ".x.md" })) + ).toBe(false); + }); + + it("differs on the user file extension", () => { + expect( + new AgentsCapability({ ...base, userFileExt: ".md" }).equals( + new AgentsCapability({ ...base, userFileExt: ".toml" }) + ) + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/commands-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/commands-capability.unit.test.ts index a4682f7ba..951253147 100644 --- a/cli/tests/contexts/tools/domain/capabilities/commands-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/commands-capability.unit.test.ts @@ -49,3 +49,21 @@ describe("CommandsCapability", () => { }); }); }); + +describe("CommandsCapability file acceptance", () => { + const cap = new CommandsCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }); + + it("accepts its own suffix and a suffix belonging to no tool", () => { + expect(cap.acceptsFileName("01-plan/plan.claude.md")).toBe(true); + expect(cap.acceptsFileName("01-plan/plan.md")).toBe(true); + }); + + it("refuses another tool's suffix wherever the file sits", () => { + expect(cap.acceptsFileName("a/b/plan.cursor.md")).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/hooks-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/hooks-capability.unit.test.ts index 06fb67507..3bb6b9f85 100644 --- a/cli/tests/contexts/tools/domain/capabilities/hooks-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/hooks-capability.unit.test.ts @@ -37,3 +37,61 @@ describe("HooksCapability", () => { }); }); }); + +describe("HooksCapability declarations", () => { + it("consumes nothing unless told otherwise", () => { + expect(new HooksCapability({ outputPath: "h.json" }).consumes).toStrictEqual([]); + expect( + new HooksCapability({ outputPath: "h.json", consumes: ["hooks"] }).consumes + ).toStrictEqual(["hooks"]); + }); + + it("merges with the function the tool declares, and takes the incoming content whole without one", () => { + expect( + new HooksCapability({ outputPath: "h.json", mergeFn: (a, b) => `${a}+${b}` }).merge("x", "y") + ).toBe("x+y"); + expect(new HooksCapability({ outputPath: "h.json" }).merge("x", "y")).toBe("y"); + }); + + it("lets the user's file win unless the tool declares otherwise", () => { + expect(new HooksCapability({ outputPath: "h.json" }).getMergeStrategy()).toBe("user-prime"); + expect( + new HooksCapability({ outputPath: "h.json", mergeStrategy: "none" }).getMergeStrategy() + ).toBe("none"); + }); + + it("names its entry section only when one is declared", () => { + expect(new HooksCapability({ outputPath: "h.json" }).getEntrySection()).toBeNull(); + expect( + new HooksCapability({ outputPath: "h.json", entrySection: "hooks" }).getEntrySection() + ).toBe("hooks"); + }); + + it("differs on the merge strategy or the entry section alone", () => { + const cap = new HooksCapability({ + outputPath: "h.json", + mergeStrategy: "none", + entrySection: "a", + }); + + expect( + cap.equals( + new HooksCapability({ outputPath: "h.json", mergeStrategy: "none", entrySection: "a" }) + ) + ).toBe(true); + expect( + cap.equals( + new HooksCapability({ + outputPath: "h.json", + mergeStrategy: "user-prime", + entrySection: "a", + }) + ) + ).toBe(false); + expect( + cap.equals( + new HooksCapability({ outputPath: "h.json", mergeStrategy: "none", entrySection: "b" }) + ) + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/mcp-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/mcp-capability.unit.test.ts index 7922c7070..77a681544 100644 --- a/cli/tests/contexts/tools/domain/capabilities/mcp-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/mcp-capability.unit.test.ts @@ -91,3 +91,31 @@ describe("McpCapability", () => { }); }); }); + +describe("McpCapability declarations", () => { + it("consumes nothing unless told otherwise", () => { + expect(new McpCapability({ outputPath: ".mcp.json", format: "json" }).consumes).toStrictEqual( + [] + ); + expect( + new McpCapability({ outputPath: ".mcp.json", format: "json", consumes: ["mcp"] }).consumes + ).toStrictEqual(["mcp"]); + }); + + it("is not equal to a capability that differs in entrySection or mergeStrategy alone", () => { + const base = { outputPath: ".mcp.json", format: "json" as const }; + const cap = new McpCapability({ ...base, entrySection: "mcp", mergeStrategy: "user-prime" }); + + expect( + cap.equals(new McpCapability({ ...base, entrySection: "mcp", mergeStrategy: "user-prime" })) + ).toBe(true); + expect( + cap.equals( + new McpCapability({ ...base, entrySection: "servers", mergeStrategy: "user-prime" }) + ) + ).toBe(false); + expect( + cap.equals(new McpCapability({ ...base, entrySection: "mcp", mergeStrategy: "none" })) + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/plugins-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/plugins-capability.unit.test.ts index ec22d508d..5e8565199 100644 --- a/cli/tests/contexts/tools/domain/capabilities/plugins-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/plugins-capability.unit.test.ts @@ -247,3 +247,90 @@ describe("PluginsCapability", () => { }); }); }); + +describe("PluginsCapability base directory resolution", () => { + const native = { + mode: "native" as const, + acceptsHooks: true as const, + pluginsDir: ".x/plugins/", + pluginManifestRelativePath: null, + }; + + it("stays in the project when the scope is project, even with a user directory declared", () => { + const cap = new PluginsCapability({ + ...native, + userPluginsDir: (home) => `${home}/.x/plugins`, + }); + + expect(cap.resolvePluginsBaseDir("/repo", "/home/me")).toBe("/repo"); + expect(cap.userPluginsBaseDir("/home/me")).toBe("/home/me/.x/plugins"); + }); + + it("stays in the project for a capability declaring no user directory at all", () => { + const cap = new PluginsCapability(native); + + expect(cap.resolvePluginsBaseDir("/repo", "/home/me")).toBe("/repo"); + expect(cap.userPluginsBaseDir("/home/me")).toBeNull(); + }); + + it("names a plugin's own directory under the plugins directory in native mode only", () => { + expect(new PluginsCapability(native).pluginOutputDir("p")).toBe(".x/plugins/p/"); + expect( + new PluginsCapability({ + mode: "flat", + acceptsHooks: true, + flatHooksDir: ".x/hooks/", + flatNamespacePrefix: "aidd-", + }).pluginOutputDir("p") + ).toBeNull(); + }); + + it("refuses a project hooks destination that names no project hooks file", () => { + expect(() => new PluginsCapability({ ...native, hooksDestination: "project" })).toThrow( + "hooksDestination 'project' requires a projectHooksRelativePath." + ); + }); +}); + +describe("PluginsCapability defaults each mode falls back to", () => { + it("delivers no mcp, lands hooks in the plugin, and reads matcher-shaped hooks unless a native tool says otherwise", () => { + const cap = new PluginsCapability({ + mode: "native", + acceptsHooks: true, + pluginsDir: ".x/plugins/", + pluginManifestRelativePath: null, + }); + + expect([cap.acceptsMcp, cap.hooksDestination, cap.hooksContentFormat]).toStrictEqual([ + false, + "plugin", + "matchers", + ]); + }); + + it("fixes the same three answers for a flat tool", () => { + const cap = new PluginsCapability({ + mode: "flat", + acceptsHooks: true, + flatHooksDir: ".x/hooks/", + flatNamespacePrefix: "aidd-", + }); + + expect([cap.acceptsMcp, cap.hooksDestination, cap.hooksContentFormat]).toStrictEqual([ + false, + "plugin", + "matchers", + ]); + }); + + it("accepts neither hooks nor mcp for a tool hosting no plugin at all", () => { + const cap = new PluginsCapability({ mode: "unsupported", hooksUnsupportedReason: "none" }); + + expect([ + cap.acceptsHooks, + cap.acceptsMcp, + cap.hooksDestination, + cap.hooksContentFormat, + ]).toStrictEqual([false, false, "plugin", "matchers"]); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/rules-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/rules-capability.unit.test.ts index e99f8a1b3..749cf2b1c 100644 --- a/cli/tests/contexts/tools/domain/capabilities/rules-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/rules-capability.unit.test.ts @@ -83,3 +83,69 @@ describe("RulesCapability", () => { }); }); }); + +describe("RulesCapability installed location edge cases", () => { + it("answers an empty directory for an installer that files a rule at the project root", () => { + const cap = new RulesCapability({ + directory: ".x/", + toolSuffix: ".x.md", + buildInstallPath: (fileName) => fileName.replace(/\.x\.md$/, ".mdc"), + convertFrontmatter: (fm) => fm, + }); + + expect(cap.installedLocation()).toStrictEqual({ directory: "", extension: ".mdc" }); + }); + + it("reads the extension even when the installer prefixes the stem", () => { + const cap = new RulesCapability({ + directory: ".x/", + toolSuffix: ".x.md", + buildInstallPath: (fileName) => `.x/rules/_${fileName.replace(/\.x\.md$/, ".mdc")}`, + convertFrontmatter: (fm) => fm, + }); + + expect(cap.installedLocation()).toStrictEqual({ directory: ".x/rules/", extension: ".mdc" }); + }); + + it("answers nothing when the installer rewrote the stem past recognition", () => { + const cap = new RulesCapability({ + directory: ".x/", + toolSuffix: ".x.md", + buildInstallPath: () => ".x/rules/renamed.mdc", + convertFrontmatter: (fm) => fm, + }); + + expect(cap.installedLocation()).toBeNull(); + }); +}); + +describe("RulesCapability file acceptance", () => { + const cap = new RulesCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }); + + it("accepts its own suffix and a suffix belonging to no tool", () => { + expect(cap.acceptsFileName("rules/a.claude.md")).toBe(true); + expect(cap.acceptsFileName("rules/a.md")).toBe(true); + }); + + it("refuses another tool's suffix wherever the file sits", () => { + expect(cap.acceptsFileName("a/b/c.cursor.md")).toBe(false); + }); + + it("reads the input suffix as its own when the tool declares one", () => { + const mdc = new RulesCapability({ + directory: ".cursor/", + toolSuffix: ".mdc", + inputSuffix: ".cursor.md", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }); + + expect(mdc.acceptsFileName("a.cursor.md")).toBe(true); + expect(mdc.acceptsFileName("a.claude.md")).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/settings-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/settings-capability.unit.test.ts index 1099f47cc..e2784f40e 100644 --- a/cli/tests/contexts/tools/domain/capabilities/settings-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/settings-capability.unit.test.ts @@ -146,3 +146,40 @@ describe("SettingsCapability", () => { }); }); }); + +describe("SettingsCapability path acceptance", () => { + it("accepts exactly its own output path", () => { + const cap = new SettingsCapability({ + outputPath: ".vscode/settings.json", + mergeStrategy: "none", + }); + + expect(cap.accepts(".vscode/settings.json")).toBe(true); + expect(cap.accepts(".vscode/extensions.json")).toBe(false); + expect(cap.accepts("x/.vscode/settings.json")).toBe(false); + }); +}); + +describe("SettingsCapability equality", () => { + const base = { outputPath: ".vscode/settings.json", mergeStrategy: "none" as const }; + + it("is equal on the same output path and merge strategy", () => { + expect(new SettingsCapability(base).equals(new SettingsCapability({ ...base }))).toBe(true); + }); + + it("differs on the output path", () => { + expect( + new SettingsCapability(base).equals( + new SettingsCapability({ ...base, outputPath: ".vscode/extensions.json" }) + ) + ).toBe(false); + }); + + it("differs on the merge strategy", () => { + expect( + new SettingsCapability(base).equals( + new SettingsCapability({ ...base, mergeStrategy: "user-prime" }) + ) + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/skills-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/skills-capability.unit.test.ts index 034af43f7..75d57d0b7 100644 --- a/cli/tests/contexts/tools/domain/capabilities/skills-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/skills-capability.unit.test.ts @@ -100,3 +100,57 @@ describe("SkillsCapability", () => { }); }); }); + +describe("SkillsCapability without a tool suffix", () => { + it("names the skill file by its bare name", () => { + const cap = new SkillsCapability({ + directory: ".claude/", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }); + + expect(cap.buildOutputPath("planning")).toBe(".claude/skills/planning"); + }); + + it("names what a capability lacking both prefix and directory is missing", () => { + expect( + () => + new SkillsCapability({ + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }) + ).toThrow("SkillsCapability requires either prefix or directory"); + }); +}); + +describe("SkillsCapability file acceptance", () => { + const cap = () => + new SkillsCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }); + + it("accepts its own suffix and a suffix belonging to no tool", () => { + expect(cap().acceptsFileName("skills/a.claude.md")).toBe(true); + expect(cap().acceptsFileName("skills/a/SKILL.md")).toBe(true); + }); + + it("refuses another tool's suffix wherever the file sits", () => { + expect(cap().acceptsFileName("a/b/c.cursor.md")).toBe(false); + }); + + it("differs from a capability with another tool suffix", () => { + expect( + cap().equals( + new SkillsCapability({ + directory: ".claude/", + toolSuffix: ".x.md", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }) + ) + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/formats/command.unit.test.ts b/cli/tests/contexts/tools/domain/formats/command.unit.test.ts new file mode 100644 index 000000000..f2032a7c9 --- /dev/null +++ b/cli/tests/contexts/tools/domain/formats/command.unit.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + buildAiddCommandFilePath, + convertCommandFrontmatter, + convertCommandFrontmatterNoHint, + stripToolSuffix, +} from "../../../../../src/contexts/tools/domain/formats/command.js"; + +describe("stripToolSuffix", () => { + it("strips the suffix off the last segment of a deeply nested path only", () => { + expect(stripToolSuffix(".claude.md", "a/b/c.claude.md")).toBe("a/b/c.md"); + }); +}); + +describe("convertCommandFrontmatter", () => { + it("namespaces the name under the phase its directory is numbered with", () => { + expect( + convertCommandFrontmatter({ name: "plan", description: "d" }, "01-plan/plan.md") + ).toStrictEqual({ name: "aidd:01:plan", description: "d" }); + }); + + it("keeps the bare name when the directory carries no leading number", () => { + expect( + convertCommandFrontmatter({ name: "plan", description: "d" }, "phase2/plan.md") + ).toStrictEqual({ name: "plan", description: "d" }); + }); + + it("reads an absent name as empty rather than as a placeholder", () => { + expect(convertCommandFrontmatter({ description: "d" }, "01-plan/plan.md")).toStrictEqual({ + name: "aidd:01:", + description: "d", + }); + }); + + it("carries the argument hint through only when the source declares one", () => { + expect( + convertCommandFrontmatter( + { name: "plan", description: "d", "argument-hint": "" }, + "plan.md" + ) + ).toStrictEqual({ name: "plan", description: "d", "argument-hint": "" }); + expect(convertCommandFrontmatter({ name: "plan", description: "d" }, "plan.md")).toStrictEqual({ + name: "plan", + description: "d", + }); + }); + + it("drops the argument hint for a tool that reads none", () => { + expect( + convertCommandFrontmatterNoHint( + { name: "plan", description: "d", "argument-hint": "" }, + "01-plan/plan.md" + ) + ).toStrictEqual({ name: "aidd:01:plan", description: "d" }); + }); +}); + +describe("buildAiddCommandFilePath", () => { + it("files a command under its numbered phase", () => { + expect(buildAiddCommandFilePath(".claude/", "01-plan/plan.md")).toBe( + ".claude/commands/aidd/01/plan.md" + ); + }); + + it("reads a directory named by its number alone as that phase", () => { + expect(buildAiddCommandFilePath(".claude/", "1/plan.md")).toBe( + ".claude/commands/aidd/1/plan.md" + ); + }); + + it("flattens a command whose directory carries no leading number", () => { + expect(buildAiddCommandFilePath(".claude/", "phase2/plan.md")).toBe( + ".claude/commands/aidd/plan.md" + ); + }); + + it("keeps only the file name of a deeper unnumbered path", () => { + expect(buildAiddCommandFilePath(".claude/", "a/b/c.md")).toBe(".claude/commands/aidd/c.md"); + }); + + it("files a bare file name directly under the aidd namespace", () => { + expect(buildAiddCommandFilePath(".claude/", "plan.md")).toBe(".claude/commands/aidd/plan.md"); + }); +}); diff --git a/cli/tests/contexts/tools/domain/formats/cursor-hooks-project-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/cursor-hooks-project-merge.unit.test.ts index 7e0f9ba06..e21af7120 100644 --- a/cli/tests/contexts/tools/domain/formats/cursor-hooks-project-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/cursor-hooks-project-merge.unit.test.ts @@ -81,3 +81,36 @@ describe("cursorProjectHooksScriptDir", () => { expect(cursorProjectHooksScriptDir("aidd-telemetry")).toBe(".cursor/hooks/aidd-telemetry/"); }); }); + +describe("rewriting a plugin's root token for the project hooks file", () => { + it("relocates a command under hooks/ to the plugin's own script directory", () => { + const { content } = mergeCursorProjectHooksJson(null, PLUGIN_A_HOOKS, "plugin-a"); + + expect(JSON.parse(content)).toStrictEqual({ + version: 1, + hooks: { postToolUse: [{ command: "node ./.cursor/hooks/plugin-a/journal.cjs" }] }, + }); + }); + + it("leaves a plugin-relative path outside hooks/ where it was", () => { + const { content } = mergeCursorProjectHooksJson( + null, + hooksJson(`node ${PLUGIN_ROOT_VAR}/scripts/run.cjs`), + "plugin-a" + ); + + expect(JSON.parse(content)).toStrictEqual({ + version: 1, + hooks: { postToolUse: [{ command: "node scripts/run.cjs" }] }, + }); + }); + + it("names where a hook script lands, with or without its hooks/ prefix", () => { + expect(cursorProjectHooksScriptPath("plugin-a", "hooks/journal.cjs")).toBe( + ".cursor/hooks/plugin-a/journal.cjs" + ); + expect(cursorProjectHooksScriptPath("plugin-a", "lib/x.cjs")).toBe( + ".cursor/hooks/plugin-a/lib/x.cjs" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts index 3fc669cec..38c580fd4 100644 --- a/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts @@ -5,6 +5,7 @@ import { mergeClaudeSettingsHooks, mergeCodexFrameworkHooksJson, mergeCursorFlatHooks, + renameCodexHookEvents, } from "../../../../../src/contexts/tools/domain/formats/flat-hooks-merge.js"; describe("mergeClaudeSettingsHooks", () => { @@ -410,3 +411,182 @@ describe("hookCommandsForEvent", () => { expect(hookCommandsForEvent(JSON.stringify({ hooks: [] }), "SessionStart")).toEqual([]); }); }); + +describe("renameCodexHookEvents", () => { + it("returns a document without hooks byte for byte", () => { + expect(renameCodexHookEvents('{"x":1}')).toBe('{"x":1}'); + }); + + it("renames Stop to SessionEnd and leaves the other events under their own names", () => { + const renamed = renameCodexHookEvents( + JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: "command", command: "a" }] }], + PreToolUse: [{ hooks: [] }], + }, + }) + ); + + expect(renamed).toBe( + `${JSON.stringify( + { + hooks: { + SessionEnd: [{ hooks: [{ type: "command", command: "a" }] }], + PreToolUse: [{ hooks: [] }], + }, + }, + null, + 2 + )}\n` + ); + }); +}); + +describe("flattenCopilotHooksShape, entry by entry", () => { + it("omits an event whose groups hold no runnable entry", () => { + const flat = flattenCopilotHooksShape( + JSON.stringify({ hooks: { PreToolUse: [{ hooks: [] }, { hooks: [{ type: "command" }] }] } }) + ); + + expect(JSON.parse(flat)).toStrictEqual({ version: 1 }); + }); + + it("defaults a missing type to command, keeps a declared one, and carries timeout only when set", () => { + const flat = flattenCopilotHooksShape( + JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ command: "a" }, { type: "prompt", command: "b", timeout: 5 }] }], + }, + }) + ); + + expect(JSON.parse(flat)).toStrictEqual({ + version: 1, + hooks: { + Stop: [ + { type: "command", command: "a" }, + { type: "prompt", command: "b", timeout: 5 }, + ], + }, + }); + }); +}); + +describe("mergeCursorFlatHooks, entry by entry", () => { + it("keeps only the entries that carry a command string", () => { + const { content } = mergeCursorFlatHooks( + null, + JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: "command" }, { command: "a" }, { command: 5 }] }] }, + }) + ); + + expect(JSON.parse(content)).toStrictEqual({ + version: 1, + hooks: { stop: [{ command: "a" }], sessionEnd: [{ command: "a" }] }, + }); + }); +}); + +describe("mergeCodexFrameworkHooksJson, entry by entry", () => { + it("writes a matcher only when the group declares one, and drops an item without a command", () => { + const { content } = mergeCodexFrameworkHooksJson( + null, + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ command: "a" }, { type: "command" }] }, + { hooks: [{ type: "prompt", command: "b" }] }, + ], + }, + }) + ); + + expect(JSON.parse(content)).toStrictEqual({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: "a" }] }, + { hooks: [{ type: "prompt", command: "b" }] }, + ], + }, + }); + }); + + it("carries timeout and statusMessage only when each is declared in its own type", () => { + const { content } = mergeCodexFrameworkHooksJson( + null, + JSON.stringify({ + hooks: { + Stop: [ + { + hooks: [ + { command: "a", timeout: 5, statusMessage: "working" }, + { command: "b", timeout: "5", statusMessage: 7 }, + ], + }, + ], + }, + }) + ); + + expect(JSON.parse(content)).toStrictEqual({ + hooks: { + SessionEnd: [ + { + hooks: [ + { type: "command", command: "a", timeout: 5, statusMessage: "working" }, + { type: "command", command: "b" }, + ], + }, + ], + }, + }); + }); +}); + +describe("hookCommandsForEvent, on content that is not a hooks file", () => { + it("answers nothing for a document that is not an object", () => { + expect(hookCommandsForEvent("[]", "Stop")).toStrictEqual([]); + expect(hookCommandsForEvent("5", "Stop")).toStrictEqual([]); + }); + + it("skips an entry that is not an object, and a command that is not a string", () => { + const content = JSON.stringify({ + hooks: { + Stop: [null, "x", { command: 5 }, { command: "c" }, { hooks: [{ command: "d" }, 3] }], + }, + }); + + expect(hookCommandsForEvent(content, "Stop")).toStrictEqual(["c", "d"]); + }); +}); + +describe("a matcher group that declares no hooks list", () => { + const groupless = JSON.stringify({ + hooks: { Stop: [{ matcher: "x" }, { hooks: [{ command: "a" }] }] }, + }); + + it("is skipped by the Copilot flattening", () => { + expect(JSON.parse(flattenCopilotHooksShape(groupless))).toStrictEqual({ + version: 1, + hooks: { Stop: [{ type: "command", command: "a" }] }, + }); + }); + + it("is skipped by the Cursor merge", () => { + expect(JSON.parse(mergeCursorFlatHooks(null, groupless).content)).toStrictEqual({ + version: 1, + hooks: { stop: [{ command: "a" }], sessionEnd: [{ command: "a" }] }, + }); + }); +}); + +describe("hookCommandsForEvent, for an event Cursor never renames", () => { + it("reads the event under its own name alone", () => { + const content = JSON.stringify({ + hooks: { PreCompact: [{ command: "a" }], preCompact: [{ command: "b" }] }, + }); + + expect(hookCommandsForEvent(content, "PreCompact")).toStrictEqual(["a"]); + }); +}); diff --git a/cli/tests/contexts/tools/domain/formats/mcp-format.unit.test.ts b/cli/tests/contexts/tools/domain/formats/mcp-format.unit.test.ts new file mode 100644 index 000000000..d1909bcbf --- /dev/null +++ b/cli/tests/contexts/tools/domain/formats/mcp-format.unit.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { + deepMerge, + mcpJsonToToml, + mergeJsonUserPrime, +} from "../../../../../src/contexts/tools/domain/formats/mcp-format.js"; + +function mcpJson(servers: Record): string { + return JSON.stringify({ mcpServers: servers }); +} + +describe("mcpJsonToToml", () => { + describe("a stdio server", () => { + it("carries its command alone when nothing else is declared", () => { + expect(mcpJsonToToml(mcpJson({ fs: { command: "npx" } }))).toBe( + '[mcp_servers.fs]\ncommand = "npx"\n' + ); + }); + + it("carries args, cwd, every universal field, and env as its own table", () => { + const toml = mcpJsonToToml( + mcpJson({ + fs: { + command: "npx", + args: ["-y", "x"], + env: { A: "1" }, + cwd: "/w", + startup_timeout_sec: 5, + tool_timeout_sec: 9, + enabled: true, + required: false, + enabled_tools: ["a"], + disabled_tools: ["b"], + }, + }) + ); + + expect(toml).toBe( + [ + "[mcp_servers.fs]", + 'command = "npx"', + 'args = [ "-y", "x" ]', + 'cwd = "/w"', + "startup_timeout_sec = 5", + "tool_timeout_sec = 9", + "enabled = true", + "required = false", + 'enabled_tools = [ "a" ]', + 'disabled_tools = [ "b" ]', + "", + "[mcp_servers.fs.env]", + 'A = "1"', + "", + ].join("\n") + ); + }); + + it("drops a field the source spells but Codex does not read", () => { + expect(mcpJsonToToml(mcpJson({ fs: { command: "npx", type: "stdio" } }))).toBe( + '[mcp_servers.fs]\ncommand = "npx"\n' + ); + }); + }); + + describe("an http server", () => { + it("carries its url alone when nothing else is declared", () => { + expect(mcpJsonToToml(mcpJson({ web: { url: "https://h" } }))).toBe( + '[mcp_servers.web]\nurl = "https://h"\n' + ); + }); + + it("renames bearerTokenEnvVar to Codex's own spelling and keeps both header maps", () => { + const toml = mcpJsonToToml( + mcpJson({ + web: { + url: "https://h", + bearerTokenEnvVar: "T", + http_headers: { X: "1" }, + env_http_headers: { Y: "Z" }, + tool_timeout_sec: 2, + }, + }) + ); + + expect(toml).toBe( + [ + "[mcp_servers.web]", + 'url = "https://h"', + 'bearer_token_env_var = "T"', + "tool_timeout_sec = 2", + "", + "[mcp_servers.web.http_headers]", + 'X = "1"', + "", + "[mcp_servers.web.env_http_headers]", + 'Y = "Z"', + "", + ].join("\n") + ); + }); + + it("carries every universal field an http server declares", () => { + const toml = mcpJsonToToml( + mcpJson({ + web: { + url: "https://h", + startup_timeout_sec: 1, + enabled: false, + required: true, + enabled_tools: ["a"], + disabled_tools: [], + }, + }) + ); + + expect(toml).toBe( + [ + "[mcp_servers.web]", + 'url = "https://h"', + "startup_timeout_sec = 1", + "enabled = false", + "required = true", + 'enabled_tools = [ "a" ]', + "disabled_tools = []", + "", + ].join("\n") + ); + }); + }); + + it("emits an empty table for a server that is neither stdio nor http", () => { + expect(mcpJsonToToml(mcpJson({ odd: { transport: "sse" } }))).toBe("[mcp_servers.odd]\n"); + }); + + it("emits nothing when the source declares no server", () => { + expect(mcpJsonToToml(mcpJson({}))).toBe(""); + expect(mcpJsonToToml("{}")).toBe(""); + }); +}); + +describe("mergeJsonUserPrime", () => { + it("takes the incoming document whole when nothing existed", () => { + expect(mergeJsonUserPrime("", '{"mcpServers":{"a":{"command":"x"}}}')).toBe( + JSON.stringify({ mcpServers: { a: { command: "x" } } }, null, 2) + ); + }); + + it("treats a whitespace-only existing file as absent rather than as malformed", () => { + expect(mergeJsonUserPrime(" \n", '{"a":1}')).toBe('{\n "a": 1\n}'); + }); + + it("keeps the existing value of a key both sides declare", () => { + expect(JSON.parse(mergeJsonUserPrime('{"a":1,"b":2}', '{"a":9,"c":3}'))).toStrictEqual({ + a: 1, + c: 3, + b: 2, + }); + }); + + it("merges nested objects key by key, the existing side winning", () => { + const merged = mergeJsonUserPrime( + '{"mcpServers":{"a":{"command":"user"}}}', + '{"mcpServers":{"a":{"command":"plugin","args":["-y"]},"b":{"command":"y"}}}' + ); + + expect(JSON.parse(merged)).toStrictEqual({ + mcpServers: { a: { command: "user", args: ["-y"] }, b: { command: "y" } }, + }); + }); +}); + +describe("deepMerge", () => { + it("replaces an array rather than merging it as an object", () => { + expect(deepMerge({ a: { b: 1 } }, { a: [1] })).toStrictEqual({ a: [1] }); + expect(deepMerge({ a: [1, 2] }, { a: [3] })).toStrictEqual({ a: [3] }); + }); + + it("lets a null from the source replace an object in the target", () => { + expect(deepMerge({ a: { b: 1 } }, { a: null })).toStrictEqual({ a: null }); + }); + + it("recurses into an object both sides carry, and leaves the target's other keys", () => { + expect(deepMerge({ a: { b: 1, c: { d: 1 } }, e: 5 }, { a: { c: { f: 2 } } })).toStrictEqual({ + a: { b: 1, c: { d: 1, f: 2 } }, + e: 5, + }); + }); + + it("copies a source object over a scalar in the target", () => { + expect(deepMerge({ a: 1 }, { a: { b: 2 } })).toStrictEqual({ a: { b: 2 } }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts b/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts index 532bd1317..af6f3ba03 100644 --- a/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts +++ b/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts @@ -99,3 +99,39 @@ describe("what a host's own registry says about a plugin AIDD installed", () => expect(buildHostRegistration([])).toEqual({ entries: [] }); }); }); + +describe("the sentence each answer carries", () => { + it("names the missing marketplace, and builds no ref, when none was recorded or it is empty", () => { + for (const marketplace of [undefined, ""]) { + expect(only(evidence({ plugins: [{ name: "p", marketplace }] }))).toStrictEqual({ + tool: "claude", + plugin: "p", + answer: "unanswerable", + detail: "AIDD records no marketplace for it, so no host registry can be asked", + }); + } + }); + + it("names the registry and the ref it records as disabled", () => { + const entry = only( + evidence({ + reading: { + location: REGISTRY, + refs: new Map([["aidd-telemetry@aidd-framework", { enabled: false }]]), + }, + }) + ); + + expect(entry.answer).toBe("registered-disabled"); + expect(entry.detail).toBe( + `${REGISTRY} carries aidd-telemetry@aidd-framework and records it disabled` + ); + }); + + it("says a registry that gave no reason could not be read, without inventing one", () => { + const entry = only(evidence({ reading: { location: REGISTRY } })); + + expect(entry.answer).toBe("unanswerable"); + expect(entry.detail).toBe(`${REGISTRY} could not be read — no reason given`); + }); +}); diff --git a/cli/tests/contexts/tools/domain/marketplace-catalog.unit.test.ts b/cli/tests/contexts/tools/domain/marketplace-catalog.unit.test.ts index 5567e9ea6..7a54c521c 100644 --- a/cli/tests/contexts/tools/domain/marketplace-catalog.unit.test.ts +++ b/cli/tests/contexts/tools/domain/marketplace-catalog.unit.test.ts @@ -2,9 +2,16 @@ import { describe, expect, it } from "vitest"; import type { PluginPresence } from "../../../../src/contexts/tools/domain/build-contract.js"; import { buildClaudeStyleCatalogEntry, + buildClaudeStyleEntry, buildClaudeStyleMarketplace, + buildCodexMarketplace, + buildCodexMarketplaceEntry, + resolveDescription, + resolveVersion, synthesizeClaudeStyleManifest, } from "../../../../src/contexts/tools/domain/marketplace-catalog.js"; +import { InvalidSourceMarketplaceError } from "../../../../src/kernel/errors.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/index.js"; const EMPTY_PRESENCE: PluginPresence = { hasAgents: false, @@ -262,3 +269,135 @@ describe("buildClaudeStyleCatalogEntry", () => { expect(typeof entry.strict).toBe("boolean"); }); }); + +describe("synthesizeClaudeStyleManifest, field by field", () => { + const opts = { agentsField: true, hooksField: true }; + + it("writes no key at all for a source declaring nothing", () => { + expect(synthesizeClaudeStyleManifest({}, EMPTY_PRESENCE, opts)).toStrictEqual({}); + }); + + it("keeps an author given as an object or a string, and drops one of any other type", () => { + expect( + synthesizeClaudeStyleManifest({ author: { name: "B" } }, EMPTY_PRESENCE, opts) + ).toStrictEqual({ author: { name: "B" } }); + expect(synthesizeClaudeStyleManifest({ author: "B" }, EMPTY_PRESENCE, opts)).toStrictEqual({ + author: "B", + }); + expect(synthesizeClaudeStyleManifest({ author: 7 }, EMPTY_PRESENCE, opts)).toStrictEqual({}); + }); + + it("drops a name, description or version that is not a string", () => { + expect( + synthesizeClaudeStyleManifest({ name: 1, description: 2, version: 3 }, EMPTY_PRESENCE, opts) + ).toStrictEqual({}); + }); +}); + +describe("buildClaudeStyleMarketplace, field by field", () => { + it("writes only the name and the plugins for a source declaring nothing else", () => { + expect(buildClaudeStyleMarketplace({ name: "m" }, [])).toStrictEqual({ + name: "m", + plugins: [], + }); + }); +}); + +describe("resolving a catalog entry's version and description", () => { + const MANIFEST = "/out/plugins/p/.claude-plugin/plugin.json"; + + it("takes the marketplace entry's own version and description without opening the manifest", async () => { + const fs = new InMemoryFileAdapter(); + + expect( + await resolveVersion(fs, "p", { version: "9.9.9" }, "/out", ".claude-plugin/plugin.json") + ).toBe("9.9.9"); + expect( + await resolveDescription( + fs, + "p", + { description: "from entry" }, + "/out", + ".claude-plugin/plugin.json" + ) + ).toBe("from entry"); + }); + + it("falls back to the built manifest, and names what is missing when neither side has it", async () => { + const fs = new InMemoryFileAdapter({ + [MANIFEST]: JSON.stringify({ version: "1.0.0", description: "" }), + }); + + expect(await resolveVersion(fs, "p", undefined, "/out", ".claude-plugin/plugin.json")).toBe( + "1.0.0" + ); + await expect( + resolveDescription(fs, "p", {}, "/out", ".claude-plugin/plugin.json") + ).rejects.toThrow( + new InvalidSourceMarketplaceError( + "plugin 'p' has no description in marketplace entry or plugin.json" + ) + ); + await expect( + resolveVersion( + new InMemoryFileAdapter({ [MANIFEST]: "{}" }), + "p", + {}, + "/out", + ".claude-plugin/plugin.json" + ) + ).rejects.toThrow( + new InvalidSourceMarketplaceError( + "plugin 'p' has no version in marketplace entry or plugin.json" + ) + ); + }); + + it("shapes the whole entry from both resolutions", async () => { + const fs = new InMemoryFileAdapter({ + [MANIFEST]: JSON.stringify({ version: "1.0.0", description: "built" }), + }); + + expect( + await buildClaudeStyleEntry("p", "/out", { strict: true }, ".claude-plugin/plugin.json", fs) + ).toStrictEqual({ + name: "p", + source: "./plugins/p", + description: "built", + version: "1.0.0", + strict: true, + }); + }); +}); + +describe("a Codex marketplace catalog", () => { + it("falls back to the marketplace name as its display name", () => { + expect(buildCodexMarketplace({ name: "m" }, [])).toStrictEqual({ + name: "m", + interface: { displayName: "m" }, + plugins: [], + }); + expect(buildCodexMarketplace({ name: "m", displayName: "Mine" }, []).interface).toStrictEqual({ + displayName: "Mine", + }); + }); + + it("defaults an entry's authentication and category, and takes a string override for each", () => { + expect(buildCodexMarketplaceEntry("p", undefined)).toStrictEqual({ + name: "p", + source: { source: "local", path: "./plugins/p" }, + policy: { installation: "AVAILABLE", authentication: "ON_USE" }, + category: "Developer Tools", + }); + expect( + buildCodexMarketplaceEntry("p", { authentication: "NEVER", category: "Testing" }) + ).toMatchObject({ + policy: { installation: "AVAILABLE", authentication: "NEVER" }, + category: "Testing", + }); + expect(buildCodexMarketplaceEntry("p", { authentication: 1, category: null })).toMatchObject({ + policy: { authentication: "ON_USE" }, + category: "Developer Tools", + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/marketplace-source-conflict.unit.test.ts b/cli/tests/contexts/tools/domain/marketplace-source-conflict.unit.test.ts index 52342b76d..e6229c74d 100644 --- a/cli/tests/contexts/tools/domain/marketplace-source-conflict.unit.test.ts +++ b/cli/tests/contexts/tools/domain/marketplace-source-conflict.unit.test.ts @@ -121,3 +121,40 @@ describe("pluginSetDifference / describePluginDiff", () => { expect(describePluginDiff(diff)).toBe("match, but the declared name differs"); }); }); + +describe("marketplaceSourceConflict, at the edges", () => { + it("is not a conflict when the name is absent, even with a registered identity in hand", () => { + const reading = { location: LOCATION, entries: new Map([["other", "/src"]]) }; + + expect( + marketplaceSourceConflict(reading, "probe-mkt", "/req", IDENTITY_B, IDENTITY_A) + ).toBeUndefined(); + }); + + it("is a conflict when the requested plugin set merely extends the registered one", () => { + const reading = { location: LOCATION, entries: new Map([["probe-mkt", "/src"]]) }; + const wider = { name: "probe-mkt", pluginNames: ["sample-plugin", "zeta-plugin"] }; + + expect( + marketplaceSourceConflict(reading, "probe-mkt", "/req", IDENTITY_A, wider) + ).toBeDefined(); + }); + + it("compares plugin sets whichever side lists them out of order", () => { + const reading = { location: LOCATION, entries: new Map([["probe-mkt", "/src"]]) }; + const unordered = { name: "probe-mkt", pluginNames: ["b", "a"] }; + const ordered = { name: "probe-mkt", pluginNames: ["a", "b"] }; + + expect( + marketplaceSourceConflict(reading, "probe-mkt", "/req", unordered, ordered) + ).toBeUndefined(); + }); +}); + +describe("describePluginDiff, with several names on each side", () => { + it("lists every added and removed name, comma separated", () => { + expect(describePluginDiff({ added: ["a", "b"], removed: ["c", "d"] })).toBe( + "differ (+a, b, -c, d)" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts b/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts index dfec36814..960de5f14 100644 --- a/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts +++ b/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { transformFor } from "../../../../src/contexts/tools/domain/mcp-exclusion.js"; +import { + mcpExclusionEquals, + transformFor, +} from "../../../../src/contexts/tools/domain/mcp-exclusion.js"; function makeConfig(servers: Record): string { return JSON.stringify({ mcpServers: servers }, null, 2); @@ -80,3 +83,24 @@ describe("transformFor()", () => { }); }); }); + +describe("the win32 transform on a config without servers", () => { + it("re-serializes the document untouched", () => { + const transform = transformFor("win32"); + if (transform === undefined) throw new Error("win32 declares a transform"); + + expect(transform('{"other":{"command":"npx"}}')).toBe( + JSON.stringify({ other: { command: "npx" } }, null, 2) + ); + }); +}); + +describe("mcpExclusionEquals", () => { + it("is equal only when both the config path and the entry key match", () => { + const one = { configPath: ".mcp.json", entryKey: "a" }; + + expect(mcpExclusionEquals(one, { configPath: ".mcp.json", entryKey: "a" })).toBe(true); + expect(mcpExclusionEquals(one, { configPath: ".mcp.json", entryKey: "b" })).toBe(false); + expect(mcpExclusionEquals(one, { configPath: "other.json", entryKey: "a" })).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/registry.unit.test.ts b/cli/tests/contexts/tools/domain/registry.unit.test.ts new file mode 100644 index 000000000..31c9db3d4 --- /dev/null +++ b/cli/tests/contexts/tools/domain/registry.unit.test.ts @@ -0,0 +1,163 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + type NativePluginsParams, + PluginsCapability, +} from "../../../../src/contexts/tools/domain/capabilities/plugins-capability.js"; +import type { AiTool } from "../../../../src/contexts/tools/domain/contracts.js"; +import { + getToolConfig, + hasToolSignals, + nativeActivationOf, + projectHooksFileOf, + registerTool, + supportsUserScopeActivation, + userMachineLocalFilesOf, +} from "../../../../src/contexts/tools/domain/registry.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/index.js"; + +function stubTool( + toolId: AiToolId, + capabilities: unknown, + signalDir: string | null +): AiTool { + return { + kind: "ai", + toolId, + directory: `.${toolId}/`, + toolSuffix: `.${toolId}.md`, + signalDir, + displayName: toolId, + telemetryLocalRead: { kind: "unsupported", reason: "a stub reads nothing" }, + telemetryTaskAttributable: false, + capabilities, + rewriteContent: (content: string) => content, + }; +} + +function nativePlugins(extra: Partial = {}): PluginsCapability { + return new PluginsCapability({ + mode: "native", + acceptsHooks: true, + pluginsDir: ".x/plugins/", + pluginManifestRelativePath: null, + ...extra, + }); +} + +describe("a tool's own files as a signal it is in use", () => { + it("answers nothing for a tool declaring no signal directory", async () => { + registerTool(stubTool("cursor", {}, null)); + const fs = new InMemoryFileAdapter({ "/p/.cursor/commands/a.md": "name: aidd:x" }); + + expect(await hasToolSignals(fs, getToolConfig("cursor"), "/p")).toStrictEqual([]); + }); + + it("answers nothing when the signal directory does not exist", async () => { + registerTool(stubTool("cursor", {}, ".cursor/commands")); + + expect( + await hasToolSignals(new InMemoryFileAdapter(), getToolConfig("cursor"), "/p") + ).toStrictEqual([]); + }); + + it("names every markdown file whose frontmatter declares an aidd name, and only those", async () => { + registerTool(stubTool("cursor", {}, ".cursor/commands")); + const fs = new InMemoryFileAdapter({ + "/p/.cursor/commands/colon.md": "---\nname: aidd:plan\n---", + "/p/.cursor/commands/quoted.md": "---\nname: 'aidd_plan'\n---", + "/p/.cursor/commands/tight.md": "name:aidd:plan", + "/p/.cursor/commands/indented.md": " name: aidd:plan", + "/p/.cursor/commands/other.md": "name: mine", + "/p/.cursor/commands/notes.txt": "name: aidd:plan", + }); + + expect((await hasToolSignals(fs, getToolConfig("cursor"), "/p")).sort()).toStrictEqual( + ["colon.md", "quoted.md", "tight.md"].map((file) => join(".cursor/commands", file)) + ); + }); +}); + +describe("what a tool's plugin capability declares about activation", () => { + it("declares no native activation, no project hooks file and no user scope without a plugins capability", () => { + registerTool(stubTool("cursor", {}, null)); + + expect(nativeActivationOf("cursor")).toBeUndefined(); + expect(projectHooksFileOf("cursor")).toBeUndefined(); + expect(supportsUserScopeActivation("cursor")).toBe(false); + }); + + it("supports a user scope through its own CLI, or through a user-scope install directory, and not otherwise", () => { + registerTool(stubTool("cursor", { plugins: nativePlugins() }, null)); + expect(supportsUserScopeActivation("cursor")).toBe(false); + + registerTool( + stubTool( + "cursor", + { plugins: nativePlugins({ nativeActivation: { binary: "codex" } }) }, + null + ) + ); + expect(supportsUserScopeActivation("cursor")).toBe(true); + + registerTool( + stubTool( + "cursor", + { + plugins: nativePlugins({ installScope: "user", userPluginsDir: (home) => `${home}/.x` }), + }, + null + ) + ); + expect(supportsUserScopeActivation("cursor")).toBe(true); + }); + + it("names the project hooks file only for a tool merging hooks into the project", () => { + registerTool( + stubTool( + "cursor", + { + plugins: nativePlugins({ + hooksDestination: "project", + projectHooksRelativePath: ".cursor/hooks.json", + }), + }, + null + ) + ); + expect(projectHooksFileOf("cursor")).toBe(".cursor/hooks.json"); + + registerTool(stubTool("cursor", { plugins: nativePlugins() }, null)); + expect(projectHooksFileOf("cursor")).toBeUndefined(); + }); + + it("names a user settings file only for a tool whose activation declares one", () => { + registerTool( + stubTool( + "cursor", + { plugins: nativePlugins({ nativeActivation: { binary: "codex" } }) }, + null + ) + ); + expect(userMachineLocalFilesOf("cursor", "/home/me", () => undefined)).toStrictEqual([]); + + registerTool( + stubTool( + "cursor", + { + plugins: nativePlugins({ + nativeActivation: { + binary: "codex", + userSettingsPath: (home, env) => `${home}/${env("X") ?? "settings.json"}`, + }, + }), + }, + null + ) + ); + expect(userMachineLocalFilesOf("cursor", "/home/me", () => undefined)).toStrictEqual([ + "/home/me/settings.json", + ]); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts new file mode 100644 index 000000000..3a6aeecac --- /dev/null +++ b/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts @@ -0,0 +1,228 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { windowsCommandLine } from "../../../../src/contexts/tools/infrastructure/executable-on-path.js"; +import { + NativePluginCliAdapter, + type NativePluginCliShape, +} from "../../../../src/contexts/tools/infrastructure/native-plugin-cli-adapter.js"; +import { NativePluginCliError } from "../../../../src/kernel/errors.js"; + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +const mockSpawnSync = vi.mocked(spawnSync); + +function makeResult(overrides: Partial>) { + return { + pid: 1, + output: [], + stdout: "", + stderr: "", + status: 0, + signal: null, + error: undefined, + ...overrides, + } as ReturnType; +} + +const RUN_OPTIONS = { timeout: 120000, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }; +const PROBE_OPTIONS = { timeout: 120000, stdio: ["ignore", "ignore", "ignore"], encoding: "utf-8" }; + +const FULL_SHAPE: NativePluginCliShape = { + scopeArgs: { project: ["--scope", "local"], user: ["--scope", "user"] }, + forceRemoveArgs: ["--force"], + sourceCheckVerb: "update", + upgradeVerb: "update", + enableVerb: "install", + disableVerb: "uninstall", + pluginArgs: ["--yes"], +}; + +function adapter(shape: NativePluginCliShape = FULL_SHAPE): NativePluginCliAdapter { + return new NativePluginCliAdapter("probe-tool", shape); +} + +afterEach(() => { + mockSpawnSync.mockReset(); +}); + +describe("registering and removing a marketplace through the tool's own CLI", () => { + it("removes a marketplace by name at the scope asked for, and nothing more by default", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().removeMarketplace("aidd-framework", "user"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "probe-tool", + ["plugin", "marketplace", "remove", "aidd-framework", "--scope", "user"], + RUN_OPTIONS + ); + }); + + it("forces a removal past installed plugins only when asked, with the arguments the tool declares", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().removeMarketplace("aidd-framework", "project", { force: true }); + adapter().removeMarketplace("aidd-framework", "project", { force: false }); + + expect(mockSpawnSync.mock.calls.map((call) => call[1])).toStrictEqual([ + ["plugin", "marketplace", "remove", "aidd-framework", "--scope", "local", "--force"], + ["plugin", "marketplace", "remove", "aidd-framework", "--scope", "local"], + ]); + }); + + it("forces nothing for a tool declaring no force arguments", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter({}).removeMarketplace("aidd-framework", "project", { force: true }); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "probe-tool", + ["plugin", "marketplace", "remove", "aidd-framework"], + RUN_OPTIONS + ); + }); +}); + +describe("telling a live registration from a dead one", () => { + it("answers unknown, without running anything, for a tool declaring no source check", () => { + expect(adapter({}).registrationState("aidd-framework")).toBe("unknown"); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + + it("answers live when the source check exits cleanly, asking only for its exit code", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + expect(adapter().registrationState("aidd-framework")).toBe("live"); + expect(mockSpawnSync).toHaveBeenCalledWith( + "probe-tool", + ["plugin", "marketplace", "update", "aidd-framework"], + PROBE_OPTIONS + ); + }); + + it("answers dead when the source check fails, or cannot be spawned at all", () => { + mockSpawnSync.mockReturnValueOnce(makeResult({ status: 1 })); + expect(adapter().registrationState("aidd-framework")).toBe("dead"); + + mockSpawnSync.mockReturnValueOnce(makeResult({ error: new Error("ENOENT") })); + expect(adapter().registrationState("aidd-framework")).toBe("dead"); + }); +}); + +describe("driving the verbs a tool declares, and only those", () => { + it("re-indexes marketplaces with the declared verb, and does nothing without one", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().upgradeMarketplaces(); + adapter({}).upgradeMarketplaces(); + + expect(mockSpawnSync.mock.calls.map((call) => call[1])).toStrictEqual([ + ["plugin", "marketplace", "update"], + ]); + }); + + it("enables and uninstalls a plugin with the declared verb, the plugin arguments and the scope", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().enablePlugin("p@m"); + adapter().uninstallPlugin("p@m", "user"); + + expect(mockSpawnSync.mock.calls.map((call) => call[1])).toStrictEqual([ + ["plugin", "install", "p@m", "--yes", "--scope", "local"], + ["plugin", "uninstall", "p@m", "--yes", "--scope", "user"], + ]); + }); + + it("neither enables nor uninstalls for a tool that loads plugins from a file this CLI writes", () => { + adapter({}).enablePlugin("p@m"); + adapter({}).uninstallPlugin("p@m"); + + expect(adapter({}).enablesPlugins()).toBe(false); + expect(adapter().enablesPlugins()).toBe(true); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); +}); + +describe("naming a failure by the tool, the step and what the tool said", () => { + it("carries the spawn error's own message when the process never started", () => { + mockSpawnSync.mockReturnValue(makeResult({ error: new Error("spawn ENOENT") })); + + expect(() => adapter().addMarketplace("/src", "project")).toThrow( + new NativePluginCliError("probe-tool marketplace add /src failed: spawn ENOENT") + ); + }); + + it("carries the tool's trimmed stderr on a non-zero exit", () => { + mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: " boom \n" })); + + expect(() => adapter().upgradeMarketplaces()).toThrow( + new NativePluginCliError("probe-tool marketplace update failed: boom") + ); + }); + + it("names the exit code when the tool said nothing, and unknown when there is no code", () => { + mockSpawnSync.mockReturnValueOnce(makeResult({ status: 2, stderr: "" })); + expect(() => adapter().enablePlugin("p@m")).toThrow( + new NativePluginCliError("probe-tool plugin install p@m failed: exited with code 2") + ); + + mockSpawnSync.mockReturnValueOnce(makeResult({ status: null, signal: "SIGKILL", stderr: "" })); + expect(() => adapter().uninstallPlugin("p@m")).toThrow( + new NativePluginCliError("probe-tool plugin uninstall p@m failed: exited with code unknown") + ); + }); + + it("names the marketplace removal that failed", () => { + mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: "not found" })); + + expect(() => adapter().removeMarketplace("m", "project")).toThrow( + new NativePluginCliError("probe-tool marketplace remove m failed: not found") + ); + }); +}); + +describe("a batch shim on PATH", () => { + let dir: string | undefined; + const previousPath = process.env.PATH; + + afterEach(() => { + process.env.PATH = previousPath; + if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + it("runs through the command interpreter as one quoted command line", () => { + dir = mkdtempSync(join(tmpdir(), "aidd-bin-")); + writeFileSync(join(dir, "probe.cmd"), "#!/bin/sh\n", { mode: 0o755 }); + process.env.PATH = dir; + mockSpawnSync.mockReturnValue(makeResult({})); + + new NativePluginCliAdapter("probe.cmd", FULL_SHAPE).addMarketplace("/my src", "project"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + windowsCommandLine(join(dir, "probe.cmd"), [ + "plugin", + "marketplace", + "add", + "/my src", + "--scope", + "local", + ]), + { ...RUN_OPTIONS, shell: true } + ); + }); + + it("is what makes the tool available, while a bare name absent from PATH is not", () => { + dir = mkdtempSync(join(tmpdir(), "aidd-bin-")); + writeFileSync(join(dir, "probe.cmd"), "#!/bin/sh\n", { mode: 0o755 }); + process.env.PATH = dir; + + expect(new NativePluginCliAdapter("probe.cmd", FULL_SHAPE).isAvailable()).toBe(true); + expect(adapter().isAvailable()).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/executable-on-path.unit.test.ts b/cli/tests/contexts/tools/infrastructure/executable-on-path.unit.test.ts index f6ee37d5b..fd8ac975f 100644 --- a/cli/tests/contexts/tools/infrastructure/executable-on-path.unit.test.ts +++ b/cli/tests/contexts/tools/infrastructure/executable-on-path.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { candidateExecutableNames, type ExecutableLookup, + hostExecutableLookup, resolveExecutableOnPath, runsThroughShell, windowsCommandLine, @@ -75,3 +76,65 @@ describe("finding a tool's own CLI on PATH", () => { ); }); }); + +describe("the shape of a PATH lookup", () => { + it("skips an empty PATHEXT segment rather than spelling the bare name twice", () => { + expect(candidateExecutableNames("claude", "win32", ".EXE;;.CMD;")).toEqual([ + "claude", + "claude.EXE", + "claude.exe", + "claude.CMD", + "claude.cmd", + ]); + }); + + it("asks about every spelling in every non-empty PATH directory, in order, and nowhere else", () => { + const asked: string[] = []; + const found = resolveExecutableOnPath("claude", { + platform: "linux", + pathExt: undefined, + pathEnv: "/usr/local/bin::/opt/bin:", + isExecutable: (path) => { + asked.push(path); + return false; + }, + }); + + expect(found).toBeUndefined(); + expect(asked).toStrictEqual(["/usr/local/bin/claude", "/opt/bin/claude"]); + }); + + it("asks nothing at all when PATH is unset", () => { + const asked: string[] = []; + resolveExecutableOnPath("claude", { + platform: "linux", + pathExt: undefined, + pathEnv: undefined, + isExecutable: (path) => { + asked.push(path); + return true; + }, + }); + + expect(asked).toStrictEqual([]); + }); + + it("recognises a shim by its final extension only", () => { + expect(runsThroughShell("C:\\tools\\claude.cmd.txt")).toBe(false); + }); + + it("quotes an empty argument, doubles an embedded quote, and quotes the interpreter's own characters", () => { + expect(windowsCommandLine("claude.cmd", ["", 'say "hi"', "100%", "a^b", "x!y", "(p)"])).toBe( + 'claude.cmd "" "say ""hi""" "100%" "a^b" "x!y" "(p)"' + ); + }); + + it("describes this machine, and answers false rather than nothing for a path that is not there", () => { + const lookup = hostExecutableLookup(); + + expect(lookup.platform).toBe(process.platform); + expect(lookup.pathEnv).toBe(process.env.PATH); + expect(lookup.pathExt).toBe(process.env.PATHEXT); + expect(lookup.isExecutable("/definitely/not/here/claude")).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts index 6129034b6..3a8045a45 100644 --- a/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts @@ -117,3 +117,60 @@ describe("Claude Code's own known_marketplaces.json", () => { expect(reading.unreadable).toBeDefined(); }); }); + +describe("which hosts declare a marketplace registry to read", () => { + it("is claude alone, read off the profiles rather than a list", () => { + expect([...hostMarketplaceRegistryReaders(home).keys()]).toStrictEqual(["claude"]); + }); +}); + +describe("Claude Code's known_marketplaces.json, at the edges of its shape", () => { + it("reads a document that is not an object as unreadable, and says so", async () => { + for (const content of ["[]", "null", '"x"']) { + await write(content); + + const reading = await reader().read(); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toBe("not a JSON object"); + } + }); + + it("names the parse failure of malformed JSON rather than calling it a wrong shape", async () => { + await write("{ not json"); + + const reading = await reader().read(); + + expect(reading.unreadable).toMatch(/JSON/); + expect(reading.unreadable).not.toBe("not a JSON object"); + }); + + it("skips an entry that names no install location, whatever else it carries", async () => { + const target = join(home, "srcA"); + await mkdir(target, { recursive: true }); + await write( + JSON.stringify({ + "null-entry": null, + "string-entry": "x", + "no-location": { source: { path: target } }, + "wrong-type": { installLocation: 5 }, + real: { installLocation: target }, + }) + ); + + const reading = await reader().read(); + + expect([...(reading.entries ?? [])]).toStrictEqual([["real", target]]); + }); +}); + +describe("Claude Code's known_marketplaces.json, a registration whose source is gone", () => { + it("keeps the dead path as written rather than dropping the entry or failing the read", async () => { + const gone = join(home, "deleted-src"); + await write(JSON.stringify({ dead: { installLocation: gone } })); + + const reading = await reader().read(); + + expect([...(reading.entries ?? [])]).toStrictEqual([["dead", gone]]); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts index 3bd5298b8..2d3c22910 100644 --- a/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts @@ -311,3 +311,131 @@ describe("Copilot's own settings.json", () => { expect((await readerFor("copilot").read(PROJECT)).unreadable).toBe("ENOENT"); }); }); + +describe("Claude Code's registry, at the edges of its shape", () => { + const PATH = ".claude/plugins/installed_plugins.json"; + + it("reads a plugins field that is not an object as unreadable, naming what was expected", async () => { + await write(PATH, JSON.stringify({ version: 1, plugins: 5 })); + + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs).toBeUndefined(); + expect(reading.unreadable).toBe("no `plugins` object"); + }); + + it("ignores a ref whose entries are not a list", async () => { + await write(PATH, JSON.stringify({ version: 1, plugins: { "a@m": { scope: "user" } } })); + + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs?.size).toBe(0); + }); + + it("answers user scope when one entry is user-scoped, whatever the others say", async () => { + await write( + PATH, + JSON.stringify({ + version: 1, + plugins: { + "a@m": [{ scope: "project", projectPath: "/repo/other" }, { scope: "user" }], + }, + }) + ); + + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: true, scope: "user" }); + }); +}); + +describe("Codex's config.toml, at the edges of its shape", () => { + const PATH = ".codex/config.toml"; + + it("reads a plugin table whose header is indented", async () => { + await write(PATH, ' [plugins."a@m"]\n enabled = false\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: false }); + }); + + it("reads no table off a line that only contains a header, or carries text after one", async () => { + await write(PATH, 'x[plugins."a@m"]\n[plugins."b@m"] junk\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.size).toBe(0); + }); + + it("reads an enabled line however the spaces around its equals sign fall", async () => { + await write( + PATH, + '[plugins."a@m"]\nenabled=false\n[plugins."b@m"]\nenabled = false\n[plugins."c@m"]\nenabled =false # off\n' + ); + + const reading = await readerFor("codex").read(PROJECT); + + expect([...(reading.refs ?? [])]).toStrictEqual([ + ["a@m", { enabled: false }], + ["b@m", { enabled: false }], + ["c@m", { enabled: false }], + ]); + }); + + it("reads a value that is not a bare boolean as no answer, so the default stands", async () => { + await write(PATH, '[plugins."a@m"]\nenabled = falsey\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: true }); + }); + + it("does not mistake a bracket inside a value for the next table", async () => { + await write(PATH, '[plugins."a@m"]\nargs = ["x"]\nenabled = false\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: false }); + }); + + it("reads past a multi-line string inside the table body, and a header spelled inside it", async () => { + await write( + PATH, + '[plugins."a@m"]\nnote = """\n[plugins."fake@m"]\nenabled = true\n"""\nenabled = false\n' + ); + + const reading = await readerFor("codex").read(PROJECT); + + expect([...(reading.refs ?? [])]).toStrictEqual([["a@m", { enabled: false }]]); + }); + + it("treats the last table in the file as enabled when it declares nothing", async () => { + await write(PATH, '[projects."/x"]\ntrust = "trusted"\n[plugins."a@m"]\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: true }); + }); +}); + +describe("Copilot's settings.json, at the edges of its shape", () => { + it("reads malformed JSON as unreadable, never as carrying no plugins", async () => { + await write(".copilot/settings.json", "// comment\n{ not json"); + + const reading = await readerFor("copilot").read(PROJECT); + + expect(reading.refs).toBeUndefined(); + expect(reading.unreadable).toBeDefined(); + }); +}); + +describe("Codex's config.toml, keys that merely end in enabled", () => { + it("does not read another key ending in enabled as the plugin's own flag", async () => { + await write(".codex/config.toml", '[plugins."a@m"]\nauto_enabled = false\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: true }); + }); +});