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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion docs/release-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version> --push`.
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.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",
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/scripts/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
31 changes: 28 additions & 3 deletions packages/cli/src/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -943,6 +943,12 @@ export async function cmdCompile(args: string[]): Promise<number> {
// 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}/`));
Expand Down
84 changes: 84 additions & 0 deletions scripts/bump-tap.sh
Original file line number Diff line number Diff line change
@@ -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 <version> [--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"
17 changes: 15 additions & 2 deletions site/changelog.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ <h1>Changelog</h1>
<p>Releases follow <a href="https://keepachangelog.com" target="_blank" rel="noopener">Keep a Changelog</a> and semver — for skills <em>and</em> for this CLI, breaking prompt changes are breaking changes. The CLI is published to npm as <a href="https://www.npmjs.com/package/kitbash" target="_blank" rel="noopener"><code>kitbash</code></a> and to Homebrew via <code>singhharsh1708/tap</code>. Tagged builds are on the <a href="https://github.com/singhharsh1708/kitbash/releases" target="_blank" rel="noopener">GitHub releases page</a>.</p>

<div class="stat-row">
<div class="stat"><b><span data-version>v0.22.0</span></b><span>Current CLI version</span></div>
<div class="stat"><b><span data-version>v0.23.0</span></b><span>Current CLI version</span></div>
<div class="stat"><b>8</b><span>Compile targets</span></div>
<div class="stat"><b>Apache-2.0</b><span>License</span></div>
</div>
Expand All @@ -105,10 +105,23 @@ <h1>Changelog</h1>
<p>Confirm with <code>kitbash --version</code>, which reads the installed package.json. Install and uninstall routes are covered on the <a href="docs/install">installation page</a>.</p>

<!-- changelog:begin -->
<article class="release" id="v0.23.0">
<div class="release-head">
<h2><a href="#v0.23.0">v0.23.0</a></h2>
<span class="release-date">2026-08-12</span><span class="release-tag">latest</span>
</div>
<h3 class="group">Added</h3>
<ul><li><strong>Compile reports the Cursor rule/skill overlap.</strong> Cursor reads <code>.agents/skills/</code> as well as <code>.cursor/rules/</code>, and the rule this compiler emits — <code>alwaysApply: false</code>, a <code>description</code>, no <code>globs</code> — is exactly what Cursor's docs call a <em>dynamic rule</em>, the shape its own <code>/migrate-to-skills</code> 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 <code>[project].targets</code> line that resolves it.</li></ul>
<p class="release-intro"> 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.</p>
<ul><li><strong><code>scripts/bump-tap.sh</code></strong> — the Homebrew formula bump, scripted. <code>TAP_TOKEN</code> 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.)</li></ul>
<h3 class="group">Fixed</h3>
<ul><li>Corrected the comment excluding <code>cursor</code> 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.</li></ul>
</article>

<article class="release" id="v0.22.0">
<div class="release-head">
<h2><a href="#v0.22.0">v0.22.0</a></h2>
<span class="release-date">2026-08-09</span><span class="release-tag">latest</span>
<span class="release-date">2026-08-09</span>
</div>
<h3 class="group">Fixed</h3>
<ul><li><strong>A repo with both <code>.agents/</code> and <code>.github/</code> (or <code>.gemini/</code>) got the same skill written twice.</strong> The <code>agents</code> 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 <code>.agents/</code> is absent, not the far more common one where a repo has it <em>and</em> a native skills directory. The result was byte-identical <code>SKILL.md</code> files in both places, which the source comment beside that adapter had explicitly claimed would not happen.</li></ul>
Expand Down
2 changes: 1 addition & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
<circle cx="256" cy="256" r="238" fill="none" stroke="#ffb454" stroke-width="5"/>
</svg>
</div>
<p class="eyebrow">Open format for AI agent skills · <span data-version>v0.22.0</span> · stable spec (RFC 0002)</p>
<p class="eyebrow">Open format for AI agent skills · <span data-version>v0.23.0</span> · stable spec (RFC 0002)</p>
<h1>Write an agent skill once. Run it <em>everywhere</em>.</h1>
<div class="actions">
<a class="button" href="docs/quickstart">Get started</a>
Expand Down
Loading