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.16.0] — 2026-08-07

The on-ramp for repos that already have the copy-per-agent mess — and an honesty correction. Grounded in a landscape scan: the strongest, most-cited pain for teams running several coding agents is config drift and painful onboarding across agents (named verbatim by five-plus independently-built sync tools). Kitbash made you author a fresh skill; now it can start from what you already have.

### Added
- **`kitbash import`** — reverse-compile. Reads a repo's existing agent instruction files (CLAUDE.md, AGENTS.md, GEMINI.md, CONVENTIONS.md, `.cursorrules`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`, `.clinerules`, `.windsurf`/`.devin/rules/*`), measures what each costs, **detects drift** — where the copies have silently diverged — and synthesizes one KSF skill from the version the most agents agree on. `--write` saves it and pins it, so `kitbash compile` regenerates every target from one source and ends the drift. `--name` sets the skill name. Non-destructive: your original files are left in place until you remove them. Purely kitbash-generated files are skipped, not re-imported.

### Fixed
- Corrected an overstated claim on the benchmark page ("the number nobody measures"). Other tools do estimate context-file token cost; what is distinct about Kitbash is measuring it **per target, from one source, and enforcing it as a declared budget at compile time** — the page now says that instead.

## [0.15.0] — 2026-08-07

Security and integrity pass. A multi-agent audit of the shipped code — five independent review passes, every finding independently reproduced before it was accepted — turned up four ways to walk a hostile skill straight past the install gate, plus five ways the tool corrupted or silently discarded its own output. Everything here was reachable in 0.13.0. Nothing here is a new feature.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ A syncer multiplies your review surface; a compiler divides it. You review one s

Already have skills? A plain SKILL.md folder — the skills.sh / Claude Skills convention — installs directly with `kitbash install owner/repo`. It is basically KSF without the manifest, so Kitbash fills in defaults and marks it `unmanifested`, since nobody declared a budget or permissions for it.

Already carrying a hand-written `CLAUDE.md`, `.cursor/rules/`, `AGENTS.md`, and the rest of the copy-per-agent set? `kitbash import` reads them back into a single skill, measures what each one costs in standing context, and reports where the copies have drifted apart — so `kitbash compile` can regenerate them all from that one source. It touches nothing on disk until you remove the originals yourself.

**Status.** v0.15.0, on npm and Homebrew, zero runtime dependencies, Node 20+. The KSF core is frozen and additive-only within the major version ([RFC 0002](rfcs/0002-ksf-1.0-stabilization.md)). Everything around it is early and labeled as such: `init`, `install`, `remove`, `list`, `compile`, `doctor`, `update`, `diff`, `lint`, `preview`, `explain`, and `test` work today; `audit`, `gate`, `search`, `publish`, `lore`, and `run` exit `7` and are on the [roadmap](docs/roadmap.md). One first-party skill ships (`prereview`); six more are specified but not built. Adoption is single-digit stars — if the measurement above is what you want, you are early.

<p align="center">
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/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 packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kitbash",
"version": "0.15.0",
"version": "0.16.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
52 changes: 52 additions & 0 deletions packages/cli/scripts/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,58 @@ try {
}
}

