Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/mutation-scopes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
Loading