diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ad86ab..1d67cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ Format: [Keep a Changelog](https://keepachangelog.com). Versioning: semver — for skills *and* for this CLI, breaking prompt changes are breaking changes. +## [0.23.0] — 2026-08-12 + +### Added +- **Compile reports the Cursor rule/skill overlap.** Cursor reads `.agents/skills/` as well as `.cursor/rules/`, and the rule this compiler emits — `alwaysApply: false`, a `description`, no `globs` — is exactly what Cursor's docs call a *dynamic rule*, the shape its own `/migrate-to-skills` command converts into a skill. Both land in Cursor's "Agent Decides" pool, and Cursor documents no dedup between them, so a repo with both paths offers one capability twice and pays each entry's description up front. Compile now says so, with the duplicated cost measured, and points at the `[project].targets` line that resolves it. + + The rule is still emitted. Agent Skills only reached general availability in Cursor 2.4, so dropping it would silently cost anyone on an older version their only trigger — a worse outcome than a duplicated description. The tradeoff is now visible instead of buried in a source comment. +- **`scripts/bump-tap.sh`** — the Homebrew formula bump, scripted. `TAP_TOKEN` is still unset, so the release workflow's tap job skips with a warning and the formula is bumped by hand; this runs the same steps the workflow does, so the digest is computed from the downloaded tarball rather than copied between terminals. Dry run by default, refuses a version that is not on npm yet, and is a no-op when the formula already points at that version. (An audit while writing it confirmed all eight hand-bumped releases, 0.17.0 through 0.22.0, carry a sha256 matching their real tarball.) + +### Fixed +- Corrected the comment excluding `cursor` from the vendor-neutral dedup, which asserted the rule file was "not a second copy" of the skill. It is a second offer of the same capability through a different mechanism — the same class of confident, unverified comment that hid the Copilot and Gemini duplication until 0.22.0. + ## [0.22.0] — 2026-08-09 ### Fixed diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 4dabc2e..4419d6b 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -131,8 +131,22 @@ Actions) as `TAP_TOKEN`. Without it the tap step skips with a warning and the formula is bumped by hand. The GitHub release does **not** need this — it runs on the built-in token. +Until that secret exists, bump the tap with `scripts/bump-tap.sh` rather than by +hand — it runs the same steps the workflow does, so the digest is computed from +the downloaded tarball instead of being copied between terminals: + +```bash +scripts/bump-tap.sh 0.22.0 # show the change, touch nothing +scripts/bump-tap.sh 0.22.0 --push # commit and push it +``` + +Dry run is the default: the tap is a separate public repo, and a wrong digest +there breaks `brew install` for everyone until somebody notices. The script +refuses a version that is not on npm yet (the formula would 404) and is a no-op +when the formula already points at that version. + ### Manual fallback If the workflow is unavailable: `npm publish` from `packages/cli` (the web-auth token goes stale between releases — expect to run `npm login --auth-type=web` -first), then bump the tap formula by hand. +first), then `scripts/bump-tap.sh --push`. diff --git a/packages/cli/package.json b/packages/cli/package.json index 47a0fb3..46e7e4b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "kitbash", - "version": "0.22.0", + "version": "0.23.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 05d08d9..fd86c47 100644 --- a/packages/cli/scripts/test.mjs +++ b/packages/cli/scripts/test.mjs @@ -1814,6 +1814,41 @@ try { rmSync(onlyTmp, { recursive: true, force: true }); } +// ── cursor rule / skill overlap ────────────────────────────────────────────── +// Cursor reads .agents/skills/ as well as .cursor/rules/, and the rule shape +// emitted here is the "dynamic rule" its own /migrate-to-skills converts into a +// skill. Both sit in one selection pool with no documented dedup, so the overlap +// is reported — but the rule is still written, because Agent Skills only reached +// GA in Cursor 2.4 and dropping it would cost older Cursor its only trigger. +const curTmp = mkdtempSync(join(tmpdir(), "kitbash-cursor-")); +try { + mkdirSync(join(curTmp, ".cursor"), { recursive: true }); + mkdirSync(join(curTmp, ".agents"), { recursive: true }); + run(["init"], curTmp); + run(["install", `file:${fixture}`, "--yes"], curTmp); + const c = run(["compile"], curTmp); + check("cursor-overlap: the rule is still written", existsSync(join(curTmp, ".cursor/rules/prereview.mdc")), c.out); + check("cursor-overlap: the skill is written too", existsSync(join(curTmp, ".agents/skills/prereview/SKILL.md"))); + check("cursor-overlap: the duplication is reported", c.out.includes("offered twice"), c.out); + check("cursor-overlap: with a measured cost", /~\d+ tok of trigger description is charged twice/.test(c.out), c.out); + check("cursor-overlap: it is a note, so --strict still passes", run(["compile", "--strict"], curTmp).status === 0); +} finally { + rmSync(curTmp, { recursive: true, force: true }); +} + +// Without .agents/, cursor is the only mechanism and there is nothing to report. +const curOnly = mkdtempSync(join(tmpdir(), "kitbash-cursoronly-")); +try { + mkdirSync(join(curOnly, ".cursor"), { recursive: true }); + run(["init"], curOnly); + run(["install", `file:${fixture}`, "--yes"], curOnly); + const c2 = run(["compile"], curOnly); + check("cursor-overlap: no note when .agents is absent", !c2.out.includes("offered twice"), c2.out); + check("cursor-overlap: and the rule is still emitted", existsSync(join(curOnly, ".cursor/rules/prereview.mdc"))); +} finally { + rmSync(curOnly, { recursive: true, force: true }); +} + if (failures) { console.error(`\n${failures} test(s) failed`); process.exit(1); diff --git a/packages/cli/src/adapters.ts b/packages/cli/src/adapters.ts index 9d69d7a..baa9395 100644 --- a/packages/cli/src/adapters.ts +++ b/packages/cli/src/adapters.ts @@ -247,15 +247,40 @@ const agents = skillDirAdapter( * skill names, so a skill present at two roots is loaded twice. * * Zed and Cline are absent from this map because they already compile to - * `.agents/skills/` and so cannot duplicate. Cursor is absent because it - * compiles to `.cursor/rules/*.mdc`, a rules file rather than a skill - * directory — a different mechanism, not a second copy of the same one. + * `.agents/skills/` and so cannot duplicate. + * + * Cursor is absent for a reason that is real but narrower than it looks, and + * worth stating exactly. Cursor reads `.agents/skills/`, and a rule shaped like + * the one this compiler emits (`alwaysApply: false`, a `description`, no + * `globs`) is what Cursor's own docs call a *dynamic rule* — the shape its + * `/migrate-to-skills` command converts into a skill. Both land in the same + * "Agent Decides" pool, and Cursor documents no dedup between them, so in a repo + * with both paths it sees two entries offering one capability and pays each + * one's description up front. + * + * The rule is still emitted anyway, because dropping it is not free: Agent + * Skills only reached general availability in Cursor 2.4, and before that + * `.cursor/rules/*.mdc` was the only mechanism that worked. Removing it would + * silently cost users on older Cursor their one trigger, to save a duplicated + * description on newer ones. Compile reports the duplication instead — see + * cursorSkillOverlapNote — so the cost is visible and the choice stays the + * reader's. */ export const VENDOR_NEUTRAL_ALIASES: { id: string; dir: string }[] = [ { id: "copilot", dir: ".github/skills" }, { id: "gemini", dir: ".gemini/skills" }, ]; +/** + * What the Cursor overlap costs, in the terms this tool measures everything else + * in. `descriptions` are the skill descriptions written into both the rule and + * the skill; each is charged once per entry, and Cursor keeps both entries. + */ +export function cursorSkillOverlapNote(descriptions: string[]): string { + const dup = descriptions.reduce((sum, d) => sum + estimateTokens(d), 0); + return `cursor: each skill is offered twice — as a rule (.cursor/rules/) and as a skill (.agents/skills/), which Cursor also reads. Both sit in its "Agent Decides" pool with no documented dedup, so ~${dup} tok of trigger description is charged twice. Cursor's own /migrate-to-skills converts rules of this shape into skills — drop "cursor" from [project].targets if every Cursor you support is 2.4 or newer.`; +} + /** * Zed's skill loader (`crates/agent_skills/agent_skills.rs`) is stricter than * KSF about frontmatter, and it fails *silently* — a skill that violates either diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index b1ab01c..6224a50 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -6,7 +6,7 @@ import { createRequire } from "node:module"; import { createInterface } from "node:readline"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { ADAPTERS, AGENT_PLUGIN_DIR, agentPluginManifest, GENERATED_MARK, mergeSection, pruneSections, readFileIfExists, VENDOR_NEUTRAL_ALIASES, type CompiledFile } from "./adapters.js"; +import { ADAPTERS, AGENT_PLUGIN_DIR, agentPluginManifest, cursorSkillOverlapNote, GENERATED_MARK, mergeSection, pruneSections, readFileIfExists, VENDOR_NEUTRAL_ALIASES, type CompiledFile } from "./adapters.js"; import { dropLock, integrityOf, readLock, upsertLock, walk, LOCK_FILE } from "./lock.js"; import { fileChanges, manifestDelta, textOf, unifiedDiff } from "./diff.js"; import { collectImports, driftGroups, type ImportedSource } from "./importers.js"; @@ -943,6 +943,12 @@ export async function cmdCompile(args: string[]): Promise { // and Codex dedupes root paths but not skill names, so it loads the skill twice. // Any existing copy is removed by the prune pass below, since nothing wrote it. if (adapters.some((a) => a.id === "agents")) { + // Cursor reads .agents/skills/ too, but its rule file is a different + // mechanism and older Cursor has no skills support, so the overlap is + // reported rather than resolved by dropping one side. + if (adapters.some((a) => a.id === "cursor") && skills.length) { + notes.push(cursorSkillOverlapNote(skills.map((s) => s.manifest.skill.description))); + } for (const alias of VENDOR_NEUTRAL_ALIASES) { if (!adapters.some((a) => a.id === alias.id)) continue; const dropped = [...files.keys()].filter((p) => p.startsWith(`${alias.dir}/`)); diff --git a/scripts/bump-tap.sh b/scripts/bump-tap.sh new file mode 100755 index 0000000..b6d179d --- /dev/null +++ b/scripts/bump-tap.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Bump the Homebrew formula to a published version. +# +# The release workflow does this automatically, but only when the TAP_TOKEN +# secret exists (a PAT with contents:write on singhharsh1708/homebrew-tap). +# Until it does, the homebrew job skips with a warning and the bump falls to a +# human — which historically meant copying a sha256 by hand, once per release. +# This runs the same steps the workflow does, so the digest is never retyped. +# +# scripts/bump-tap.sh 0.22.0 # show what would change, touch nothing +# scripts/bump-tap.sh 0.22.0 --push # commit and push it +# +# Dry run is the default on purpose: this pushes to a separate public repo, and +# a wrong digest there breaks `brew install` for everyone until it is noticed. +set -euo pipefail + +VERSION="${1:-}" +PUSH="${2:-}" +TAP_REPO="singhharsh1708/homebrew-tap" + +if [ -z "$VERSION" ]; then + echo "usage: scripts/bump-tap.sh [--push]" >&2 + exit 2 +fi +if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "error: '$VERSION' is not a semver version (expected e.g. 0.22.0)" >&2 + exit 2 +fi + +# Refuse to point the formula at something nobody can install. +published=$(npm view "kitbash@${VERSION}" version 2>/dev/null || true) +if [ "$published" != "$VERSION" ]; then + echo "error: kitbash@${VERSION} is not on npm yet — publish first, or the formula will 404" >&2 + exit 1 +fi + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +url="https://registry.npmjs.org/kitbash/-/kitbash-${VERSION}.tgz" +# The tarball can lag the publish by a few seconds on the CDN, same as in CI. +for i in 1 2 3 4 5; do + if curl -fsSL "$url" -o "$work/kitbash.tgz"; then break; fi + echo "tarball not on the registry yet, retrying ($i/5)" + sleep 10 +done +[ -s "$work/kitbash.tgz" ] || { echo "error: could not download $url" >&2; exit 1; } + +# shasum on macOS, sha256sum on Linux — this script runs on a maintainer laptop. +if command -v sha256sum >/dev/null 2>&1; then + sha=$(sha256sum "$work/kitbash.tgz" | cut -d' ' -f1) +else + sha=$(shasum -a 256 "$work/kitbash.tgz" | cut -d' ' -f1) +fi + +GIT_ASKPASS= git -c credential.helper='!gh auth git-credential' \ + clone -q "https://github.com/${TAP_REPO}.git" "$work/tap" + +formula="$work/tap/Formula/kitbash.rb" +[ -f "$formula" ] || { echo "error: $formula not found in $TAP_REPO" >&2; exit 1; } + +# Same substitution the workflow performs. +if command -v gsed >/dev/null 2>&1; then SED=gsed; else SED=sed; fi +$SED -i.bak -E "s|url \".*\"|url \"${url}\"|; s|sha256 \".*\"|sha256 \"${sha}\"|" "$formula" +rm -f "${formula}.bak" + +echo +git -C "$work/tap" --no-pager diff -- Formula/kitbash.rb +echo + +if git -C "$work/tap" diff --quiet -- Formula/kitbash.rb; then + echo "formula is already at ${VERSION} — nothing to do" + exit 0 +fi + +if [ "$PUSH" != "--push" ]; then + echo "dry run — re-run with --push to commit and push the change above" + exit 0 +fi + +git -C "$work/tap" commit -qam "kitbash ${VERSION}" +GIT_ASKPASS= git -C "$work/tap" -c credential.helper='!gh auth git-credential' push -q origin HEAD +echo "pushed kitbash ${VERSION} to ${TAP_REPO}" +echo "verify with: brew update && brew info singhharsh1708/tap/kitbash" diff --git a/site/changelog.html b/site/changelog.html index 74a2022..dc504c5 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.22.0Current CLI version
+
v0.23.0Current CLI version
8Compile targets
Apache-2.0License
@@ -105,10 +105,23 @@

Changelog

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

+
+
+

v0.23.0

+ 2026-08-12latest +
+

Added

+
  • Compile reports the Cursor rule/skill overlap. Cursor reads .agents/skills/ as well as .cursor/rules/, and the rule this compiler emits — alwaysApply: false, a description, no globs — is exactly what Cursor's docs call a dynamic rule, the shape its own /migrate-to-skills command converts into a skill. Both land in Cursor's "Agent Decides" pool, and Cursor documents no dedup between them, so a repo with both paths offers one capability twice and pays each entry's description up front. Compile now says so, with the duplicated cost measured, and points at the [project].targets line that resolves it.
+

The rule is still emitted. Agent Skills only reached general availability in Cursor 2.4, so dropping it would silently cost anyone on an older version their only trigger — a worse outcome than a duplicated description. The tradeoff is now visible instead of buried in a source comment.

+
  • scripts/bump-tap.sh — the Homebrew formula bump, scripted. TAP_TOKEN is still unset, so the release workflow's tap job skips with a warning and the formula is bumped by hand; this runs the same steps the workflow does, so the digest is computed from the downloaded tarball rather than copied between terminals. Dry run by default, refuses a version that is not on npm yet, and is a no-op when the formula already points at that version. (An audit while writing it confirmed all eight hand-bumped releases, 0.17.0 through 0.22.0, carry a sha256 matching their real tarball.)
+

Fixed

+
  • Corrected the comment excluding cursor from the vendor-neutral dedup, which asserted the rule file was "not a second copy" of the skill. It is a second offer of the same capability through a different mechanism — the same class of confident, unverified comment that hid the Copilot and Gemini duplication until 0.22.0.
+
+

v0.22.0

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

Fixed

  • A repo with both .agents/ and .github/ (or .gemini/) got the same skill written twice. The agents adapter's detection is narrow so the vendor-neutral path is not forced on repos that never asked for it — but that only covers the case where .agents/ is absent, not the far more common one where a repo has it and a native skills directory. The result was byte-identical SKILL.md files in both places, which the source comment beside that adapter had explicitly claimed would not happen.
diff --git a/site/index.html b/site/index.html index a31a5c0..c04680e 100644 --- a/site/index.html +++ b/site/index.html @@ -151,7 +151,7 @@ -

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

+

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

Write an agent skill once. Run it everywhere.