diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index 22611e29f..2d6a38ff0 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -46,11 +46,11 @@ }, "tools-codex": { "mutate": "src/contexts/tools/domain/profiles/codex/**/*.ts", - "break": 87 + "break": 94 }, "tools-copilot": { "mutate": "src/contexts/tools/domain/profiles/copilot/**/*.ts", - "break": 80 + "break": 93 }, "tools-cursor": { "mutate": "src/contexts/tools/domain/profiles/cursor/**/*.ts", @@ -58,7 +58,7 @@ }, "tools-opencode": { "mutate": "src/contexts/tools/domain/profiles/opencode/**/*.ts", - "break": 81 + "break": 94 } }, "excluded": { diff --git a/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts index 3fc7c5d4e..e253cebaa 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts @@ -54,6 +54,11 @@ describe("codex", () => { const path = codex.capabilities.skills.buildInstallPath("my-skill"); expect(path).toBe(".agents/skills/aidd-my-skill/SKILL.md"); }); + + it("keeps a skill folder's name whole, even one ending in .md", () => { + const path = codex.capabilities.skills.buildInstallPath("notes.md/SKILL.md"); + expect(path).toBe(".agents/skills/aidd-notes.md/SKILL.md"); + }); }); it("names the one config file Codex reads, and where it goes", () => { @@ -268,6 +273,21 @@ describe("mergeCodexHooksJson()", () => { expect(mergeCodexHooksJson(once)).toBe(once); }); + it("finds its own hook inside any group of a user's SessionStart, and adds no second one", () => { + const existing = JSON.stringify( + { + SessionStart: [ + { hooks: [{ type: "command", command: "user-start.sh" }] }, + { hooks: [{ type: "command", command: "user-two.sh" }, AIDD_ENTRY.hooks[0]] }, + ], + }, + null, + 2 + ); + + expect(mergeCodexHooksJson(existing)).toBe(existing); + }); + it("starts over from a file it cannot read rather than failing the install", () => { expect(mergeCodexHooksJson("{ not json")).toBe( JSON.stringify({ SessionStart: [AIDD_ENTRY] }, null, 2) @@ -360,6 +380,42 @@ enabled = true const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); expect(result).toContain(".agents/skills"); }); + + it("starts over from a config it cannot parse rather than failing the install", () => { + expect(mergeCodexConfigToml("not = [valid", '[mcp_servers.ctx]\ncommand = "node"\n')).toBe( + 'project_doc_max_bytes = 262144\n\n[mcp_servers.ctx]\ncommand = "node"\n\n[features]\nhooks = true\n' + ); + }); + + it("adds only its defaults when the payload names no mcp server", () => { + expect(mergeCodexConfigToml('[mcp_servers.mine]\ncommand = "x"\n', "")).toBe( + 'project_doc_max_bytes = 262144\n\n[mcp_servers.mine]\ncommand = "x"\n\n[features]\nhooks = true\n' + ); + }); + + it("raises project_doc_max_bytes to what the payload asks when that is above the floor", () => { + expect(mergeCodexConfigToml("", "project_doc_max_bytes = 500000\n")).toBe( + "project_doc_max_bytes = 500000\n\n[features]\nhooks = true\n" + ); + }); + + it("leaves a user's project_doc_max_bytes already at the floor untouched, whatever the payload asks", () => { + expect( + mergeCodexConfigToml("project_doc_max_bytes = 262144\n", "project_doc_max_bytes = 500000\n") + ).toBe("project_doc_max_bytes = 262144\n\n[features]\nhooks = true\n"); + }); + + it("keeps a user's own hooks = false rather than turning hooks on", () => { + expect(mergeCodexConfigToml("[features]\nhooks = false\n", "")).toBe( + "project_doc_max_bytes = 262144\n\n[features]\nhooks = false\n" + ); + }); + + it("turns hooks on beside a user's other features, keeping them", () => { + expect(mergeCodexConfigToml("[features]\nweb_search = true\n", "")).toBe( + "project_doc_max_bytes = 262144\n\n[features]\nweb_search = true\nhooks = true\n" + ); + }); }); /** @@ -392,6 +448,10 @@ describe("a skill's frontmatter, rewritten for Codex", () => { expect(stripCodexSkillFrontmatter({ description: "d" })).toEqual({ description: "d" }); }); + it("writes no description key for a skill that has none", () => { + expect(stripCodexSkillFrontmatter({ name: "n" })).toStrictEqual({ name: "n" }); + }); + it("quotes a value whose colon would otherwise make the frontmatter unreadable", () => { // Not cosmetic: a description containing ": " makes `js-yaml` refuse the source with "bad // indentation of a mapping entry". Re-serialising with quotes is what makes it parse. diff --git a/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts index eb4316145..e1966316b 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts @@ -23,6 +23,14 @@ const HOOKS_JSON = JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: "node journal.cjs" }] }] }, }); +const NO_CONTENT = { + hasAgents: false, + agentsList: [], + skillsList: [], + hasHooksJson: false, + hasMcpJson: false, +}; + function supported(artifact: ArtifactContract): Extract { if (!artifact.supported) throw new Error("artifact is declared unsupported"); return artifact; @@ -92,6 +100,27 @@ describe("buildCodexContract()", () => { expect(manifest).toStrictEqual({ name: "aidd-dev" }); }); + it("copies an author given as a plain name into the manifest", () => { + const manifest = buildCodexContract().synthesizeManifest?.( + { name: "aidd-dev", author: "AIDD" }, + NO_CONTENT + ); + + expect(manifest).toStrictEqual({ name: "aidd-dev", author: "AIDD" }); + }); + + it("copies an author given as an object into the manifest", () => { + const manifest = buildCodexContract().synthesizeManifest?.( + { name: "aidd-dev", author: { name: "AIDD", email: "team@example.test" } }, + NO_CONTENT + ); + + expect(manifest).toStrictEqual({ + name: "aidd-dev", + author: { name: "AIDD", email: "team@example.test" }, + }); + }); + it("sources skills, agents, mcp and hooks from the plugin tree, and neither rules nor commands", () => { const { artifacts } = buildCodexContract(); @@ -128,6 +157,14 @@ describe("buildCodexContract()", () => { }); }); + it("changes only the trailing .md of a staged agent's name to .toml", () => { + const agents = supported(buildCodexContract().artifacts.agents); + + expect(agents.path("aidd-dev", "agents/notes.md-helper.md")).toBe( + "codex-agents/notes.md-helper.toml" + ); + }); + it("keeps only the three frontmatter fields Codex reads in a skill", () => { const transform = supported(buildCodexContract().artifacts.skills).transform; @@ -259,6 +296,14 @@ describe("buildCodexFlatContract()", () => { ); }); + it("changes only the trailing .md of a flat agent's name to .toml", () => { + const agents = supported(buildCodexFlatContract().artifacts.agents); + + expect(agents.path("aidd-dev", "agents/notes.md-helper.md")).toBe( + ".codex/agents/aidd-dev-notes.md-helper.toml" + ); + }); + it("names a flat agent after its plugin, whatever its own frontmatter says", () => { const transform = supported(buildCodexFlatContract().artifacts.agents).transform; @@ -312,4 +357,21 @@ describe("buildCodexFlatContract()", () => { 'project_doc_max_bytes = 262144\n\n[mcp_servers.aidd-dev-context]\ncommand = "node"\n\n[features]\nhooks = true\n', }); }); + + it("writes no mcp_servers table when no built plugin ships an mcp server", async () => { + const fs = new InMemoryFileAdapter(); + + await buildCodexFlatContract().emitConfigArtifact?.( + ["aidd-dev"], + "/out", + "/src", + fs, + { validate: () => undefined }, + { loadConfigAsset: () => ({}), loadSchema: () => ({}) } + ); + + expect(fs.getFile("/out/.codex/config.toml")).toBe( + "project_doc_max_bytes = 262144\n\n[features]\nhooks = true\n" + ); + }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts index 6ec6f14b2..1d1cb9cd5 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts @@ -221,3 +221,30 @@ describe("codexAgentMarkdownToToml()", () => { }); }); }); + +describe("the name a Codex agent falls back to", () => { + it("ignores an empty frontmatter name and names the agent after its plugin and file", () => { + expect( + codexAgentMarkdownToToml( + "---\nname: ''\ndescription: Plans\n---\nBody.\n", + "aidd-dev", + "planner.md" + ) + ).toBe( + 'name = "aidd-dev-planner"\ndescription = "Plans"\ndeveloper_instructions = "Body.\\n"\n' + ); + }); + + it("drops only the trailing .md from the file it names a flat agent after", () => { + expect( + codexAgentMarkdownToToml( + "---\ndescription: Plans\n---\nBody.\n", + "aidd-dev", + "notes.md-helper.md", + true + ) + ).toBe( + 'name = "aidd-dev-notes.md-helper"\ndescription = "Plans"\ndeveloper_instructions = "Body.\\n"\n' + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/codex-transcript-location.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/codex-transcript-location.unit.test.ts new file mode 100644 index 000000000..16cc16469 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/codex-transcript-location.unit.test.ts @@ -0,0 +1,11 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CODEX_ROLLOUT_LOCATION } from "../../../../../../src/contexts/tools/domain/profiles/codex/codex-transcript-location.js"; + +describe("where Codex keeps a session's rollout", () => { + it("claims no file that is not a rollout, even one named for the session", () => { + expect( + CODEX_ROLLOUT_LOCATION.matches(join("2026", "01", "02", "history-abc.jsonl"), "abc") + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts index 95cc7a7b0..2a15d8eeb 100644 --- a/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts @@ -29,6 +29,39 @@ describe("copilot", () => { const result = copilot.capabilities.agents.convertFrontmatter(fm); expect(result).toEqual({ name: "alexia", description: "Agent" }); }); + + it("names an unnamed agent after its own file, whatever folders it sits in", () => { + expect( + copilot.capabilities.agents.convertFrontmatter( + { description: "Reviews code" }, + "team/reviews/code-reviewer.md" + ) + ).toStrictEqual({ name: "code-reviewer", description: "Reviews code" }); + }); + + it("drops only the trailing .md from the file it names an agent after", () => { + expect( + copilot.capabilities.agents.convertFrontmatter( + { description: "Helps" }, + "notes.md-helper.md" + ) + ).toStrictEqual({ name: "notes.md-helper", description: "Helps" }); + }); + + it("leaves an agent unnamed when neither its frontmatter nor a file name gives one", () => { + expect( + copilot.capabilities.agents.convertFrontmatter({ description: "Helps" }) + ).toStrictEqual({ + name: undefined, + description: "Helps", + }); + }); + + it("drops a name that is not a string rather than writing it", () => { + expect( + copilot.capabilities.agents.convertFrontmatter({ name: 42, description: "Helps" }, "x.md") + ).toStrictEqual({ name: undefined, description: "Helps" }); + }); }); describe("capabilities.mcp", () => { @@ -41,6 +74,28 @@ describe("copilot", () => { }); }); + describe("capabilities.mcp.params.transformContent()", () => { + const transform = (content: string): string | undefined => + copilot.capabilities.mcp.params.transformContent?.(content); + + it("renames mcpServers to the servers key VS Code reads, keeping every other key", () => { + const content = JSON.stringify({ mcpServers: { ctx: { command: "node" } }, inputs: [] }); + expect(transform(content)).toBe( + JSON.stringify({ inputs: [], servers: { ctx: { command: "node" } } }, null, 2) + ); + }); + + it("returns a config already keyed by servers byte for byte", () => { + const content = JSON.stringify({ servers: { ctx: { command: "node" } } }); + expect(transform(content)).toBe(content); + }); + + it("renames nothing when a config carries both keys, rather than overwriting servers", () => { + const content = JSON.stringify({ servers: { a: {} }, mcpServers: { b: {} } }); + expect(transform(content)).toBe(content); + }); + }); + describe("capabilities.settings", () => { const settings = Array.isArray(copilot.capabilities.settings) ? copilot.capabilities.settings[0] @@ -83,6 +138,26 @@ describe("copilot", () => { const path = copilot.capabilities.commands?.buildInstallPath("commit.md"); expect(path).toBe(".github/prompts/commit.prompt.md"); }); + + it("prefixes a command nested two folders deep with both folders' phase numbers", () => { + const path = copilot.capabilities.commands?.buildInstallPath("01_plan/02_steps/review.md"); + expect(path).toBe(".github/prompts/01-02-review.prompt.md"); + }); + + it("keeps a folder name whole when it does not start with a phase number", () => { + const path = copilot.capabilities.commands?.buildInstallPath("v2-tools/x.md"); + expect(path).toBe(".github/prompts/v2-tools-x.prompt.md"); + }); + + it("does not add the prompt extension twice to a file that already carries it", () => { + const path = copilot.capabilities.commands?.buildInstallPath("review.prompt.md"); + expect(path).toBe(".github/prompts/review.prompt.md"); + }); + + it("adds the prompt extension to a file that carries no extension at all", () => { + const path = copilot.capabilities.commands?.buildInstallPath("commit"); + expect(path).toBe(".github/prompts/commit.prompt.md"); + }); }); describe("capabilities.rules.buildInstallPath()", () => { @@ -96,6 +171,16 @@ describe("copilot", () => { expect(path).toBe(".github/instructions/01-naming.instructions.md"); }); + it("strips a numeric prefix of several digits from a rule's file name", () => { + const path = copilot.capabilities.rules?.buildInstallPath("01-standards/10-mermaid.md"); + expect(path).toBe(".github/instructions/01-mermaid.instructions.md"); + }); + + it("keeps a number inside a rule's file name, stripping only a leading one", () => { + const path = copilot.capabilities.rules?.buildInstallPath("01-standards/naming-2-rules.md"); + expect(path).toBe(".github/instructions/01-naming-2-rules.instructions.md"); + }); + it("flattens rules: strips .copilot tool suffix from filename", () => { const path = copilot.capabilities.rules?.buildInstallPath( "04-tooling/ide-mapping.copilot.md" @@ -117,6 +202,16 @@ describe("copilot", () => { it("returns null for .gitkeep files", () => { expect(copilot.capabilities.agents.buildInstallPath(".gitkeep")).toBeNull(); }); + + it("names a nested agent after its own file, not the folders above it", () => { + const path = copilot.capabilities.agents.buildInstallPath("team/sub/code-reviewer.md"); + expect(path).toBe(".github/agents/code-reviewer.agent.md"); + }); + + it("keeps a file that is not markdown under its own name", () => { + const path = copilot.capabilities.agents.buildInstallPath("helper.txt"); + expect(path).toBe(".github/agents/helper.txt"); + }); }); describe("capabilities.skills.buildInstallPath()", () => { @@ -140,6 +235,24 @@ describe("copilot", () => { ).toEqual({ description: "Apply when editing command files." }); }); + it("joins several path patterns into one comma-separated applyTo", () => { + expect( + copilot.capabilities.rules?.convertFrontmatter({ paths: ["src/**/*.ts", "tests/**/*.ts"] }) + ).toStrictEqual({ applyTo: "src/**/*.ts,tests/**/*.ts" }); + }); + + it("writes no description key when alwaysApply is false and there is no description", () => { + expect(copilot.capabilities.rules?.convertFrontmatter({ alwaysApply: false })).toStrictEqual( + {} + ); + }); + + it("drops the description of a rule that always applies", () => { + expect( + copilot.capabilities.rules?.convertFrontmatter({ description: "Always on." }) + ).toStrictEqual({}); + }); + it("converts globs + alwaysApply: false from framework to applyTo", () => { expect( copilot.capabilities.rules?.convertFrontmatter({ @@ -276,6 +389,54 @@ describe("a reference to another framework file, installed for Copilot", () => { "Everything under [.github/agents/](../../.github/agents/) applies" ); }); + + it("resolves every other section's directory reference to its installed directory", () => { + expect(rewrite("@{{TOOLS}}/commands/ @{{TOOLS}}/rules/ @{{TOOLS}}/skills/")).toBe( + "[.github/prompts/](../../.github/prompts/) " + + "[.github/instructions/](../../.github/instructions/) " + + "[.github/skills/](../../.github/skills/)" + ); + }); + + it("keeps a nested directory reference's folder rather than flattening it", () => { + expect( + rewrite( + "@{{TOOLS}}/agents/team/ @{{TOOLS}}/commands/01-plan/ @{{TOOLS}}/rules/01-standards/" + ) + ).toBe( + "[.github/agents/team/](../../.github/agents/team/) " + + "[.github/prompts/01-plan/](../../.github/prompts/01-plan/) " + + "[.github/instructions/01-standards/](../../.github/instructions/01-standards/)" + ); + }); + + it("keeps a reference to a section's .gitkeep under its own section path", () => { + expect( + rewrite( + "@{{TOOLS}}/agents/.gitkeep @{{TOOLS}}/commands/.gitkeep @{{TOOLS}}/rules/.gitkeep @{{TOOLS}}/skills/.gitkeep" + ) + ).toBe( + "[.github/agents/.gitkeep](../../.github/agents/.gitkeep) " + + "[.github/commands/.gitkeep](../../.github/commands/.gitkeep) " + + "[.github/rules/.gitkeep](../../.github/rules/.gitkeep) " + + "[.github/skills/.gitkeep](../../.github/skills/.gitkeep)" + ); + }); + + it("rewrites every reference in the content, not only the first", () => { + expect( + rewrite("@{{TOOLS}}/agents/a.md @{{TOOLS}}/agents/b.md @{{DOCS}}/a.md @{{DOCS}}/b.md") + ).toBe( + "[.github/agents/a.agent.md](../../.github/agents/a.agent.md) " + + "[.github/agents/b.agent.md](../../.github/agents/b.agent.md) " + + "[aidd_docs/a.md](../../aidd_docs/a.md) " + + "[aidd_docs/b.md](../../aidd_docs/b.md)" + ); + }); + + it("leaves alone a placeholder that only resembles {{TOOLS}}", () => { + expect(rewrite("See @{{TOOLX}}/agents/x.md")).toBe("See @{{TOOLX}}/agents/x.md"); + }); }); describe("a plain path reference, which stays plain text", () => { diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts index 88b3e8c4a..356012722 100644 --- a/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts @@ -295,4 +295,12 @@ describe("buildCopilotFlatContract()", () => { ].join("\n") ); }); + + it("drops only the trailing .md from the name it gives a flat agent", () => { + const transform = supported(buildCopilotFlatContract().artifacts.agents).transform; + + expect( + transform?.("---\ndescription: Helps\n---\nBody.\n", "aidd-dev", "notes.md-helper.md") + ).toBe("---\ndescription: 'Helps'\nname: 'aidd-dev-notes.md-helper'\n---\nBody.\n"); + }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts index 5d1b921ee..c9c40137c 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts @@ -92,6 +92,12 @@ describe("opencode", () => { }); expect(result).toEqual({ description: "Apply when editing command files." }); }); + + it("writes no description key when alwaysApply is false and there is no description", () => { + expect(opencode.capabilities.rules?.convertFrontmatter({ alwaysApply: false })).toStrictEqual( + {} + ); + }); }); describe("capabilities.skills.buildInstallPath()", () => { @@ -122,6 +128,11 @@ describe("opencode", () => { it("uses framework-prime merge strategy", () => { expect(opencode.capabilities.mcp.params.mergeStrategy).toBe("framework-prime"); }); + + it("writes its config as JSON, under the mcp key", () => { + const { format, entrySection } = opencode.capabilities.mcp.params; + expect({ format, entrySection }).toStrictEqual({ format: "json", entrySection: "mcp" }); + }); }); describe("capabilities.mcp.transform() (MCP transform)", () => { @@ -339,3 +350,15 @@ describe("opencode", () => { }); }); }); + +describe("opencode.rewriteContent()", () => { + it("routes a numbered command folder under commands/aidd//, with or without the @ prefix", () => { + expect( + opencode.rewriteContent( + "Run .opencode/commands/04_code/implement.md, then @.opencode/commands/02-plan/plan.md.\n" + ) + ).toBe( + "Run .opencode/commands/aidd/04/implement.md, then @.opencode/commands/aidd/02/plan.md.\n" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts index f51658fb3..6eabd24ca 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts @@ -80,6 +80,11 @@ describe("transformMcpToOpencode()", () => { expect(() => transformMcpToOpencode("[]")).toThrow("MCP config must be a JSON object"); expect(() => transformMcpToOpencode("{ not json")).toThrow(/Cannot parse MCP config/); }); + + it("refuses a config that is null or a bare value", () => { + expect(() => transformMcpToOpencode("null")).toThrow("MCP config must be a JSON object"); + expect(() => transformMcpToOpencode("42")).toThrow("MCP config must be a JSON object"); + }); }); describe("buildOpencodeFlatContract()", () => { @@ -159,6 +164,16 @@ describe("buildOpencodeFlatContract()", () => { ); }); + it("drops only the trailing .md from the name it gives a flat agent", () => { + const transform = supported(buildOpencodeFlatContract().artifacts.agents).transform; + + expect( + transform?.("---\ndescription: Helps\n---\nBody.\n", "aidd-dev", "notes.md-helper.md") + ).toBe( + "---\ndescription: 'Helps'\nname: 'aidd-dev-notes.md-helper'\nmode: 'subagent'\n---\nBody.\n" + ); + }); + it("delivers a plugin's own OpenCode module where the loader scans, and every other script apart", () => { const hooks = supported(buildOpencodeFlatContract().artifacts.hooks); @@ -256,4 +271,49 @@ describe("buildOpencodeFlatContract()", () => { json: fs.has("/out/opencode.json"), }).toStrictEqual({ jsonc: true, json: false }); }); + + it("builds opencode.json on opencode's own bundled base config", async () => { + const fs = new InMemoryFileAdapter(); + + await buildOpencodeFlatContract().emitConfigArtifact?.( + [], + "/out", + "/src", + fs, + { validate: () => undefined }, + { + loadConfigAsset: (tool, name) => { + if (tool !== "opencode" || name !== "opencode.json") { + throw new Error(`no bundled asset ${tool}/${name}`); + } + return { $schema: "https://opencode.ai/config.json" }; + }, + loadSchema: () => ({}), + } + ); + + expect(JSON.parse(fs.getFile("/out/opencode.json") ?? "null")).toStrictEqual({ + $schema: "https://opencode.ai/config.json", + }); + }); + + it("takes a bundled base config that comes as text as written", async () => { + const fs = new InMemoryFileAdapter(); + + await buildOpencodeFlatContract().emitConfigArtifact?.( + [], + "/out", + "/src", + fs, + { validate: () => undefined }, + { + loadConfigAsset: () => '{"$schema":"https://opencode.ai/config.json"}', + loadSchema: () => ({}), + } + ); + + expect(JSON.parse(fs.getFile("/out/opencode.json") ?? "null")).toStrictEqual({ + $schema: "https://opencode.ai/config.json", + }); + }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts index e63967f37..c633609e0 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { generateOpencodeHooksBridge } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.js"; +import { + generateOpencodeHooksBridge, + parseHooksJsonForBridge, +} from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.js"; // Built rather than written as a literal "${CLAUDE_PLUGIN_ROOT}" string: biome reads a plain // string holding "${...}" as a forgotten template literal. @@ -148,6 +151,40 @@ describe("generateOpencodeHooksBridge", () => { expect(generateOpencodeHooksBridge(JSON.stringify({ hooks: {} }), "aidd-test")).toBeNull(); }); + it("generates a bridge for a plugin whose hooks name only Stop", () => { + const hooksJson = JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: "command", command: `node ${ROOT}/hooks/journal.cjs turn-end` }] }, + ], + }, + }); + + expect(generateOpencodeHooksBridge(hooksJson, "aidd-test")).toContain( + 'const SESSION_START = [];\nconst STOP = [{"script":"journal.cjs","args":["turn-end"]}];\nconst POST_TOOL_USE = [];\n' + ); + }); + + it("generates a bridge for a plugin whose hooks name only PostToolUse", () => { + const hooksJson = JSON.stringify({ + hooks: { + PostToolUse: [ + { hooks: [{ type: "command", command: `node ${ROOT}/hooks/journal.cjs tool-used` }] }, + ], + }, + }); + + expect(generateOpencodeHooksBridge(hooksJson, "aidd-test")).toContain( + 'const SESSION_START = [];\nconst STOP = [];\nconst POST_TOOL_USE = [{"script":"journal.cjs","args":["tool-used"]}];\n' + ); + }); + + it("names the bridge's export after the plugin even when its name holds an empty segment", () => { + expect(generateOpencodeHooksBridge(THREE_EVENT_HOOKS_JSON, "aidd--sample")).toContain( + "export const AiddSampleHooks = async (input) => {" + ); + }); + it("drops a hook whose command does not invoke node against its own hooks/ script", () => { const hooksJson = JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: `${ROOT}/hooks/check.sh` }] }] }, @@ -156,3 +193,41 @@ describe("generateOpencodeHooksBridge", () => { expect(generateOpencodeHooksBridge(hooksJson, "aidd-test")).toBeNull(); }); }); + +describe("parseHooksJsonForBridge", () => { + const stopCallsOf = (command: string) => + parseHooksJsonForBridge( + JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command }] }] } }) + ).stop; + + it("splits a hook's arguments on any run of whitespace", () => { + expect(stopCallsOf(`node ${ROOT}/hooks/journal.cjs turn-end --quiet`)).toStrictEqual([ + { script: "journal.cjs", args: ["turn-end", "--quiet"] }, + ]); + }); + + it("reads a command written with surrounding whitespace", () => { + expect(stopCallsOf(` node ${ROOT}/hooks/journal.cjs `)).toStrictEqual([ + { script: "journal.cjs", args: [] }, + ]); + }); + + it("skips a hook that carries no command, keeping the ones beside it", () => { + const hooksJson = JSON.stringify({ + hooks: { + Stop: [ + { + hooks: [ + { type: "prompt" }, + { type: "command", command: `node ${ROOT}/hooks/journal.cjs` }, + ], + }, + ], + }, + }); + + expect(parseHooksJsonForBridge(hooksJson).stop).toStrictEqual([ + { script: "journal.cjs", args: [] }, + ]); + }); +});