// --- import: reverse-compile a repo's existing agent config files ---
const imp = mkdtempSync(join(tmpdir(), "kitbash-import-"));
try {
// empty repo: nothing to import, exits 0
const empty = run(["import"], imp);
check("import: empty repo exits 0 with nothing to import", empty.status === 0 && empty.out.includes("nothing to import"), empty.out);

// agreeing configs across two agents → detected, no drift
writeFileSync(join(imp, "CLAUDE.md"), "# Rules\n\nUse strict mode. Write tests.\n");
writeFileSync(join(imp, "AGENTS.md"), "# Rules\n\nUse strict mode. Write tests.\n");
const agree = run(["import"], imp);
check("import: detects both config files", agree.out.includes("CLAUDE.md") && agree.out.includes("AGENTS.md"), agree.out);
check("import: reports token cost", /~\d+ tok/.test(agree.out), agree.out);
check("import: no drift when identical", agree.out.includes("no drift"), agree.out);
check("import: dry run does not write", !existsSync(join(imp, ".kitbash/skills")), agree.out);

// introduce a drifted third source
mkdirSync(join(imp, ".cursor/rules"), { recursive: true });
writeFileSync(join(imp, ".cursor/rules/main.mdc"), "---\ndescription: r\n---\nUse strict mode. Always lint first.\n");
const drift = run(["import"], imp);
check("import: detects drift", drift.out.includes("drifted into 2 different versions"), drift.out);
check("import: groups agreeing files together", /version 1: (CLAUDE\.md, AGENTS\.md|AGENTS\.md, CLAUDE\.md)/.test(drift.out), drift.out);

// --write persists a synthesized skill
const written = run(["import", "--write", "--name", "myrules"], imp);
check("import --write exits 0", written.status === 0, written.out);
check("import --write creates the skill", existsSync(join(imp, ".kitbash/skills/myrules/skill.toml")) && existsSync(join(imp, ".kitbash/skills/myrules/SKILL.md")), written.out);
check("import --write pins the skill", readFileSync(join(imp, "kitbash.lock"), "utf8").includes("myrules"), "");
const importedToml = readFileSync(join(imp, ".kitbash/skills/myrules/skill.toml"), "utf8");
check("import: synthesized manifest is valid-shaped", importedToml.includes('name = "myrules"') && /budget = \d+/.test(importedToml) && importedToml.includes('disclosure = "lazy"'), importedToml.slice(0, 120));

// the imported skill actually compiles (closes the loop)
const importedCompile = run(["compile"], imp);
check("import: the imported skill compiles", importedCompile.status === 0 && importedCompile.out.includes("compiled 1 skill"), importedCompile.out);

// re-writing the same name is refused
const dup = run(["import", "--write", "--name", "myrules"], imp);
check("import --write refuses an existing name", dup.status === 1 && dup.out.includes("already exists"), dup.out);

// a purely kitbash-generated file is NOT re-imported as a source
const gen = mkdtempSync(join(tmpdir(), "kitbash-import-gen-"));
try {
writeFileSync(join(gen, "AGENTS.md"), "<!-- kitbash:begin x -->\n<!-- generated by kitbash — do not edit -->\n\n## Skill: x\n\nbody\n<!-- kitbash:end x -->\n");
const genImp = run(["import"], gen);
check("import: skips a purely kitbash-generated file", genImp.status === 0 && genImp.out.includes("nothing to import"), genImp.out);
} finally {
rmSync(gen, { recursive: true, force: true });
}
} finally {
rmSync(imp, { recursive: true, force: true });
}

