diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dfe509..342afff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 9ec58f9..e848e8c 100644 --- a/README.md +++ b/README.md @@ -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` → `/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` → `/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 diff --git a/packages/cli/package.json b/packages/cli/package.json index 0ebe390..e4c048f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/scripts/test.mjs b/packages/cli/scripts/test.mjs index 891f277..12bf869 100644 --- a/packages/cli/scripts/test.mjs +++ b/packages/cli/scripts/test.mjs @@ -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"); @@ -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); diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 256e9f9..adfd9f2 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -971,7 +971,7 @@ export async function cmdCompile(args: string[]): Promise { 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}`); diff --git a/packages/cli/src/mcp.ts b/packages/cli/src/mcp.ts index d5703f7..bc9eedf 100644 --- a/packages/cli/src/mcp.ts +++ b/packages/cli/src/mcp.ts @@ -9,14 +9,15 @@ * It is a heavier request than anything in a SKILL.md body, so it is gated at * install like one: the lints here are hard failures, not warnings. * - * 2. **Most targets cannot honor it.** Of the eleven compile targets, only three - * have a dedicated, project-scoped, primary-source-confirmed config file. - * The rest either have no MCP mechanism at all (aider, agentsmd), no project - * scope (cline, windsurf), or need a merge into a shared settings file that - * also holds unrelated user config (zed, gemini). Emitting a plausible-looking - * file for those would produce something that reads as configured and does - * nothing — the exact silent failure Kitbash exists to prevent. They get a - * typed warning naming the reason instead, and no file. + * 2. **Not every target can honor it.** Six of the eleven compile targets have a + * confirmed project-scoped surface: a dedicated file (claude-code, copilot, + * cursor, agent-plugins) or a settings file Kitbash merges into without + * disturbing the rest of it (gemini, zed). The others have no MCP mechanism + * at all (aider, agentsmd), no project scope (cline, windsurf), or no + * confirmed path (the .agents convention). Emitting a plausible-looking file + * for those would produce something that reads as configured and does nothing + * — the exact silent failure Kitbash exists to prevent. They get a typed + * warning naming the reason instead, and no file. * * Client config dialects genuinely disagree (the HTTP transport is spelled * `streamable-http` by Agent Plugins, `http` by Claude Code and Copilot; timeouts @@ -24,6 +25,8 @@ * `transport` and every emitter TRANSLATES. Nothing is passed through verbatim. */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import type { LoadedSkill } from "./ksf.js"; import type { TomlTable } from "./toml.js"; @@ -242,9 +245,6 @@ const UNSUPPORTED: Record agents: { reason: "unconfirmed-path", detail: "the vendor-neutral .agents/ convention has no confirmed MCP file; the only candidate is a third-party draft no client reads" }, cline: { reason: "no-project-scope", detail: "Cline's MCP settings are global-only, so a repo-committed declaration cannot represent them" }, windsurf: { reason: "no-project-scope", detail: "Windsurf/Devin documents only a user-global MCP config, and its documented page covers the legacy agent" }, - zed: { reason: "needs-shared-file-merge", detail: "Zed keeps MCP servers in .zed/settings.json alongside unrelated user settings; merging into it needs its own consent path" }, - gemini: { reason: "needs-shared-file-merge", detail: "Gemini keeps MCP servers in .gemini/settings.json alongside unrelated user settings; merging into it needs its own consent path" }, - cursor: { reason: "needs-shared-file-merge", detail: "Cursor's .cursor/mcp.json cannot express a tools allowlist, so a declaration would be silently widened" }, }; export interface McpEmit { @@ -257,7 +257,7 @@ export interface McpEmit { * all installed skills. Returns no files (and a specific warning) for targets * that cannot honor a declaration. */ -export function emitMcp(targetId: string, servers: McpServer[], pluginRoot: string): McpEmit { +export function emitMcp(targetId: string, servers: McpServer[], pluginRoot: string, root: string): McpEmit { if (!servers.length) return { files: [], warnings: [] }; const un = UNSUPPORTED[targetId]; @@ -275,6 +275,12 @@ export function emitMcp(targetId: string, servers: McpServer[], pluginRoot: stri return emitClaudeCode(servers); case "copilot": return emitCopilot(servers); + case "cursor": + return emitCursor(servers); + case "gemini": + return emitGemini(servers, root); + case "zed": + return emitZed(servers, root); default: return { files: [], warnings: [] }; } @@ -457,14 +463,214 @@ export function toolBudgetReport(b: ToolBudget, max: number): { line: string; wa return { line, warnings, over }; } -/** Targets that can carry an MCP declaration today. */ -const MCP_TARGETS = ["agent-plugins", "claude-code", "copilot"]; +// ── merging into a shared settings file ────────────────────────────────────── + +/** + * Zed and Gemini keep MCP servers inside a settings file that also carries + * unrelated user configuration. Writing those is a different risk class from + * writing a dedicated `mcp.json`: get it wrong and you destroy settings that + * have nothing to do with skills. Three rules make it safe. + * + * 1. **Only the server key is touched.** Every other top-level key is preserved + * exactly, and within the server map, entries Kitbash did not write are left + * alone — a hand-added server survives a compile. + * 2. **A file that cannot be parsed is never overwritten.** Refuse and say so. + * Clobbering a settings file we failed to understand is the worst outcome + * available, and it is indistinguishable from the malware that targets these + * same paths for persistence. + * 3. **Comments mean refuse.** Both editors permit JSONC, and `JSON.parse` + * cannot round-trip a comment — reserializing would silently delete the + * user's annotations. A warning beats quiet data loss. + */ +export interface MergeResult { + content?: string; + warning?: string; +} + +/** Comments outside of string literals — the signal that JSON.parse would lose data. */ +function hasJsonComment(src: string): boolean { + let inStr = false; + let esc = false; + for (let i = 0; i < src.length; i++) { + const c = src[i]!; + if (inStr) { + if (esc) esc = false; + else if (c === "\\") esc = true; + else if (c === '"') inStr = false; + continue; + } + if (c === '"') inStr = true; + else if (c === "/" && (src[i + 1] === "/" || src[i + 1] === "*")) return true; + } + return false; +} + +export function mergeSettings(root: string, rel: string, key: string, ours: Record, target: string): MergeResult { + const abs = join(root, rel); + let base: Record = {}; + if (existsSync(abs)) { + const src = readFileSync(abs, "utf8"); + if (hasJsonComment(src)) { + return { warning: `${target}: ${rel} contains comments, which rewriting would delete. Nothing was written — add the MCP server(s) there by hand, or strip the comments.` }; + } + if (src.trim()) { + try { + const parsed: unknown = JSON.parse(src); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { warning: `${target}: ${rel} is not a JSON object. Nothing was written — kitbash will not overwrite a settings file it cannot read.` }; + } + base = parsed as Record; + } catch (e) { + return { warning: `${target}: ${rel} is not valid JSON (${e instanceof Error ? e.message.split("\n")[0] : "parse error"}). Nothing was written — kitbash will not overwrite a settings file it cannot read.` }; + } + } + } + const existing = base[key]; + const servers: Record = existing && typeof existing === "object" && !Array.isArray(existing) ? { ...(existing as Record) } : {}; + for (const [name, entry] of Object.entries(ours)) servers[name] = entry; + return { content: `${JSON.stringify({ ...base, [key]: servers }, null, 2)}\n` }; +} + +/** + * Cursor — `.cursor/mcp.json`, a dedicated project file. `type` is emitted on + * stdio entries (its field table marks it required while its examples omit it, + * so emitting satisfies both readings) and omitted on remote entries, where + * Cursor documents neither a field table nor a `type` value and every one of its + * own examples is `url` + `headers`. + */ +function emitCursor(servers: McpServer[]): McpEmit { + const warnings: string[] = []; + const out: Record = {}; + for (const s of servers) { + const entry: Record = {}; + if (s.transport === "stdio") { + entry["type"] = "stdio"; + entry["command"] = s.command; + if (s.args.length) entry["args"] = s.args; + if (Object.keys(s.env).length) entry["env"] = s.env; + } else { + entry["url"] = s.url; + if (Object.keys(s.headers).length) entry["headers"] = s.headers; + } + out[s.name] = entry; + if (s.tools.length && !s.tools.includes("*")) { + warnings.push(`cursor: "${s.name}" declares a tools allowlist, which .cursor/mcp.json has no field for — Cursor will expose every tool the server offers. The allowlist is not enforced there.`); + } + if (s.timeoutMs !== undefined) warnings.push(`cursor: "${s.name}" declares timeout_ms, which .cursor/mcp.json has no documented field for — dropped.`); + } + return { files: [{ path: ".cursor/mcp.json", content: json({ mcpServers: out }) }], warnings }; +} + +/** + * Gemini CLI — merged into `.gemini/settings.json` under `mcpServers`. + * + * Two Gemini-specific traps are handled here. An explicit `type` is always + * written, because a bare `url` defaults to Streamable HTTP (the opposite of + * Cline, which defaults to SSE) — the reason "always emit an explicit type" is + * a rule rather than a style preference. And Gemini unconditionally sanitizes + * the environment it hands an MCP server, stripping anything whose name matches + * TOKEN/SECRET/KEY/AUTH/CREDENTIAL and friends, so a server relying on an + * ambient credential fails to authenticate with no discoverable cause. That + * gets a warning naming the variables it will strip. + */ +const GEMINI_REDACTED_RE = /TOKEN|SECRET|PASSWORD|PASSWD|KEY|AUTH|CREDENTIAL|CREDS|PRIVATE|CERT/i; + +function emitGemini(servers: McpServer[], root: string): McpEmit { + const warnings: string[] = []; + const out: Record = {}; + for (const s of servers) { + const entry: Record = {}; + if (s.transport === "stdio") { + entry["command"] = s.command; + if (s.args.length) entry["args"] = s.args; + if (Object.keys(s.env).length) entry["env"] = s.env; + } else { + // `httpUrl` is deprecated and wins over `url` when both are present. + entry["url"] = s.url; + entry["type"] = s.transport === "sse" ? "sse" : "http"; + if (Object.keys(s.headers).length) entry["headers"] = s.headers; + } + // Gemini's own allowlist field. + if (s.tools.length && !s.tools.includes("*")) entry["includeTools"] = s.tools; + if (s.timeoutMs !== undefined) entry["timeout"] = s.timeoutMs; // Gemini reads ms + out[s.name] = entry; + + const stripped = refsOf(s).filter((r) => GEMINI_REDACTED_RE.test(r)); + if (stripped.length) { + warnings.push(`gemini: "${s.name}" references ${stripped.map((r) => `\${${r}}`).join(", ")}, and Gemini strips credential-shaped variables from the environment it passes to MCP servers. Set them explicitly in this server's env, or it will fail to authenticate with no visible cause.`); + } + } + const merged = mergeSettings(root, ".gemini/settings.json", "mcpServers", out, "gemini"); + if (merged.warning) return { files: [], warnings: [...warnings, merged.warning] }; + return { files: [{ path: ".gemini/settings.json", content: merged.content! }], warnings }; +} + +/** + * Zed — merged into `.zed/settings.json` under `context_servers`. + * + * Zed's settings enum is untagged, so no `type` key is written. Timeouts are in + * SECONDS here (every other target in this file is milliseconds) and Zed + * silently clamps to 600, so an over-long timeout is converted and warned about + * rather than passed through to be quietly truncated. Zed documents no variable + * interpolation at all, so a server carrying a `${VAR}` cannot be expressed: + * it is omitted rather than emitted with a reference that would be read as a + * literal. + */ +const ZED_MAX_TIMEOUT_SECS = 600; + +function emitZed(servers: McpServer[], root: string): McpEmit { + const warnings: string[] = []; + const out: Record = {}; + for (const s of servers) { + const refs = refsOf(s); + if (refs.length) { + warnings.push(`zed: skipped MCP server "${s.name}" — it references ${refs.map((r) => `\${${r}}`).join(", ")}, and Zed performs no variable interpolation, so the reference would be passed through literally.`); + continue; + } + const entry: Record = {}; + if (s.transport === "stdio") { + entry["command"] = s.command; + if (s.args.length) entry["args"] = s.args; + if (Object.keys(s.env).length) entry["env"] = s.env; + } else { + entry["url"] = s.url; + if (Object.keys(s.headers).length) entry["headers"] = s.headers; + } + if (s.timeoutMs !== undefined) { + const secs = Math.ceil(s.timeoutMs / 1000); + if (secs > ZED_MAX_TIMEOUT_SECS) { + warnings.push(`zed: "${s.name}" declares timeout_ms ${s.timeoutMs} (${secs}s), above Zed's ${ZED_MAX_TIMEOUT_SECS}s maximum, which it clamps silently — written as ${ZED_MAX_TIMEOUT_SECS}s.`); + } + entry["timeout"] = Math.min(secs, ZED_MAX_TIMEOUT_SECS); // seconds, not ms + } + out[s.name] = entry; + if (s.tools.length && !s.tools.includes("*")) { + warnings.push(`zed: "${s.name}" declares a tools allowlist, which lives under agent.profiles in Zed's settings rather than beside the server — kitbash does not write that subtree, so the allowlist is not enforced there.`); + } + } + if (!Object.keys(out).length) return { files: [], warnings }; + const merged = mergeSettings(root, ".zed/settings.json", "context_servers", out, "zed"); + if (merged.warning) return { files: [], warnings: [...warnings, merged.warning] }; + warnings.push("zed: an untrusted worktree makes Zed discard the whole of .zed/settings.json, MCP servers included — trust the worktree in Zed if the servers do not appear."); + return { files: [{ path: ".zed/settings.json", content: merged.content! }], warnings }; +} + +/** Targets that can carry an MCP declaration today, and where each one lands. */ +const MCP_TARGET_PATHS: Record = { + "agent-plugins": "/mcp.json", + "claude-code": ".mcp.json", + copilot: ".github/mcp.json", + cursor: ".cursor/mcp.json", + gemini: ".gemini/settings.json (merged)", + zed: ".zed/settings.json (merged)", +}; +const MCP_TARGETS = Object.keys(MCP_TARGET_PATHS); /** One line per target explaining its MCP status — the support matrix, printed. */ export function mcpSupportMatrix(): string[] { const out: string[] = []; for (const id of MCP_TARGETS) { - const path = id === "agent-plugins" ? "/mcp.json" : id === "claude-code" ? ".mcp.json" : ".github/mcp.json"; + const path = MCP_TARGET_PATHS[id]!; out.push(`✓ ${id.padEnd(14)} ${path}`); } for (const [id, u] of Object.entries(UNSUPPORTED)) out.push(`· ${id.padEnd(14)} no output — ${u.reason}: ${u.detail}`); diff --git a/site/changelog.html b/site/changelog.html index dab8f39..3d4b450 100644 --- a/site/changelog.html +++ b/site/changelog.html @@ -91,7 +91,7 @@

