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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

Format: [Keep a Changelog](https://keepachangelog.com). Versioning: semver — for skills *and* for this CLI, breaking prompt changes are breaking changes.

## [0.21.0] — 2026-08-09

MCP support doubles: six of the eleven targets now carry a declaration, including the two that keep servers inside a settings file full of unrelated user configuration.

### Added
- **`cursor` → `.cursor/mcp.json`** — a dedicated project file. `type` is written on stdio entries (Cursor's field table marks it required while its examples omit it, so writing it satisfies both readings) and omitted on remote entries, where Cursor documents no `type` at all. Its format has no field for a tool allowlist, so a declared one is reported as unenforced rather than silently widened.
- **`gemini` → `.gemini/settings.json`** and **`zed` → `.zed/settings.json`**, merged. Gemini gets an explicit `type` (a bare `url` there defaults to Streamable HTTP — the exact inverse of Cline, which defaults to SSE) and its own `includeTools` allowlist field. Zed gets no `type` at all (its settings enum is untagged), timeouts converted to **seconds** rather than the milliseconds every other target uses, and a warning when a timeout exceeds the 600s Zed silently clamps to.
- **A safe merge for shared settings files.** This is a destructive-write class — these files hold configuration that has nothing to do with skills, and they are the same paths malware targets for persistence. Three rules: only the server key is touched and every other key is preserved byte-for-byte; servers Kitbash did not write are left alone, so a hand-added one survives a compile; and a file that cannot be parsed is **never** overwritten. A settings file containing comments is refused outright, because `JSON.parse` cannot round-trip them and rewriting would silently delete the user's annotations.
- Gemini strips credential-shaped variables (`TOKEN`, `SECRET`, `KEY`, `AUTH`, …) from the environment it hands an MCP server, unconditionally. A server relying on an ambient credential fails to authenticate with no discoverable cause, so that now produces a warning naming the variables Gemini will strip.

## [0.20.0] — 2026-08-09

The token-cost argument, applied to MCP — the largest standing-context line item Kitbash was not measuring.
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,13 @@ tools = ["plan_diff", "list_environments"] # required: deny-by-default all
DEPLOY_TOKEN = "${ACME_DEPLOY_TOKEN}" # a reference; a literal credential fails the gate
```

Three targets have a project-scoped MCP config file, and those are the three Kitbash writes: `claude-code` → `.mcp.json`, `copilot` → `.github/mcp.json`, `agent-plugins` → `<plugin-root>/mcp.json`. Nothing is passed through verbatim — the client dialects disagree (the HTTP transport is `http` in Claude Code and Copilot, `streamable-http` in Agent Plugins), so every emitter translates and always writes an explicit `type`.
Six targets have a project-scoped MCP surface, and Kitbash writes all six: `claude-code` → `.mcp.json`, `copilot` → `.github/mcp.json`, `cursor` → `.cursor/mcp.json`, `agent-plugins` → `<plugin-root>/mcp.json`, plus `gemini` → `.gemini/settings.json` and `zed` → `.zed/settings.json`, which are **merged** rather than overwritten.

The other eight targets get a warning naming the specific reason — `no-mcp-surface` (aider, AGENTS.md have no configuration mechanism), `no-project-scope` (Cline and Windsurf are user-global only), `needs-shared-file-merge` (Zed and Gemini keep servers in a settings file full of unrelated user config) — and **no file**. A config the client never reads would look configured and do nothing, which is the failure mode this project exists to prevent.
Nothing is passed through verbatim, because the dialects genuinely disagree: the HTTP transport is `http` in Claude Code, Copilot and Gemini but `streamable-http` in Agent Plugins; timeouts are milliseconds everywhere except Zed, which uses seconds and silently clamps at 600; Zed wants no `type` key while Gemini needs one, since a bare `url` there defaults to Streamable HTTP. Every emitter translates.

Merging into a settings file is a destructive-write class — those files hold configuration with nothing to do with skills — so only the server key is touched, servers you added by hand survive, and a file Kitbash cannot parse (or one containing comments, which `JSON.parse` cannot round-trip) is **refused, never overwritten**.

The remaining five targets get a warning naming the specific reason — `no-mcp-surface` (aider and AGENTS.md have no configuration mechanism), `no-project-scope` (Cline and Windsurf are user-global only), `unconfirmed-path` (the `.agents` convention) — and **no file**. A config the client never reads would look configured and do nothing, which is the failure mode this project exists to prevent.

### What it costs you

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kitbash",
"version": "0.20.0",
"version": "0.21.0",
"description": "The package manager and compiler for AI agent skills — write once, run in every coding agent",
"license": "Apache-2.0",
"author": "Harsh Singh",
Expand Down
55 changes: 54 additions & 1 deletion packages/cli/scripts/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1615,7 +1615,7 @@ try {
const doc = run(["doctor"], polTmp);
check("doctor: lists declared MCP servers", doc.out.includes("MCP servers declared: outside"), doc.out);
check("doctor: shows which targets can carry them", doc.out.includes(".mcp.json") && doc.out.includes(".github/mcp.json"), doc.out);
check("doctor: shows why the others cannot", doc.out.includes("no-mcp-surface") && doc.out.includes("needs-shared-file-merge"), doc.out);
check("doctor: shows why the others cannot", doc.out.includes("no-mcp-surface") && doc.out.includes("no-project-scope"), doc.out);

// A stdio server is matched on its command line, not a url.
const stdioSrc = join(polTmp, "stdio");
Expand Down Expand Up @@ -1679,6 +1679,59 @@ try {
rmSync(budTmp, { recursive: true, force: true });
}

// ── merging MCP into shared settings files ───────────────────────────────────
// Zed and Gemini keep servers inside settings files carrying unrelated user
// config, so this is a destructive-write class: unrelated keys must survive, and
// a file kitbash cannot parse must never be overwritten.
const mgTmp = mkdtempSync(join(tmpdir(), "kitbash-merge-"));
try {
const ms = join(mgTmp, "src");
mkdirSync(ms, { recursive: true });
mkdirSync(join(mgTmp, ".gemini"), { recursive: true });
mkdirSync(join(mgTmp, ".zed"), { recursive: true });
writeFileSync(
join(ms, "skill.toml"),
'[skill]\nname = "merged"\nversion = "1.0.0"\ndescription = "Server merged into shared settings files"\n[context]\nbudget = 1500\n\n[mcp.servers.acme]\ntransport = "stdio"\ncommand = "npx"\nargs = ["-y", "@acme/a@1.0.0"]\ntools = ["t1"]\n',
);
writeFileSync(join(ms, "SKILL.md"), "# Merged\n\nBody.\n");
writeFileSync(join(mgTmp, ".gemini/settings.json"), JSON.stringify({ theme: "dark", mcpServers: { userOwn: { command: "mine" } } }, null, 2));
writeFileSync(join(mgTmp, ".zed/settings.json"), JSON.stringify({ vim_mode: true, buffer_font_size: 15 }, null, 2));
writeFileSync(join(mgTmp, "kitbash.toml"), '[project]\ntargets = ["cursor", "gemini", "zed"]\n');
run(["install", `file:${ms}`, "--yes"], mgTmp);
const mgc = run(["compile"], mgTmp);
check("merge: compile exits 0", mgc.status === 0, mgc.out);

const gem = JSON.parse(readFileSync(join(mgTmp, ".gemini/settings.json"), "utf8"));
check("merge: unrelated gemini settings survive", gem.theme === "dark", JSON.stringify(gem));
check("merge: the user's own server survives", gem.mcpServers.userOwn.command === "mine");
check("merge: our server is added alongside", gem.mcpServers.acme.command === "npx");
check("merge: gemini gets its own allowlist field", JSON.stringify(gem.mcpServers.acme.includeTools) === '["t1"]');

const zed = JSON.parse(readFileSync(join(mgTmp, ".zed/settings.json"), "utf8"));
check("merge: unrelated zed settings survive", zed.vim_mode === true && zed.buffer_font_size === 15, JSON.stringify(zed));
check("merge: zed uses context_servers, not mcpServers", !!zed.context_servers.acme && zed.mcpServers === undefined);

const cur = JSON.parse(readFileSync(join(mgTmp, ".cursor/mcp.json"), "utf8"));
check("merge: cursor gets a dedicated mcp.json with an explicit stdio type", cur.mcpServers.acme.type === "stdio");
check("merge: cursor warns that it cannot enforce the allowlist", mgc.out.includes("cursor:") && mgc.out.includes("tools allowlist"), mgc.out);

// A file with comments cannot be round-tripped by JSON.parse — refuse, never clobber.
writeFileSync(join(mgTmp, ".zed/settings.json"), '{\n // annotated\n "vim_mode": true\n}\n');
const before = readFileSync(join(mgTmp, ".zed/settings.json"), "utf8");
const cmt = run(["compile"], mgTmp);
check("merge: a commented settings file is refused", cmt.out.includes("contains comments"), cmt.out);
check("merge: and left byte-identical", readFileSync(join(mgTmp, ".zed/settings.json"), "utf8") === before);

// Same for a file we cannot parse at all.
writeFileSync(join(mgTmp, ".zed/settings.json"), '{ "vim_mode": true,,, }\n');
const before2 = readFileSync(join(mgTmp, ".zed/settings.json"), "utf8");
const broke = run(["compile"], mgTmp);
check("merge: an unparseable settings file is refused", broke.out.includes("not valid JSON"), broke.out);
check("merge: and left byte-identical", readFileSync(join(mgTmp, ".zed/settings.json"), "utf8") === before2);
} finally {
rmSync(mgTmp, { recursive: true, force: true });
}

if (failures) {
console.error(`\n${failures} test(s) failed`);
process.exit(1);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,7 +971,7 @@ export async function cmdCompile(args: string[]): Promise<number> {

const unsupported: string[] = [];
for (const adapter of adapters) {
const out = emitMcp(adapter.id, mcpServers, AGENT_PLUGIN_DIR);
const out = emitMcp(adapter.id, mcpServers, AGENT_PLUGIN_DIR, root);
for (const f of out.files) {
if (!resolveSubpath(root, f.path)) {
console.error(`✗ refusing to write outside the project: ${f.path}`);
Expand Down
Loading
Loading