Skip to content
Merged
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ craftar import --from claude-code \

# 2. See what sync would do. On a freshly imported workspace everything is "adopt":
# the files already on disk are exactly what the Forge produces, so they just become managed.
# (A JSON file that differs only in whitespace is also "adopt"; the first sync rewrites it.)
craftar status --workspace C:/Projects/acme-portal-workspace

# 3. Generate + lock
Expand All @@ -52,7 +53,7 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
| Command | What it does |
|---|---|
| `craftar import --from claude-code --forge <dir> --profile <name> [--workspace .] [--write-config]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering). Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `<profile>-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml`. A reused base takes the place its `<name>--<profile>` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `<name>--<profile>` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `<recipe>--<profile>`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections <names>` when a section value filled the render), `inferred`, `sectioned`, `param <key>: <old> → <new>`, `section <key> <name>: <old> → <new>`, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created|edited|unchanged` lines. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, and a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand). Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. |
| `craftar status` | Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. `--json` for tooling. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in a file not every target renders as text (copied as they are). |
| `craftar status` | Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in a file not every target renders as text (copied as they are). |
| `craftar sync` | Renders sections (no rendered body holds a marker; a file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock`. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default). |
| `craftar diff [path]` | Line diff between disk and what the Forge would generate. |
| `craftar explain <path>` | Which ingredient, recipe chain, target and origin produced a file and, for an ingredient with sections, which layer filled each one: `sections flavors (profile globex), extra (default)` (`default`, `profile <p>` or `workspace`). `AGENTS.md` gets no sections line: it is not one ingredient. |
Expand Down Expand Up @@ -240,6 +241,12 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC

## Upgrading

### to 0.8.1

- **No behaviour change.** This is a documentation and test-only release.
- **The README now states that adopting a JSON file rewrites it on the first sync** — by design. `claude-code`'s `.mcp.json` becomes two-space JSON and keeps its BOM and line endings; Kiro's JSON files become CRLF without a BOM. Only differences `JSON.parse` erases are adopted; a different key order is a `collision`.
- **Test-only CI fix**: the test suite no longer reads `.git/` in its Forge snapshots, and its test repositories turn git's automatic maintenance off (`maintenance.auto`, `gc.auto`), ending a flake where that background maintenance raced the snapshots.

### to 0.8.0