Changelog

Releases follow Keep a Changelog and semver — for skills and for this CLI, breaking prompt changes are breaking changes. The CLI is published to npm as kitbash and to Homebrew via singhharsh1708/tap. Tagged builds are on the GitHub releases page.

-
v0.20.0Current CLI version
+
v0.21.0Current CLI version
8Compile targets
Apache-2.0License
@@ -105,10 +105,20 @@

Changelog

Confirm with kitbash --version, which reads the installed package.json. Install and uninstall routes are covered on the installation page.

+
+
+

v0.21.0

+ 2026-08-09latest +
+

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.
+
+

v0.20.0

- 2026-08-09latest + 2026-08-09

The token-cost argument, applied to MCP — the largest standing-context line item Kitbash was not measuring.

A skill body's cost is statically countable, which is why context.budget can be enforced at compile time. An MCP server's real cost is not: the tokens it adds are the JSON schema of every tool it exposes, and knowing those means asking the server — which for a stdio server means executing it. That is exactly what the install gate exists to prevent, so Kitbash does not do it, and does not print an estimate it cannot derive.

diff --git a/site/index.html b/site/index.html index 3506688..5c80189 100644 --- a/site/index.html +++ b/site/index.html @@ -151,7 +151,7 @@ -

Open format for AI agent skills · v0.20.0 · stable spec (RFC 0002)

+

Open format for AI agent skills · v0.21.0 · stable spec (RFC 0002)

Write an agent skill once. Run it everywhere.