if (failures) {
console.error(`\n${failures} test(s) failed`);
process.exit(1);
Expand Down
101 changes: 101 additions & 0 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { basename, dirname, join, resolve, sep } from "node:path";
import { ADAPTERS, GENERATED_MARK, mergeSection, pruneSections, readFileIfExists, 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";
import { estimateTokens, loadInstalledSkills, loadInstalledSkillsSafe, loadSkill, resolveBody, schemaLints, standingStub, COMMAND_RE, NAME_RE, SKILLS_DIR, type LoadedSkill } from "./ksf.js";
import { parseToml } from "./toml.js";

Expand Down Expand Up @@ -48,6 +49,106 @@ export async function cmdInit(): Promise<number> {
return 0;
}

/**
* Reverse compile: read the agent instruction/rule files a repo already has,
* measure what each costs, show where they have drifted apart, and synthesize a
* single KSF skill so `kitbash compile` can regenerate them all from one source.
* This is the on-ramp for a repo already carrying the copy-per-agent mess.
*/
export async function cmdImport(args: string[]): Promise<number> {
const root = process.cwd();
const write = args.includes("--write");
const nameArg = flagValue(args, "--name");

const sources = collectImports(root);
if (!sources.length) {
console.log("no existing agent instruction files found (CLAUDE.md, AGENTS.md, .cursor/rules/, .clinerules, …).");
console.log(" nothing to import — author a skill instead: kitbash init && kitbash install <source>");
return 0;
}

console.log(`found ${plural(sources.length, "agent config file")}:`);
for (const s of sources) {
console.log(` ${s.file} → ${s.agent} (~${s.tokens} tok, ${s.loading})`);
}
const eager = sources.filter((s) => s.loading === "eager").reduce((sum, s) => sum + s.tokens, 0);
console.log(`standing cost of the always-on files: ~${eager} tokens every session`);

// Drift is the hook: do the copies actually say the same thing?
const groups = driftGroups(sources);
if (groups.length === 1) {
console.log(`\n✓ all ${sources.length} carry the same rules — no drift.`);
} else {
console.log(`\n⚠ these ${sources.length} files have drifted into ${groups.length} different versions:`);
groups.forEach((g, i) => console.log(` version ${i + 1}: ${g.files.join(", ")}`));
console.log(" the canonical version below is the one the most agents agree on.");
}

// Synthesize one skill from the de-facto canonical body (largest drift group).
const canonical = groups[0]!;
const name = deriveImportName(nameArg, root);
if (!NAME_RE.test(name)) {
console.error(`invalid skill name "${name}" — use --name <a-z, digits, hyphens, 2–41 chars>`);
return 1;
}
const bodyTokens = estimateTokens(canonical.body);
const budget = Math.min(20000, Math.max(500, Math.ceil((bodyTokens * 1.2) / 100) * 100));
const desc = `Imported from ${sources.length} existing agent config file${sources.length === 1 ? "" : "s"} (${sources.map((s) => s.agent).filter((a, i, arr) => arr.indexOf(a) === i).slice(0, 4).join(", ")})`;
const manifest = [
`[skill]`,
`name = "${name}"`,
`version = "0.1.0"`,
`description = ${JSON.stringify(desc.slice(0, 200))}`,
``,
`[context]`,
`budget = ${budget}`,
`standing = 100`,
`disclosure = "lazy"`,
``,
].join("\n");
const skillMd = groups.length > 1
? `<!-- imported by kitbash from drifted sources; this is the version most agents agreed on. Review before compiling. -->\n\n${canonical.body}\n`
: `${canonical.body}\n`;

if (!write) {
console.log(`\n— proposed skill "${name}" (budget ${budget}) —\n`);
console.log(manifest);
console.log(`# SKILL.md (${bodyTokens} tok, first lines):`);
console.log(canonical.body.split("\n").slice(0, 8).join("\n"));
console.log(`\nre-run with --write to save it to ${SKILLS_DIR}/${name}/, then: kitbash compile`);
return 0;
}

const dest = join(root, SKILLS_DIR, name);
if (existsSync(dest)) {
console.error(`${SKILLS_DIR}/${name}/ already exists — pass --name <other> or remove it first.`);
return 1;
}
mkdirSync(dest, { recursive: true });
writeFileSync(join(dest, "skill.toml"), manifest);
writeFileSync(join(dest, "SKILL.md"), skillMd);
if (!existsSync(join(root, CONFIG))) writeFileSync(join(root, CONFIG), INIT_CONFIG);
upsertLock(root, { name, version: "0.1.0", source: "import:local", integrity: integrityOf(dest) });
console.log(`\nwrote ${SKILLS_DIR}/${name}/ (skill.toml + SKILL.md), pinned in ${LOCK_FILE}`);
console.log(`next: kitbash preview ${name} (see it per agent + the token cost)`);
console.log(`then: kitbash compile (regenerate every target from this one source — ends the drift)`);
return 0;
}

/** Derive a valid skill name from --name or the repo directory. */
function deriveImportName(nameArg: string | undefined, root: string): string {
if (nameArg) return nameArg;
const base = basename(root).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/^[^a-z]+/, "");
const candidate = `${base || "project"}-rules`.slice(0, 41);
return NAME_RE.test(candidate) ? candidate : "project-rules";
}

/** Value following a `--flag` token, or undefined. */
function flagValue(args: string[], flag: string): string | undefined {
const i = args.indexOf(flag);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}

/**
* Confine an install subpath to the cloned repo. Returns the resolved absolute
* path, or null if it escapes `base` (e.g. "../../etc") — a directory-traversal guard.
Expand Down
126 changes: 126 additions & 0 deletions packages/cli/src/importers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Reverse compile: read the agent instruction/rule files a repo ALREADY has —
* the copy-per-agent mess — so `kitbash import` can turn them into one KSF source
* and show where they have drifted apart. This is the inverse of the adapters:
* adapters WRITE a format; here we READ the common ones back.
*/

import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { GENERATED_MARK, pruneSections } from "./adapters.js";
import { estimateTokens } from "./ksf.js";

/** One imported instruction source found in a repo. */
export interface ImportedSource {
agent: string; // the coding agent this file feeds
file: string; // repo-relative path
body: string; // human-authored instruction text (frontmatter + kitbash sections stripped)
tokens: number; // estimated standing cost of that body
loading: "eager" | "lazy";
}

/** Known hand-authored instruction files, by exact path. */
const FILE_SOURCES: { path: string; agent: string; loading: "eager" | "lazy" }[] = [
{ path: "CLAUDE.md", agent: "claude-code", loading: "eager" },
{ path: "AGENTS.md", agent: "agentsmd", loading: "eager" },
{ path: "GEMINI.md", agent: "gemini", loading: "eager" },
{ path: "CONVENTIONS.md", agent: "aider", loading: "eager" },
{ path: ".cursorrules", agent: "cursor", loading: "eager" },
{ path: ".windsurfrules", agent: "windsurf", loading: "eager" },
{ path: ".clinerules", agent: "cline", loading: "eager" }, // may also be a directory (handled below)
{ path: ".github/copilot-instructions.md", agent: "copilot", loading: "eager" },
];

/** Known rule directories whose *.md/*.mdc files are each an instruction source. */
const DIR_SOURCES: { dir: string; ext: string; agent: string; loading: "eager" | "lazy" }[] = [
{ dir: ".cursor/rules", ext: ".mdc", agent: "cursor", loading: "lazy" },
{ dir: ".github/instructions", ext: ".instructions.md", agent: "copilot", loading: "eager" },
{ dir: ".clinerules", ext: ".md", agent: "cline", loading: "eager" },
{ dir: ".windsurf/rules", ext: ".md", agent: "windsurf", loading: "lazy" },
{ dir: ".devin/rules", ext: ".md", agent: "windsurf", loading: "lazy" },
];

/**
* Strip a file down to its human-authored instruction text: drop leading YAML
* frontmatter, remove any kitbash-generated marker sections and header comment,
* and trim. Returns "" for a file that is entirely kitbash output (nothing to import).
*/
export function stripToBody(raw: string): string {
let s = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw; // BOM
s = s.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, ""); // leading frontmatter
s = pruneSections(s, new Set()); // remove all <!-- kitbash:begin/end --> sections
s = s
.split(/\r?\n/)
.filter((line) => !line.includes(GENERATED_MARK)) // drop generated-header comments
.join("\n");
return s.trim();
}

