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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions src/emitters/claude-code.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -11,7 +11,6 @@ export const claudeCode: Emitter = {
target: "claude-code",
async emit(ctx) {
const out: PlannedFile[] = [];
const mcp: Record<string, unknown> = {};
const t = "claude-code";

for (const ing of ctx.resolution.ingredients) {
Expand Down Expand Up @@ -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/*"));
Expand Down
8 changes: 3 additions & 5 deletions src/emitters/kiro.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<string, unknown> = {};
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;
Expand Down
21 changes: 21 additions & 0 deletions src/emitters/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
// A null prototype, so a server named `__proto__` is an entry and not the object's prototype.
const servers: Record<string, unknown> = Object.create(null);
// A Map, so a server named `constructor` or `toString` is not mistaken for one already written.
const writtenBy = new Map<string, string>();
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;
}
25 changes: 25 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down
14 changes: 14 additions & 0 deletions test/emitters/claude-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")!;
Expand Down
14 changes: 14 additions & 0 deletions test/emitters/kiro.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
});
});
Loading