diff --git a/package-lock.json b/package-lock.json index a20f68a..66db67d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 48a7874..6e989c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.2.1", + "version": "0.2.2", "description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.", "license": "MIT", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index 1177134..8655401 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,7 +25,7 @@ import { UnifyPlanSchema, type IngredientRef, type Take, type UnifyPlan } from " process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE") process.exit(0); }); const program = new Command(); -program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.2.1"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.2.2"); /* ---------------------------------------------------------------- import */ program diff --git a/src/emitters/claude-code.ts b/src/emitters/claude-code.ts index 7b8677e..fc6a939 100644 --- a/src/emitters/claude-code.ts +++ b/src/emitters/claude-code.ts @@ -1,6 +1,6 @@ import { serializeFrontmatter } from "../core/frontmatter.js"; import { listFiles } from "../core/forge.js"; -import { appliesTo, outName, textFile } from "./shared.js"; +import { appliesTo, mcpServers, outName, textFile } from "./shared.js"; import type { Emitter, EmitContext, PlannedFile } from "./types.js"; /** @@ -11,7 +11,6 @@ export const claudeCode: Emitter = { target: "claude-code", async emit(ctx) { const out: PlannedFile[] = []; - const mcp: Record = {}; const t = "claude-code"; for (const ing of ctx.resolution.ingredients) { @@ -56,13 +55,13 @@ export const claudeCode: Emitter = { for (const f of m.files) out.push(await anyFile(ctx, ing, f, `.claude/hooks/${f}`, t)); break; case "mcp": - mcp[m.name] = m.server; - break; + break; // collected into .mcp.json below case "steering": break; // Kiro-only by nature } } + const mcp = mcpServers(ctx, t, ".mcp.json"); if (Object.keys(mcp).length) { const json = JSON.stringify({ mcpServers: mcp }, null, 2) + "\n"; out.push(await textFile(ctx, ".mcp.json", json, t, "mcp/*")); diff --git a/src/emitters/kiro.ts b/src/emitters/kiro.ts index 0013988..db10cb0 100644 --- a/src/emitters/kiro.ts +++ b/src/emitters/kiro.ts @@ -1,7 +1,7 @@ import { toCrlf } from "../core/text.js"; import { serializeFrontmatter } from "../core/frontmatter.js"; import { listFiles } from "../core/forge.js"; -import { appliesTo, outName } from "./shared.js"; +import { appliesTo, mcpServers, outName } from "./shared.js"; import type { Emitter, EmitContext, PlannedFile } from "./types.js"; import type { ResolvedIngredient } from "../core/resolve.js"; @@ -79,10 +79,8 @@ export const kiro: Emitter = { } } - const mcp = ctx.resolution.ingredients.filter((i) => i.meta.type === "mcp" && appliesTo(i.meta.targets, t)); - if (mcp.length) { - const servers: Record = {}; - for (const i of mcp) if (i.meta.type === "mcp") servers[i.meta.name] = i.meta.server; + const servers = mcpServers(ctx, t, ".kiro/settings/mcp.json"); + if (Object.keys(servers).length) { out.push(crlf(".kiro/settings/mcp.json", JSON.stringify({ mcpServers: servers }, null, 2) + "\n", "mcp/*")); } return out; diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 5000753..2978c0e 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -20,3 +20,24 @@ export async function textFile(ctx: EmitContext, relPath: string, text: string, export function outName(m: { name: string; as?: string }): string { return m.as ?? m.name; } + +/** + * The MCP servers a target writes into its one JSON file, keyed by `outName` so a variant keeps the + * server name its workspace uses. The file holds one entry per name, so when two ingredients write + * the same name the earlier one is dropped — said out loud, never silently. + */ +export function mcpServers(ctx: EmitContext, target: string, file: string): Record { + // A null prototype, so a server named `__proto__` is an entry and not the object's prototype. + const servers: Record = Object.create(null); + // A Map, so a server named `constructor` or `toString` is not mistaken for one already written. + const writtenBy = new Map(); + for (const ing of ctx.resolution.ingredients) { + if (ing.meta.type !== "mcp" || !appliesTo(ing.meta.targets, target)) continue; + const key = outName(ing.meta); + const prev = writtenBy.get(key); + if (prev) ctx.warn(`${target}: two ingredients write the MCP server "${key}" into ${file}: ${prev} and ${ing.ref} (last wins)`); + servers[key] = ing.meta.server; + writtenBy.set(key, ing.ref); + } + return servers; +} diff --git a/test/cli.test.ts b/test/cli.test.ts index c7b4767..0ab5054 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -86,6 +86,31 @@ describe("cli", () => { expect(r.stdout + r.stderr).not.toContain("ghp_"); }); + it("a second workspace whose MCP server differs imports as a variant and syncs back to its own .mcp.json", async () => { + // Adopt, don't collide: since 0.2.1 the differing server becomes mcp/srv--b; it must be + // emitted under its original name, or the workspace's own .mcp.json reads as a collision. + const root = await tmpDir("craftar-cli-import-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + const forge = path.join(root, "forge"); + const ws = (name: string, args: string[]) => + writeFiles(path.join(root, name), { + ".claude/rules/workflow.md": "# Workflow\n", + ".mcp.json": JSON.stringify({ mcpServers: { srv: { command: "npx", args } } }, null, 2) + "\n", + }); + await ws("a", ["public-server"]); + await ws("b", ["acme-server"]); + expect(runCli(["import", "--from", "claude-code", "--workspace", path.join(root, "a"), "--forge", forge, "--profile", "a"]).code).toBe(0); + const imp = runCli(["import", "--from", "claude-code", "--workspace", path.join(root, "b"), "--forge", forge, "--profile", "b", "--write-config"]); + expect(imp.code).toBe(0); + expect(await exists(path.join(forge, "ingredients/mcp/srv--b"))).toBe(true); + + const st = runCli(["status", "--workspace", path.join(root, "b"), "--json"]); + expect(st.code).toBe(0); + const mcp = JSON.parse(st.stdout).statuses.find((s: { path: string }) => s.path === ".mcp.json"); + expect(mcp.state).not.toBe("collision"); + expect(["adopt", "unchanged"]).toContain(mcp.state); + }); + it("forge variants lists variants as JSON and leaves the Forge untouched", async () => { const root = await tmpDir("craftar-cli-forge-"); cleanups.push(() => fs.rm(root, { recursive: true, force: true })); diff --git a/test/emitters/claude-code.test.ts b/test/emitters/claude-code.test.ts index 7d88df8..b92f069 100644 --- a/test/emitters/claude-code.test.ts +++ b/test/emitters/claude-code.test.ts @@ -52,6 +52,20 @@ describe("claude-code emitter", () => { expect(text(p, ".mcp.json")).toBe(JSON.stringify({ mcpServers: { pw: { command: "npx", args: ["-y", "pw"] }, docs: { url: "https://mcp.example.com/sse" } } }, null, 2) + "\n"); }); + it("emits an MCP variant under its original server name", async () => { + const p = await planFor([{ meta: { type: "mcp", name: "srv--acme", as: "srv", server: { command: "npx", args: ["acme-server"] } } }]); + expect(text(p, ".mcp.json")).toBe(JSON.stringify({ mcpServers: { srv: { command: "npx", args: ["acme-server"] } } }, null, 2) + "\n"); + }); + + it("warns when two MCP ingredients emit the same server name, instead of dropping one silently", async () => { + const p = await planFor([ + { meta: { type: "mcp", name: "srv", server: { command: "npx", args: ["public-server"] } } }, + { meta: { type: "mcp", name: "srv--acme", as: "srv", server: { command: "npx", args: ["acme-server"] } } }, + ]); + expect(p.warnings).toContain('claude-code: two ingredients write the MCP server "srv" into .mcp.json: mcp/srv and mcp/srv--acme (last wins)'); + expect(text(p, ".mcp.json")).toBe(JSON.stringify({ mcpServers: { srv: { command: "npx", args: ["acme-server"] } } }, null, 2) + "\n"); + }); + it("keeps the BOM of the file it replaces", async () => { const p = await planFor([rule("a", "# A\n")], { ".claude/rules/a.md": "\uFEFF# old\n" }); const f = p.files.find((x) => x.path === ".claude/rules/a.md")!; diff --git a/test/emitters/kiro.test.ts b/test/emitters/kiro.test.ts index dba40a4..636f14f 100644 --- a/test/emitters/kiro.test.ts +++ b/test/emitters/kiro.test.ts @@ -82,4 +82,18 @@ describe("kiro emitter", () => { const p = await planFor([{ meta: { type: "script", name: "hello", files: ["hello.ps1"], targets: ["claude-code"] }, files: { "hello.ps1": "x\n" } }]); expect(p.warnings.filter((w) => w.startsWith("kiro: script"))).toEqual([]); }); + + it("emits an MCP variant under its original server name", async () => { + const p = await planFor([{ meta: { type: "mcp", name: "srv--acme", as: "srv", server: { command: "npx", args: ["acme-server"] } } }]); + const json = JSON.parse(file(p, ".kiro/settings/mcp.json")!.content.toString("utf8")); + expect(Object.keys(json.mcpServers)).toEqual(["srv"]); + }); + + it("warns when two MCP ingredients emit the same server name, instead of dropping one silently", async () => { + const p = await planFor([ + { meta: { type: "mcp", name: "srv", server: { command: "npx", args: ["public-server"] } } }, + { meta: { type: "mcp", name: "srv--acme", as: "srv", server: { command: "npx", args: ["acme-server"] } } }, + ]); + expect(p.warnings).toContain('kiro: two ingredients write the MCP server "srv" into .kiro/settings/mcp.json: mcp/srv and mcp/srv--acme (last wins)'); + }); });