function readSource(root: string, rel: string, agent: string, loading: "eager" | "lazy"): ImportedSource | null {
const body = stripToBody(readFileSync(join(root, rel), "utf8"));
if (!body) return null; // empty or purely kitbash-generated — not a real existing config
return { agent, file: rel, body, tokens: estimateTokens(body), loading };
}

/** Every hand-authored agent instruction source present in the repo. */
export function collectImports(root: string): ImportedSource[] {
const out: ImportedSource[] = [];
const seen = new Set<string>();

for (const s of FILE_SOURCES) {
const abs = join(root, s.path);
if (!existsSync(abs) || !statSync(abs).isFile()) continue; // .clinerules may be a dir
const src = readSource(root, s.path, s.agent, s.loading);
if (src) {
out.push(src);
seen.add(s.path);
}
}

for (const d of DIR_SOURCES) {
const abs = join(root, d.dir);
if (!existsSync(abs) || !statSync(abs).isDirectory()) continue;
for (const name of readdirSync(abs).sort()) {
if (!name.endsWith(d.ext)) continue;
const rel = `${d.dir}/${name}`;
if (seen.has(rel)) continue;
const src = readSource(root, rel, d.agent, d.loading);
if (src) {
out.push(src);
seen.add(rel);
}
}
}
return out;
}

/** A set of sources sharing byte-identical instruction text (after whitespace normalization). */
export interface DriftGroup {
body: string;
files: string[];
}

/** Normalize for drift comparison: collapse runs of whitespace, trim each line. */
function normalize(body: string): string {
return body
.split(/\r?\n/)
.map((l) => l.replace(/\s+/g, " ").trim())
.filter(Boolean)
.join("\n");
}

/**
* Group sources by whether they carry the same rules. One group means every agent
* agrees; more than one means the copies have drifted apart — the pain to surface.
*/
export function driftGroups(sources: ImportedSource[]): DriftGroup[] {
const groups = new Map<string, DriftGroup>();
for (const s of sources) {
const key = normalize(s.body);
const g = groups.get(key);
if (g) g.files.push(s.file);
else groups.set(key, { body: s.body, files: [s.file] });
}
// Largest group first (the de-facto canonical version), then by first file for determinism.
return [...groups.values()].sort((a, b) => b.files.length - a.files.length || (a.files[0]! < b.files[0]! ? -1 : 1));
}
3 changes: 2 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { createRequire } from "node:module";
import { cmdCompile, cmdDiff, cmdDoctor, cmdInit, cmdInstall, cmdList, cmdRemove, cmdTest, cmdLint, cmdExplain, cmdPreview, cmdUpdate } from "./commands.js";
import { cmdCompile, cmdDiff, cmdDoctor, cmdImport, cmdInit, cmdInstall, cmdList, cmdRemove, cmdTest, cmdLint, cmdExplain, cmdPreview, cmdUpdate } from "./commands.js";

const VERSION: string = createRequire(import.meta.url)("../package.json").version;

Expand All @@ -30,6 +30,7 @@ function todo(name: string) {

const commands: Command[] = [
{ name: "init", summary: "Set up kitbash in this repository (kitbash.toml)", run: cmdInit },
{ name: "import", summary: "Turn a repo's existing agent config files (CLAUDE.md, .cursor/rules, …) into one skill + a drift report (--write; --name)", run: cmdImport },
{ name: "install", summary: "Install a skill with pre-install review: gh:owner/repo[/path][@ref], owner/repo, or file:path (--yes; [policy] enforced)", run: cmdInstall },
{ name: "remove", summary: "Remove an installed skill", run: cmdRemove },
{ name: "list", summary: "List installed skills with versions and context cost", run: cmdList },
Expand Down
Loading