- **`forge unify` can write `sections` into a profile and set `schema: 2` in `craftar.forge.yaml`.** craftar 0.6.2 and older then refuse that Forge, as after an import that relies on markers.
Expand Down
4 changes: 2 additions & 2 deletions 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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "craftar",
"version": "0.8.0",
"version": "0.8.1",
"description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.",
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { HUNK_CLASSES, UnifyPlanSchema, type HunkClass, type HunkSuggestion, typ
process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE") process.exit(0); });

const program = new Command();
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.0");
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.1");

/* ---------------------------------------------------------------- import */
program
Expand Down
17 changes: 15 additions & 2 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ afterEach(async () => {

/** Every file under root with its content, so a before/after comparison catches an in-place edit. */
async function snapshot(root: string): Promise<Record<string, string>> {
const files = await listFiles(root);
const files = (await listFiles(root)).filter((f) => f !== ".git" && !f.startsWith(".git/"));
return Object.fromEntries(await Promise.all(files.map(async (f) => [f, await fs.readFile(path.join(root, f), "utf8")] as const)));
}

Expand All @@ -32,6 +32,8 @@ function gitEnv(): NodeJS.ProcessEnv {

function gitInit(dir: string): void {
execFileSync("git", ["init", "-q", dir]);
execFileSync("git", ["-C", dir, "config", "maintenance.auto", "false"]);
execFileSync("git", ["-C", dir, "config", "gc.auto", "0"]);
}

function gitCommitAll(dir: string, message: string): void {
Expand All @@ -40,6 +42,17 @@ function gitCommitAll(dir: string, message: string): void {
}

describe("cli", () => {
it("snapshot() never reads .git/ — git's background maintenance races it (0.8.1)", async () => {
const root = await tmpDir("craftar-cli-forge-");
cleanups.push(() => fs.rm(root, { recursive: true, force: true }));
await fs.writeFile(path.join(root, "a.txt"), "a\n");
gitInit(root);
gitCommitAll(root, "init");
const keys = Object.keys(await snapshot(root));
expect(keys).toContain("a.txt");
expect(keys.filter((k) => k === ".git" || k.startsWith(".git/"))).toEqual([]);
});

it("status prints the unresolved-param warning and exits 0; sync --check still passes", async () => {
const s = await scenario(
{ ingredients: [rule("a", "Org: {{missing}}\n")], recipes: [recipe("base", ["rule/a"])], profiles: [profile("acme", ["base"])] },
Expand Down Expand Up @@ -1525,7 +1538,7 @@ describe("cli — suggested hunk classes (spec 08)", () => {
const r = runCli(["forge", "unify", "rule/wf", "--profile", "acme", "--plan", file, "--forge", forgeRoot]);
expect(r.code, `${name}: ${r.stderr}`).toBe(0);
const snap = await snapshot(forgeRoot);
results[name] = Object.fromEntries(Object.entries(snap).filter(([k]) => !k.startsWith(".git/")));
results[name] = snap;
}
expect(results.mangled).toEqual(results.edited);
expect(results.removed).toEqual(results.edited);
Expand Down
2 changes: 2 additions & 0 deletions test/golden-param.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ async function freshForge(): Promise<{ tmp: string; forge: string }> {
const forge = path.join(tmp, "forge");
await copyTree(INPUT, forge);
execFileSync("git", ["init", "-q", forge]);
execFileSync("git", ["-C", forge, "config", "maintenance.auto", "false"]);
execFileSync("git", ["-C", forge, "config", "gc.auto", "0"]);
gitCommitAll(forge, "init");
return { tmp, forge };
}
Expand Down
2 changes: 2 additions & 0 deletions test/golden-sections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ async function roundTrip(): Promise<RoundTrip> {
await edit(path.join(ws[p], "craftar.yaml"), addAgentsMd);
}
execFileSync("git", ["init", "-q", forge]);
execFileSync("git", ["-C", forge, "config", "maintenance.auto", "false"]);
execFileSync("git", ["-C", forge, "config", "gc.auto", "0"]);
gitCommitAll(forge, "import acme and globex");

// 2. Sync both: the workspace's own files adopt, AGENTS.md is new.
Expand Down
2 changes: 2 additions & 0 deletions test/golden-take-section.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ async function step1_importAndSync(): Promise<RoundTrip> {
}

execFileSync("git", ["init", "-q", forge]);
execFileSync("git", ["-C", forge, "config", "maintenance.auto", "false"]);
execFileSync("git", ["-C", forge, "config", "gc.auto", "0"]);
gitCommitAll(forge, "import acme, globex and initech");

// Sync and snapshot all three.
Expand Down
2 changes: 2 additions & 0 deletions test/golden-unify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ function gitEnv(): NodeJS.ProcessEnv {

function gitInit(dir: string): void {
execFileSync("git", ["init", "-q", dir]);
execFileSync("git", ["-C", dir, "config", "maintenance.auto", "false"]);
execFileSync("git", ["-C", dir, "config", "gc.auto", "0"]);
}

function gitCommitAll(dir: string, message: string): void {
Expand Down
22 changes: 22 additions & 0 deletions test/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,28 @@ describe("status", () => {
expect(await stateOf(s.wsRoot, ".mcp.json")).toBe("adopt");
});

it("adopt rewrites a JSON file into the emitter's layout on the first sync — by design (0.8.1)", async () => {
const mcp: IngredientSpec = { meta: { type: "mcp", name: "pw", server: { command: "npx", args: ["-y", "pw"] } } };
const s = await oneRule({ ".mcp.json": '{"mcpServers":{"pw":{"command":"npx","args":["-y","pw"]}}}\n' }, [mcp], ["mcp/pw"]);
expect(await stateOf(s.wsRoot, ".mcp.json")).toBe("adopt");
await sync(s.wsRoot);
expect(await fs.readFile(path.join(s.wsRoot, ".mcp.json"), "utf8")).toBe(
'{\n "mcpServers": {\n "pw": {\n "command": "npx",\n "args": [\n "-y",\n "pw"\n ]\n }\n }\n}\n',
);
expect(await stateOf(s.wsRoot, ".mcp.json")).toBe("unchanged");
});

it("adopt keeps the BOM and CRLF of the JSON file it rewrites — by design (0.8.1)", async () => {
const mcp: IngredientSpec = { meta: { type: "mcp", name: "pw", server: { command: "npx", args: ["-y", "pw"] } } };
const s = await oneRule({ ".mcp.json": '{"mcpServers":{"pw":{"command":"npx","args":["-y","pw"]}}}\r\n' }, [mcp], ["mcp/pw"]);
expect(await stateOf(s.wsRoot, ".mcp.json")).toBe("adopt");
await sync(s.wsRoot);
expect(await fs.readFile(path.join(s.wsRoot, ".mcp.json"), "utf8")).toBe(
'{\r\n "mcpServers": {\r\n "pw": {\r\n "command": "npx",\r\n "args": [\r\n "-y",\r\n "pw"\r\n ]\r\n }\r\n }\r\n}\r\n',
);
expect(await stateOf(s.wsRoot, ".mcp.json")).toBe("unchanged");
});

it("a CRLF + BOM copy of a synced LF file is unchanged, not drift (FM-2)", async () => {
const s = await oneRule();
await sync(s.wsRoot);
Expand Down
4 changes: 4 additions & 0 deletions test/unify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ describe("gitDirty", () => {
cleanups.push(() => fs.rm(dir, { recursive: true, force: true }));
await fs.writeFile(path.join(dir, "a.txt"), "one\n");
await execFileP("git", ["-C", dir, "init", "-q"]);
await execFileP("git", ["-C", dir, "config", "maintenance.auto", "false"]);
await execFileP("git", ["-C", dir, "config", "gc.auto", "0"]);
await execFileP("git", ["-C", dir, "add", "-A"]);
await execFileP("git", ["-C", dir, "-c", "user.email=t@e", "-c", "user.name=t", "commit", "-qm", "init"]);
expect(await gitDirty(dir)).toBe(false);
Expand Down Expand Up @@ -789,6 +791,8 @@ describe("U1 — a merge never changes section markers (spec 11 §6.12, Ruling 8
});
await fs.writeFile(path.join(root, "craftar.forge.yaml"), "name: test-forge\nschema: 2\n");
await execFileP("git", ["-C", root, "init", "-q"]);
await execFileP("git", ["-C", root, "config", "maintenance.auto", "false"]);
await execFileP("git", ["-C", root, "config", "gc.auto", "0"]);
await execFileP("git", ["-C", root, "add", "-A"]);
await execFileP("git", ["-C", root, "commit", "-qm", "init"], { env: gitEnv });
return root;
Expand Down
Loading