Craft, sync and convert AI-coding workspace harnesses — rules, agents, commands, skills, MCP servers — across every client workspace you maintain and every AI coder each team uses.
Status: phase 1 in progress — phase 0, the foundations, is done.
import,sync,status,diff,explain,lswork end-to-end for theclaude-codeandkirotargets and are validated byte-for-byte against a real workspace (see Oracle). The Forge commandsforge variants,forge diff(with a suggested class per hunk) andforge unify(--take base|variant,take: paramto turn a value into a{{key}}parameter, andtake: sectionto turn a client block into a section) andforge promote(a profile's variant becomes the base) also work, covered by the unit and golden suites rather than the oracle, andimportis render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific sections (<!-- craftar:section <name> -->…<!-- /craftar:section -->), overridden by a profile'ssectionsand a workspace'soverrides.sections; import infers each client's content andforge unifycan extract a section from a variant, and a Forge that uses them declaresschema: 2or above (see Forge layout). The read-only catalogue —recipes,ingredientsandtargetswith the capability matrix — works too, and a workspace can name its Forge by git URL with an optionalref, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see Remote Forge).add recipe/remove recipechange a workspace's recipe choice incraftar.yaml, refusing an edit that would not resolve. A writingsyncrecords each workspace in a per-machine registry, andcraftar workspaceslists them all with their status (see Workspace registry).craftar forge impactlists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, andforge unifyplans them before and after its writes, names the ones its warnings concern, and with--prune-recipesdeletes a recipe left identical to its sibling once every one of them plans the same.craftar doctorreports, one line per finding, what is off on this machine and in this workspace.craftar cache pruneremoves the cache entries and trees nothing uses. An MCP ingredient can declareauthEnv, the environment variables its server needs (names only, never a value), andcraftar doctorchecks them; withclaude-code,syncwrites an example file listing them. A recipe can bring local services: aserviceingredient is a Docker Compose fragment thatsyncwrites under.craftar/, andcraftar services up | down | statusrun Compose on what the lock records (see Local services). A profile'sintegrationsblock can select recipes and give params in a Forge that declaresschema: 3or above (see Integrations), since0.27.0.craftar templates list | installshows and runs the profile'stemplatesblock —installrunsdotnet new installper entry into the user's template store; nothing installs implicitly. An MCPservertakes the params its ingredient declares, in a Forge that declaresschema: 4(see Server params), since0.28.0. Everything else in the spec (craftar ui,craftar mcp) is not built yet.
You write the harness once, in a central repository called the Forge, as small reusable ingredients (a rule, an agent, a command, a skill, an MCP server). Recipes bundle ingredients (base, stack-backend-api, stack-frontend-angular…). A profile describes a client: which recipes, which targets (Claude Code, Kiro, AGENTS.md…), and the parameters that differ per client. A workspace holds a tiny craftar.yaml pointing at the Forge and a profile; craftar sync generates .claude/, .kiro/, AGENTS.md… from it and records what it wrote in craftar.lock. Generated files are never edited by hand again — you edit the Forge and sync everywhere.
npx craftar --help # run without installing
npm install -g craftar # or install the craftar commandRequires Node ≥ 22.
npm install
npm run build # → dist/
node bin/craftar.js --help
# or during development
npx tsx src/cli.ts --help# In a terminal, craftar init asks for what the flags do not give:
# the Forge, the profile, then the recipes and the targets to keep or adjust.
cd C:/Projects/acme-portal-workspace
craftar init
# Or pass everything for scripts and CI:
craftar init --workspace C:/Projects/acme-portal-workspace \
--forge C:/Projects/forge \
--profile acme-portalThe workspace follows the profile's recipes and targets; --add-recipe / --remove-recipe adjust the recipes, and --targets writes the targets into craftar.yaml. When the directory already holds a harness, files equal to the Forge's are adopted and different ones are left as they are and reported — craftar import (below) brings them into the Forge.
# 1. Import the harness of a Claude Code workspace into a (new or existing) Forge
craftar import --from claude-code \
--workspace C:/Projects/acme-portal-workspace \
--forge C:/Projects/forge \
--profile acme-portal \
--write-config # writes craftar.yaml into the workspace
# 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
craftar sync --workspace C:/Projects/acme-portal-workspaceFrom then on, change a rule in forge/ingredients/rules/<name>/rule.md, run craftar sync in each workspace (or craftar sync --check in CI) and every target is regenerated.
| Command | What it does |
|---|---|
craftar init [--forge <dir|url>] [--profile <name>] [--ref <ref>] [--targets <a,b>] [--add-recipe <name>]... [--remove-recipe <name>]... [--replace] [--no-sync] [--offline] [--workspace .] |
Starts a workspace: writes a craftar.yaml holding forge (a directory as the path from the workspace, POSIX; a URL as given), ref (a remote Forge only), profile, recipes (only the lists the recipe flags leave non-empty) and targets (only with --targets; otherwise the workspace follows the profile's), then runs the first sync — which also registers the workspace. In a terminal (stdin and stdout both a TTY), --forge and --profile are optional: what is missing is asked for — the Forge (offering the one last synced on this machine, from the registry), a remote Forge's ref, the profile (from the Forge's list), then the recipes and the targets the profile gives, to keep or adjust; a flag given is never asked for. One confirmation before anything is written, showing what the first sync would do. Ctrl-C, the end of input, or no at the confirmation: nothing written, exit 1 (init cancelled — nothing written). The run ends with an again craftar init … line: the same run as flags, for CI. Off a terminal (a pipe, CI), a missing flag is refused, exit 1, naming the flag; where the terminal does not report a TTY, the flags are required. With both flags given nothing is asked, in a terminal or not. The file is written only once the configuration loads (a remote Forge is fetched as sync fetches it; --offline reads the cache), resolves and plans, and the sync applies that same plan. --remove-recipe and --add-recipe go through remove recipe / add recipe's core (removes first, then adds; --replace swaps a slot holder); a call whose every name changes nothing prints its reasons as a note. --workspace is created when missing. Files already on disk and not in a lock are adopted when equal to the plan and skipped as a collision otherwise, never overwritten, with one warning pointing at craftar import. --no-sync writes craftar.yaml only and prints what the first sync would do. When a declared parameter a file cites has no value (see sync), craftar.yaml is still written — the workspace's overrides.params is a place for the value — but the first sync is not run: the refusal is printed (error: N declared parameter(s) have no value — craftar.yaml written, sync not run, with then run craftar sync in its fix line), exit 1; in a terminal the confirmation says first sync: refused — … and asks only Write craftar.yaml; --no-sync prints next sync: refused — … and exits 0. Refused, with nothing written and no directory created: an existing craftar.yaml; a --workspace that is a file, or under one; credentials in --forge (the value is never printed); --ref with a directory Forge; an unknown target; a recipe both added and removed, or any refusal of add recipe / remove recipe; a craftar.local.yaml that sets forge or profile, or a key a given flag would write (ref, recipes, targets) — its value would replace the flag's; a Forge, profile or plan that does not load. |
craftar import --from claude-code --forge <dir> --profile <name> [--workspace .] [--write-config] [--report <file.md>] [--prefer-kiro <rule,…>] |
Reads .claude/{rules,agents,commands,skills,scripts,hooks}, .mcp.json and, when present, .kiro/steering (for inclusion modes and hand-written steering — and, with --prefer-kiro, the text of the rules it names). 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 manifest already at 2 or above is left as it is). 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. In a Forge at schema 3 or above, what the profile's integrations block alone provides is not imported — not compared, not written — and is listed (left to recipe <r> (integrations.<slot>): …, and under ## Left to an integration recipe in the --report file); craftar status then shows whether the workspace's copies differ (drift, collision); move an agent attached to a scoped rule into the integration recipe with its rule, or base splits on import. 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, a craftar.forge.yaml it needs to bump to schema: 2 that does not round-trip (set it by hand), and — in a Forge at schema 3 or above — a profile whose integrations block is malformed or, when the block holds a kind, that does not resolve. 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. authEnv is never inferred; a variant import rewrites keeps the one it declares, a new variant takes its base's, and a declaration a profile loses is reported. 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. --report <file.md> also writes a Markdown report, after the Forge is written: what was created, reused, made a variant or rejected, the import's warnings, and the Kiro side — Kiro collisions, every .kiro/ file that differs from what sync would generate for the imported profile (status' collision state against no lock, so a difference of line endings, BOM or JSON formatting is not one), each with its ingredient and the line counts of the two sides, a rule's steering mirror that outgrew its rule marked steering bigger than the rule; and Unsourced Kiro files, the files under .kiro/steering/, .kiro/agents/, .kiro/skills/ and .kiro/settings/mcp.json that the plan for the imported profile does not write — listed, never imported. The report holds paths and line counts, never content (a .kiro/ mirror is not scanned for secrets), and names an inferred parameter's key, not its value; a warning, a rejection reason or the not computed line is written with every parameter and section value this run accepted, replaced or inferred as "…", and a variant is named against its base, without its reason — the summary on the terminal keeps them. It describes the profile as imported: of the workspace's own craftar.yaml and craftar.local.yaml only overrides.params and overrides.sections are read (as import reads them to decide a reuse) — not its forge, profile, targets or recipes — and craftar.lock is not read. The path may be inside the workspace; it is refused, before anything is imported, when it resolves inside the Forge (through symlinks and ..), when a file already exists there, or when a component of it cannot be resolved. If the Kiro side cannot be computed — the plan fails, or sync would refuse it for a declared parameter with no value — its sections read not computed: …, the same is an import warning, and the import still exits 0. The report is written after the summary, which then ends with report <file.md>; if that write fails the import has still succeeded and the Forge is written, and the command says so and exits 1. Without --report none of this is computed. --prefer-kiro <rule,…> takes the named rules from .kiro/steering/<name>.md instead of .claude/rules/<name>.md — for a steering mirror that was edited by hand and is the text to keep; a rule left to an integration recipe is warned (nothing to prefer; its steering file was not used) and skipped, before that rule's own refusals (no steering file, a form with no certain source, a line that would not come back). The rule's text is built from the steering file — its frontmatter and its opening banner (the default one, or the line rendered by the kiro.banner sync would use: the workspace's overrides.params, else — for a profile already in the Forge — the profile's own or a default of a recipe it resolves) dropped, .kiro/steering/ turned back into .claude/rules/ except in a reference to a hand-written steering file and under commands/ — and is accepted only when it is proved to render back to that steering file's body for Kiro over the workspace's own rules (every rule aimed at every target, as import writes them); it is then scanned for secrets and decided like any source (reused, a variant, or created; a rejection names the steering file's line, and a rejected rule gets no prefer-kiro line), its inclusion and fileMatchPattern still read from the steering frontmatter. Refused, with the Forge untouched: an empty or repeated name; a name with no .claude/rules/<name>.md (the flag takes rules; a steering file with no rule is already imported as steering); a rule with no .kiro/steering/<name>.md; a steering file holding a form Kiro writes for a rule it does not have (AGENTS.md (rule: <x>), a link to AGENTS.md, rule not in this workspace), which has no certain source; and a steering file that would not come back from the rule built from it (it names .claude/rules/ where sync writes .kiro/steering/) — the last two name the line of the steering file and quote none of it. The ingredient's origin.path is the steering file; the summary gains prefer-kiro <ref> — source .kiro/steering/<name>.md (N lines; .claude/rules/<name>.md has M) per rule — N and M count the two files whole, the steering file's frontmatter and banner included — and --report a Preferred from Kiro section with the same lines. What the workspace sees next: the Forge rule now differs from .claude/rules/<name>.md on disk, so with no lock entry for that file the next sync shows it as collision and leaves it alone — delete it (and the steering file, if it also collides) and run craftar sync to regenerate both; where craftar.lock already records the file unedited, sync updates it. The summary says so in a note for each rule whose text changed (after the warnings, before the report line). Until the workspace converges, an import without the flag reads .claude/rules/<name>.md again and lands that older text as a <name>--<profile> variant: keep the flag, or converge first. Rules only: commands, agents and skills are not taken from Kiro. In a schema 4 Forge an MCP base is compared as sync renders it (its declared params substituted into its server): a workspace whose server equals that rendering reuses the base — unless the base cites a declared key that neither the profile nor the workspace sets while some recipe of the Forge defaults it (import cannot know which value sync will render): that one becomes a variant whatever it holds; one whose server differs becomes a variant holding the server as the workspace has it, braces included, with no params (it declares nothing, so sync writes any braces in it back, with the undeclared-key warning whenever a layer gives that key a value). No value is ever inferred into a profile from a server. |
craftar status |
With a remote Forge, the header's second line names it — forge <url> @ <ref | <branch> (default branch)> <commit>, plus · lock <commit> when the Forge moved since the last sync — and --json carries forge: {kind, source, ref, defaultBranch, commit, lockCommit, fetched} for every Forge. 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: {forge, statuses, warnings, unsetParams}. Exits 0 whenever it prints its listing, with one exception: when a declared parameter a file cites has no value (see sync) it prints the listing, then the refusal on stderr, and exits 1 — --json still prints the whole object, with unsetParams: [{key, declaredBy, citedBy}] ([] when there is none). 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 an emitted file not every target renders as text (copied as they are). |
craftar sync |
Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and craftar.lock (schema 2, or 3 when the workspace has a service — see Local services; it records the Forge's source, requested ref and commit, the resolved recipes and targets — rewritten only when that content changes). With a remote Forge it fetches first and exits 1 when the fetch fails, --dry-run and --check included. --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 drift lists them, discards one, or promotes one to the Forge. A writing sync (not --dry-run, not --check) also records the workspace in $CRAFTAR_HOME/registry.json (see Workspace registry); a registry it cannot write is a warning, never a failed sync. Refuses when a declared parameter has no value: a key an ingredient declares under params with no default, that a file the plan writes cites as {{key}}, and that no layer sets (recipe default, profile params, overrides.params in craftar.yaml or craftar.local.yaml). The plan's warnings print, then one block on stderr — error: N declared parameter(s) have no value — nothing written, one line per key with the ingredients that declare it and the ones that cite it, and the fix — and sync exits 1 with nothing written: no file, no lock, no registry entry; --dry-run exits 1 the same way, and --check prints its listing, then the block, and exits 1 even when every file is unchanged. A {{key}} no ingredient declares (an Angular {{ 'X' | localize }}, a stray placeholder) is still left verbatim with a warning, and an empty string is a value. (That is a body; a {{key}} in an MCP server of a schema 4 Forge follows Server params.) |
craftar diff [path] [--exit-code] |
Line diff between disk and what the Forge would generate, for every file sync --check refuses: update, drift, collision, new, an orphan (shown as the removal of the whole file — the next sync deletes it) and an orphan-drift (a header and one line: sync keeps it). For .claude/settings.craftar.example.json, when it has drifted or collides, diff prints the headers and content not shown: an example file may hold a value typed by hand. [path] limits it to the one file whose workspace-relative path, as status prints it, equals it. Exits 0 by default, differences or not. --exit-code exits 1 when there are differences — exactly when sync --check would — and makes a [path] that names no file craftar manages an error (exit 1, nothing on stdout); without the flag such a path prints no differences. With a remote Forge, --exit-code also exits 1 when the fetch fails, as sync --check does; plain diff reads the cached copy with a warning. When a declared parameter a file cites has no value (see sync), the refusal is printed on stderr after the diff: plain diff still exits 0, --exit-code with no [path] exits 1 even with no differences — as sync --check does — and with a [path] its exit code answers for that file only. |
craftar drift [show] [path] [--workspace <dir>] [--offline] [--json] |
The hand-edited files (drift, orphan-drift), whether the Forge also changed each, whether it can be promoted; with a path, its diff. In a Forge at schema 3 or above, a file the profile's integrations block alone provides reads promote: no (provided by integrations.<slot>) — a service file reads promote: no (service file) there too, whoever provides it. In a workspace whose profile's block holds a kind and that does not resolve on its own (its recipes.remove takes out a missing integration recipe, say), show warns that what the block provides could not be computed: a file the block provides then reads promote: yes, and drift promote refuses it. Read-only. --json is a contract: {workspace, profile, files: [{path, state, target, ingredient, forge, promotable, reason}], warnings, unsetParams}. Exits 0 whenever it prints its listing, with one exception: when a declared parameter a file cites has no value (see sync) it prints the listing, then the refusal on stderr, and exits 1 — --json still prints the whole object, with unsetParams: [{key, declaredBy, citedBy}] ([] when there is none). |
craftar drift discard <path...> [--workspace <dir>] [--dry-run] [--offline] |
A sync that regenerates only the named hand-edited files and removes a named hand-edited orphan; everything else sync would do happens too. A path that is not drift or orphan-drift refuses the whole run before any write. --dry-run. |
craftar drift promote <path> [--forge <dir>] [--workspace <dir>] [--dry-run] [--json] |
Carries one hand edit of a .claude/ file into the Forge's working tree: a value in the profile's params / sections when the edit is only that, otherwise a variant for the workspace's profile. In a Forge at schema 3 or above, a file the profile's integrations block alone provides is not promotable — edit the ingredient in the Forge or discard the edit; and promote refuses to fork a recipe the block selects when it is not that profile's alone — another profile resolves it, or does not resolve at all; and it refuses every file when the block holds a kind and the profile does not resolve on its own. Gated on git, proved before the first write, never syncs, never commits. With a remote Forge, --forge <dir> names the clone to write. --dry-run, --json. --json is a contract: {workspace, forge, profile, dryRun, path, ingredient, outcome, promoted, written: [{path, action}], params, sections, flattened, dependents, nextSync, impact, warnings}. |
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). For an ingredient whose files cite parameters, each cited key with the layer that filled it for this ingredient: params org (profile globex), port (default), root (unset) — default (the ingredient's own), recipe <name>, profile <p>, workspace (either workspace file) or unset. AGENTS.md gets neither line: it is not one ingredient. |
craftar ls |
Recipes and ingredients resolved for this workspace. |
craftar workspaces [--fetch] [--json] |
Every workspace a writing sync registered on this machine, sorted by path: name, path, profile, stack (slot=recipe), targets, the Forge, the last sync, and a status computed now — up to date, outdated (new, update or orphan pending), drift (a drift, orphan-drift or collision), no lock, missing (directory or craftar.yaml gone) or error (it no longer loads, plans or reads its lock, or its sync would refuse for a declared parameter with no value — the reason is one of the row's warnings: sync refused: declared parameter(s) with no value: <keys>), plus forge moved when the Forge's commit differs from the lock's. Read without the network: a remote Forge comes from the cache; --fetch fetches first. Each row's warnings print after the table, prefixed with its path. Times are UTC. Writes nothing. Exits 0 whenever the table prints. Takes no --workspace. --json is a contract: {registry, fetch, workspaces, warnings}. |
craftar doctor [--workspace <dir>] [--fetch] [--strict] [--json] |
Checks this machine — Node ≥ 22, git, $CRAFTAR_HOME, the registry (entries whose workspace is gone), the Forge cache (its size, entries nothing names, a fetch that never completed) — and, when run inside one (the current directory, or --workspace), this workspace: the configuration and its Forge (cached, fetched or pinned), whether the workspace sits inside its Forge, the plan's warnings, declared parameters with no value, the ${NAME} variables a written MCP server expects and this environment lacks — mcp-env reads authEnv next to ${NAME} (names only, never values), templates when the profile declares a templates block (an empty templates: line declares none), dotnet when that block is accepted and declares a dotnet-new entry (the .NET SDK answering dotnet --version at the workspace root); neither goes above warn — the lock, status, and registration; in a workspace with a service: docker — Docker and its Compose plugin, 2.20.0 or later — and services-env — the variables a fragment needs. One line per finding — ok, warn or error — each problem with the command that fixes it; each cause is reported once. A declared parameter with no value is an error when a file the plan writes cites it (sync refuses that workspace) and a warn when nothing cites it. It only reports: nothing is written to a workspace, a lock or the registry, and the Forge cache is used as every reader uses it (.used stamps, a rebuilt tree; under --fetch, a fetch and the cleanup of trees unused for 14 days). Read without the network; --fetch fetches a remote Forge first. Exits 1 on an error, or on a warn under --strict; an explicit --workspace without craftar.yaml exits 1, while the current directory without one runs the machine checks only. --json is a contract: {version, workspace, fetch, strict, checks: [{id, scope, level, message, fix}], summary}. In a schema 4 Forge mcp-env reads the server as rendered, so a ${NAME} a param brings into env is checked. |
craftar templates list [--json] [--workspace <dir>] [--offline] |
What the profile's templates block declares: one line per package — kind, package, version or latest, source or default feeds — and one per entry this craftar cannot install. Runs no program and does not know what is installed. --json is a contract: {profile, templates: [{entry, kind, package, version, source, installable}], warnings}. Exits 1 when the block is refused, naming the position and the rule, never a value. |
craftar templates install [--dry-run] [--force] [--interactive] [--workspace <dir>] [--offline] |
Runs dotnet new install <pkg>[::<version>]... [--add-source <source>] once per dotnet-new entry, in the profile's order, at the workspace root. --dry-run prints the exact lines, needs no dotnet and exits 0 over an accepted block, even with a skipped entry. A real run exits 1 when the .NET SDK is not available. Both exit 1 when the block is refused or a package ID exists as a file or folder at the workspace root. --force and --interactive are passed to dotnet as typed, never otherwise; --interactive also gives dotnet the terminal's input. An entry that fails does not stop the others; a real run exits 1 when any failed or was skipped. When an entry declares a source and the workspace root has no NuGet.Config, a warning line is printed before the runs. Writes nothing in the workspace, the lock or the registry; nothing else (sync, init, doctor) runs it. See Project templates. |
craftar cache prune [--dry-run] [--workspace <dir>] [--json] |
Removes the Forge cache entries nothing uses — no registered workspace (missing ones included until craftar workspaces prune) and not the current workspace — entries whose first fetch never completed (even when named), and trees unused for 14 days. Whole entries are kept with a warning when the registry is off or unreadable, or the current craftar.yaml does not read. Deletes by default; --dry-run shows. Never touches a workspace, a lock or the registry; no network. One removing prune at a time per $CRAFTAR_HOME (forges/prune.lock). An explicit --workspace without craftar.yaml exits 1. --json is a contract: {home, dryRun, removed: [{path, kind, reason, bytes}], kept: [{path, reason, namedBy}], freedBytes, warnings}. |
craftar workspaces forget <dir> / craftar workspaces prune |
forget removes one entry (the directory is not touched; an unregistered path exits 1); prune removes every missing entry and never an error one. Nothing else removes an entry. |
craftar recipes [--forge <dir> [--profile <name>] | --workspace <dir>] [--json] |
Every recipe of the Forge, sorted by name: slot, the parents it extends, the ingredients it lists itself and the profiles that reach it. In a workspace (or with --forge <dir> --profile <name>) each recipe says whether it is in use and why — profile (listed in recipes or selected by the profile's integrations block), workspace (recipes.add), extends <child> — and a recipe recipes.remove names reads removed by this workspace. A context that does not resolve (two recipes on one slot, an unknown recipe, a cycle) still lists everything, marks nothing and warns. Read-only: it resolves, never plans. --json is a contract: {forge, context, recipes, warnings}. |
craftar ingredients [--recipe <name>] [--type <type>] [--forge <dir> [--profile <name>] | --workspace <dir>] [--json] |
Every ingredient of the Forge, grouped by type: output name, targets, the recipes that list it, and whether the context uses it or the workspace disables it; a reference a recipe makes to an ingredient the Forge does not hold is listed under missing, never dropped. --recipe <name> lists what choosing that recipe brings, with the parents it extends, parents first; two of them on one slot is a warning. --type keeps one type. Read-only. --json is a contract: {forge, context, recipe, ingredients, missing, warnings}. |
craftar targets [--workspace <dir>] [--json] |
The capability matrix (see Targets): what each target does with each ingredient type, the paths it writes and what changes. Needs no Forge; inside a workspace it marks the targets in use, and when that workspace does not load or resolve it prints the matrix unmarked with a warning. An explicit --workspace <dir> without a craftar.yaml exits 1; the current directory without one just prints the matrix unmarked. --json is a contract: {ingredientTypes, targets, warnings}. |
craftar add recipe <name...> [--replace] [--workspace <dir>] [--offline] |
Adds recipes to the workspace's choice: deletes each from craftar.yaml's recipes.remove when it is there, and appends it to recipes.add when the workspace does not resolve it yet. A recipe that would share a slot with another resolved one is refused, naming the holder; --replace removes every other holder in the same edit, as remove recipe would. Names apply in order, all or nothing, and the edit is written only once the workspace resolves and plans with it. Writes craftar.yaml only — in place, keeping its comments and style (a document the YAML writer does not reproduce byte for byte, such as { add: [] } with inner spaces or a column-aligned comment, is refused: reformat it by hand) — and never syncs: it prints the net edit, the new recipe order and what the next sync would do (next sync: 1 new, 1 orphan — run \craftar sync`), or nothing to change. When the edited workspace has a declared parameter with no value that a file cites (see sync), the edit is still made and the line reads next sync: refused — 1 declared parameter(s) have no value (org), exit 0. Refused when craftar.local.yamlsetsrecipes` (its lists would replace the edited ones). |
craftar remove recipe <name...> [--workspace <dir>] [--offline] |
Removes recipes from the workspace's choice: deletes each from recipes.add, and appends a profile recipe to recipes.remove. A recipe another one still brings in through extends is refused, naming the top-level recipe to remove instead. A name the Forge does not hold is accepted only to clean it out of recipes.add or recipes.remove. Same output, refusals and all-or-nothing rule as add recipe. |
craftar forge variants [--forge <dir> | --workspace <dir>] [--json] |
Lists ingredients that have variants, nearest first, with the profile each came from, its distance to the base and its hunks counted by suggested class ([1 evolution · 2 block]), then any variant whose base is missing from the Forge. Read-only; exits 0 either way. --json prints {groups, orphans}; each variant carries classes: {evolution, value, block}, which add up to distance.hunks. |
craftar forge impact [--forge <dir> | --workspace <dir>] [--json] |
Lists every workspace registered on this machine that reads this Forge — by its path, by a git remote of this clone, or through another clone of the same remote — with what its next sync would do against the Forge as it is now: a header, then one line per workspace sorted by path (unchanged, the next-sync counts, missing or error: <message> — a workspace whose sync would refuse for a declared parameter with no value is error: sync refused: declared parameter(s) with no value: <keys>; · after push (<remote>) for a remote match, · after push and pull (<dir>) for a clone match; , pins <ref> when the workspace pins a ref). When no registered workspace reads this Forge, the short header (craftar forge impact — <forge>) then one line: no registered workspace reads this Forge on this machine; when the registry is off, the short header then one line: the registry is off (CRAFTAR_NO_REGISTRY); when the registry cannot be read, exits 1. A workspace whose Forge is remote is refused with the same message as forge unify. A remote URL holding credentials is not matched — one warning per remote, remote <name> of <dir> holds credentials in its URL — not matched (the URL is never printed). Read-only; no fetch. --json is a contract: {forge, registry, workspaces: [{path, profile, match, via, ref, state, counts, error}]} (every key always present). |
craftar forge diff <type/name> [--against <profile>] [--forge <dir> | --workspace <dir>] [--json] |
Shows the differences between a base ingredient and each of its variants: a header carrying the same distance forge variants reports, then hunk by hunk, each with a suggested class and reason — evolution (one side is newer text), value (an identifier-like token swapped in shared prose, with a suggested param.<slug>) or block (lines only one side has) — then the files that exist on only one side. A suggestion never decides anything. It compares the raw Forge text, section marker lines included, so a base with markers shows them as hunks against a variant that has none. Read-only. --json prints an array of {ref, profile, distance, diff}; each hunk carries suggestion: {class, reason, tokens?} next to its kind. |
craftar forge unify <type/name> --profile <p> (--take base|variant | --plan <file> | --save-plan <file>) [--no-impact] [--prune-recipes] [--forge <dir> | --workspace <dir>] [--json] |
Resolves one variant back into its base, hunk by hunk. --save-plan writes a reviewable plan with every decision set to keep, each hunk annotated with its suggested class (which --plan ignores) and, for a value hunk, a pre-filled params list (token → suggested key); on a block hunk, a pre-filled section: { name } from the Markdown heading above (or section-<n>); and on a hunk touching a section the base already has, that section's name. It refuses a path that resolves inside the Forge (after .. segments and symlinks) and a path that already exists, so it never overwrites a Forge file or a plan you already edited; it writes nothing else. A hunk set to take: param turns each listed token into {{key}} in the base, declares the base's text as the key's default in the base's ingredient.yaml and writes the variant's text into the variant profile's profile.yaml params — only after proving that both the base's and the variant's text render back exactly, and only when the same plan resolves the variant; it refuses (with the Forge untouched) a key another layer, profile or ingredient already uses, a variant another profile also uses, a whitespace-only difference, and a YAML file that would not round-trip unchanged; in a Forge at schema 3 or above it never extracts into a key under integrations. (the prefix is reserved there). A hunk set to take: section (with section: { name, lines? }) wraps the base's lines in section markers as the default and writes the variant's lines into the variant profile's sections — consecutive hunks with one name form one section, lines: "<from>-<to>" widens it over equal base lines, and hunks inside a section the base already has only write the profile value; it is proved (both sides render back exactly) and needs the same plan to resolve the variant; take: param and take: section may share a plan, never a hunk. It refuses (Forge untouched) a take: param or take: section on a file no target emits, a variant that holds markers, a profile that already sets that section (another profile for a new section, or the variant's profile with other content), a span that cuts a hunk or crosses another section, a span that covers a hunk outside the run (including take: base on a hunk inside a section the plan fills), a hunk mixing lines inside and outside an existing section, and a span reaching a missing final newline; when it adds a section to a Forge still at schema: 1 it sets schema: 2 in craftar.forge.yaml in place (refused if the file does not round-trip). --plan applies an edited plan, refusing when it is not for this exact ingredient/profile or either side's fingerprint moved since it was saved. --take base|variant resolves every decision to that side. Writes the merged base and, once every difference is resolved, removes the variant and rewrites every recipe reference to it (ingredients) to name the base. It never edits an extends, and deletes a recipe or repoints a profile's recipe list only under --prune-recipes; otherwise it edits a profile only to write the values of a take: param or take: section extraction into the variant's own profile. A <recipe>--<profile> left identical to <recipe> is reported as a warning unless pruned; --prune-recipes deletes such a recipe and repoints every profile that names it — the candidates are found in a dry pass, their profile files join the git check, and pruning is refused when another recipe's extends names the candidate, when the registry is off or unreadable, when the registry did not check every workspace (no registered workspace, a missing entry, one whose configuration did not load), when a registered workspace's plan throws, when a workspace's recipes.add or recipes.remove names the suffixed recipe, when a profile's recipe list cannot be edited in place (a YAML document that does not round-trip), when a profile selects the candidate through its integrations block, or when the Forge no longer loads when the prune reloads it; each remaining candidate is deleted and its profiles repointed only when every registered workspace plans the same files (the same paths and the same bytes) with and without it — any difference refuses that one candidate, and the Forge stays as unify left it. --prune-recipes with --no-impact is refused before anything is read. unify plans every registered workspace of the Forge before its writes and again after them, and reports one impact: line per workspace — no effect, 1 file changes — <path> / <n> files change — <paths>, missing or error: <message> (each line carries the same · after push (<remote>) / · after push and pull (<dir>) / , pins <ref> suffix as forge impact) — replacing the next: run craftar status … hint when at least one workspace was checked; an unreadable registry is one warning. The three warnings — a removed variant a workspace may name in overrides.ingredients.disable, a new parameter a workspace's overrides.params now overrides, a new section a workspace's overrides.sections now fills — now name the concerned workspaces with the file that holds the entry, vanish when the registry proves none is concerned, or keep their previous text with a reason in parentheses (the registry could not check N workspaces, no workspace registered, registry off, registry unreadable). --no-impact skips both impact passes and restores the previous warning text (the identical-to-sibling warning keeps its new ending). Every recipe rewrite is checked before the first write, so one that cannot land (a reference behind a YAML alias) is refused with the Forge untouched; a failure after writing began (an I/O error, a locked file) names the paths already touched and the git checkout / git clean commands that undo them. A merge never adds, removes or changes a section marker other than the sections the plan declares; a plan or --take variant that would is refused before the first write — take base for the marker lines, or take: section to fill the section; the citation checks of take: param also read the profiles' section values, which can cite {{key}}. ingredient.yaml is never merged: when the two sides' metadata differs (beyond name, as and origin), the variant stays unresolved and the differing fields are named — edit it by hand, or --take base to discard the variant, metadata included. Requires a clean git checkout in the Forge with at least one commit (--save-plan excepted) — the Forge has no lock, so git is the undo. It also refuses when any path it would overwrite or delete (the base, the variant, the recipe files it rewrites, the profile a parameter or section extraction edits, the profiles --prune-recipes repoints, craftar.forge.yaml when it is bumped) is ignored, untracked, modified, or flagged skip-worktree or assume-unchanged in the index, since git could not restore it. --json prints {base, profile, resolved, written, removed, unresolved, variantRemoved, recipes: {rewritten, identicalToSibling, pruned, kept}, metaDiffers, params, profileEdited, sections, manifestEdited, impact, warnings} for --take/--plan (every key always present, [] when empty), or {base, profile, plan, unresolved} for --save-plan. In a schema 4 Forge take: param refuses, with the Forge untouched, a plan that would write a profile param an MCP server cites as a key it declares, and an MCP variant with no params under a base that declares some stays (metaDiffers then holds params); and when that variant's server cites a key the base declares, a second warning line says what discarding it does. --take base discards it, with neither line — the next sync may then render the base's params into the MCP files of the workspace that variant came from. |
craftar forge promote <type/name> --profile <p> [--forge <dir> | --workspace <dir>] [--no-impact] [--dry-run] [--json] |
Makes a profile's variant the base — <type/name> is the base, --profile names the variant, as for forge unify. The base takes the variant's whole directory (every file, raw bytes; its ingredient.yaml with the base's name and no as); every other profile that resolves the old base today gets it back as a variant of its own, <name>--<q> (the old base's whole directory, raw bytes); and the recipes are repointed so each profile resolves, in the same position, the ingredient holding the bytes it resolved before: a recipe naming the variant names the base; a recipe naming the base that the other profile owns by name (<recipe>--<q> or <q>-steering) is edited in place; any other recipe naming the base is not edited — each profile resolving it gets an owned copy <recipe>--<q> (the recipe's text with its name and that one entry changed: slot, extends, params and comments carry over) and its profile.yaml names the copy in the same position. The variant's directory is then removed. Other variants of the base are not touched; when no other profile resolves the old base, nothing is created and its text is discarded (the old base is resolved by no profile — discarded (git holds it)). Before the first write: the Forge must be a local directory (a remote one is refused before anything is fetched), a git repository with a commit and a clean tree; git must hold every path the run overwrites or deletes, and ignore none it creates; no path may be reached through a symbolic link; every YAML edit is made in place and refused when the file does not round-trip or the edit does not read back as exactly that change; and the slot proof — on a scratch copy holding the edits, every profile that resolves today must resolve the same ingredients in the same order (the variant now the base, the old base now its own variant, each holding the files of the one it replaces), through recipes on the same slots, with the same parameters, sections, targets and resolution warnings. Refused, exit 1, the Forge untouched: a <type/name> that is not an ingredient, is itself a variant, or has no variant for <p>; a Forge that is remote, not a repository, without a commit, dirty, or with a path git does not hold; a <name>--<q> that already exists for a profile that also resolves the base, the promoted profile resolving the base itself, or a recipe naming both (the Forge is inconsistent); a <recipe>--<q> that already exists; a profile that reaches a recipe naming the base only through another recipe's extends (edit the Forge by hand); in a Forge at schema 3 or above, a profile that resolves a recipe naming the base only because its integrations block selects it (selects recipe <slot>-<kind> … through integrations.<slot> — promote edits a recipes list, and a block's selection is not in one; edit the Forge by hand); a recipe whose file and name disagree; and a failed slot proof, which names the profile and what would differ — an owned recipe another profile also resolves fails it. A profile that does not resolve today is warned about and neither given a variant nor checked. Impact, as forge unify: the workspaces registered on this machine that read the Forge are planned before and after the writes — impact N workspace(s) planned before and after: no effect when none changes, otherwise one impact: line per workspace with its files, and impact no workspace of this Forge is registered on this machine when there is none. A workspace changes only through a reference the Forge cannot see — recipes.add of a shared recipe naming the base, recipes.remove of a recipe this run copied for its profile (the profile now names the copy, so the removal no longer applies), or overrides.ingredients.disable naming the base or the variant — and those are named in a warn line (… its next sync follows the Forge, not this promotion). --no-impact skips both passes; with the registry off (CRAFTAR_NO_REGISTRY) or unreadable the command still runs and warns that no workspace was checked; a workspace synced elsewhere is not known. --dry-run runs every check and the first impact pass, prints what would be written (would hold, would be created, impact N workspace(s) planned: not written, not compared) and writes nothing. When a variant was created the output ends with next craftar forge variants — the old base now shows as N variant(s) to unify: the old base is now an ordinary variant per profile. --json, a tooling contract: {base, profile, dryRun, removed, created, recipes: {edited, created: [{name, from}]}, profiles, impact, warnings} in that order — removed and created are refs, impact is the array forge unify --json gives ([{path, profile, match, via, ref, state, files, error}], [] under --no-impact or --dry-run, or when no workspace was checked), and every array is always present; adding a key is additive, removing or renaming one needs an Upgrading line. Exit 0 written, or --dry-run passed; 1 a refusal, or a failure after writing began — the error then names the paths already written and the git checkout / git clean lines that restore them (a directory they leave empty does not stop a re-run). Nothing is committed: review the working tree and commit, or git checkout / git clean to undo. |
craftar services up [service...] [--wait] [--workspace <dir>] |
Starts the workspace's local services: docker compose -p <project> -f .craftar/compose.yaml [--env-file .env] up -d. It reads craftar.lock only — no Forge, no network — and hands Compose only a stack and fragments the lock records: it exits 1, one line per file with its fix, when a recorded file under .craftar/ is missing (run craftar sync), was edited by hand (run craftar drift discard <path>: it regenerates the file, or removes it when the Forge no longer produces it), or the stack or a fragment is a file of yours the lock does not record (run craftar status), and when the lock records no service file (no recipe brings a service, or the workspace was never synced); a service name starting with - is refused. [service...] are Compose service names; --wait waits until they are running or healthy. Exits 1 as well when Docker or its Compose plugin is missing or Compose is older than 2.20.0. Otherwise exits with Compose's own code. Nothing else in craftar starts a container. See Local services. |
craftar services down [--volumes] [--workspace <dir>] |
Stops them (docker compose -p <project> [-f .craftar/compose.yaml [--env-file .env]] down). Never refused on the files or the lock: when a recorded file is missing or edited, the stack is not craftar's, the lock cannot be read, or Compose is older than 2.20.0, the project is stopped by its name alone. An unreadable lock is a warning, not a refusal. --volumes also removes the volumes the stack declares. Exits with Compose's code; 1 when Docker or its Compose plugin is missing. |
craftar services status [--json] [--workspace <dir>] |
One line per container of a declared service (a service with none reads not created) — its ingredient, state (not created, or Compose's own: running, exited…), health and published ports — then every pending file with its fix — a recorded file that is missing or edited, or a stack or fragment that is unrecorded (the lock does not record it). Exits 0 also when nothing runs; 1 when Docker cannot be asked (--json still prints, every service not created) or the lock cannot be read. A container of the project that no recorded fragment declares is listed too ((not declared); ingredient: null in --json, where health, composeFile and docker.compose can be null as well; docker.compose is the bare version, 2.24.5 for 2.24.5-desktop.1). --json: {workspace, project, composeFile, docker: {available, compose}, services: [{name, ingredient, state, health, ports}], pending: [{path, reason}], warnings}. pending[].reason is missing, edited or unrecorded. |
All commands but workspaces take --workspace <dir> (default: current directory); the forge commands, recipes and ingredients also take --forge <dir> as an alternative to it — a directory: a URL there is refused — and targets needs no Forge. With a remote Forge (see Remote Forge), status, sync, diff, explain, ls, recipes, ingredients, add recipe, remove recipe, forge variants and forge diff fetch first and take --offline to use the cached copy; targets never fetches; forge unify and forge promote refuse a workspace whose Forge is remote — clone it and pass --forge <dir>; import --write-config keeps a remote forge: and sets only profile and targets.
forge/
├── craftar.forge.yaml
├── ingredients/
│ ├── rules/<name>/{ingredient.yaml, rule.md}
│ ├── agents/<name>/{ingredient.yaml, agent.md}
│ ├── commands/<name>/{ingredient.yaml, command.md}
│ ├── skills/<name>/{ingredient.yaml, SKILL.md, …}
│ ├── scripts/<name>/{ingredient.yaml, <file>}
│ ├── hooks/<name>/{ingredient.yaml, <file>}
│ ├── mcp/<name>/ingredient.yaml # server definition (its declared params rendered at schema 4)
│ ├── steerings/<name>/{ingredient.yaml, steering.md} # Kiro-only, no Claude counterpart
│ └── services/<name>/{ingredient.yaml, compose.yaml, …} # a Compose fragment and its files — no AI coder's; see Local services
├── recipes/<name>.yaml
└── profiles/<name>/profile.yaml
An ingredient's as and a script's or hook's files entries become part of the paths sync writes, so one that climbs out of its folder with .. (as: ../x, files: [../x.sh]) fails the Forge load. A leading / anchors nothing (/../x is refused too). A spelling that stays inside (./run.sh, a/../run.sh) is kept as written.
craftar.forge.yaml:
name: forge
schema: 2 # 1 (the default), 2, 3 or 4; 2 is required for section markers, 3 for integrations, 4 for params in an MCP server — each includes the ones below it
description: Craftar Forge — shared harness ingredients, recipes and client profiles.ingredient.yaml (rule):
type: rule
name: frontend-angular
inclusion: fileMatch # always | fileMatch | manual | auto (Kiro steering modes)
fileMatchPattern: projects/acme-portal-front/**
targets: "*" # or [claude-code, kiro]
tags: []
origin: { workspace: acme-portal-workspace, path: .claude/rules/frontend-angular.md }
params: # optional: {{deploy.api}} in the body renders "acme-portal-api" unless a stronger layer sets it
deploy.api: { default: acme-portal-api, description: The API repository }The keys above are the whole vocabulary (params declares the ingredient's own parameters: each default is a string, number or boolean, and fills only this ingredient's placeholders): an ingredient.yaml with a key the schema does not declare (a typo such as incluson, or a field no command reads) fails to load, and the error names the file and every unknown key at once. The one exception is an MCP server, which is the tool's own configuration: command, args, url, env and type are checked, and every other key is kept and emitted as it is, in the order the Forge holds it.
ingredient.yaml (mcp):
type: mcp
name: acme-docs
authEnv: [ACME_DOCS_TOKEN] # names only, never a value
targets: "*"
tags: []
server:
type: http
url: https://mcp.acme.dev/docs
headers: { X-Team: acme } # not declared by Craftar: passed through to both MCP files
timeout: 30authEnv names the environment variables the server needs — names only, never a value. It is a declaration: below schema: 4 the server is emitted exactly as written, and at 4 only the params it declares are rendered into it — see Server params (how the server receives the variable is yours to write: a ${NAME} reference where the tool expands one, or nothing at all for a server that inherits the environment — Craftar never expands it). An item that is a token of a known format fails the load.
Server params. In a Forge that declares schema: 4, an MCP ingredient can hold one server for every client and take the part that differs from a param it declares:
# ingredients/mcp/idp/ingredient.yaml # profiles/acme/profile.yaml
type: mcp recipes: [base] # base lists mcp/idp
name: idp integrations:
params: idp:
integrations.idp.url: {} url: https://idp.example.com/mcp
server:
type: http
url: "{{integrations.idp.url}}"(The block has no kind here, so it only gives the param. With kind: generic the Forge would also need a recipe idp-generic.) At schema 4 every string value of server — url, command, each item of args, each value of env and headers, any key the tool defines, at any depth — has {{key}} replaced for the keys the ingredient declares in its own params, with the value the layers give (the ingredient's default, recipe defaults, the profile's integrations and params, the workspace's overrides.params). Never a key (a server name, a variable name, a header name), never a number or a boolean; the result is always a string (port: "{{port}}" renders "8080"), and substituted text is not substituted again. ${NAME} and a tool's own {{ … }} are left alone. Both .mcp.json and .kiro/settings/mcp.json get the same rendered server. Below schema: 4 a server is written exactly as the Forge holds it, braces included, with no warning. Schema 4 includes 3 and 2: a Forge that moves from 2 to 4 also gets its integrations blocks read.
What sync does with a {{key}} in a server, at schema 4:
| The key is | Result |
|---|---|
| declared by the ingredient, and some layer gives it a value | substituted |
| declared, with no value in any layer | left as written, and the sync is refused — a server is a file sync writes, so this is the unset declared-parameter refusal (sync, sync --check, status, diff --exit-code exit 1) |
| not declared by the ingredient, while some layer gives the key a value | left as written, with one warning per ingredient and key: mcp/idp: server cites {{org}}, which has a value but is not declared in the ingredient's params — left verbatim |
| not declared, and no layer gives it a value | left as written, silently: braces that belong to the tool |
The credential check. .mcp.json is generated and committed, and a param's value can come from any hand-written layer — the gitignored craftar.local.yaml among them. This is not the check on a forge: URL (see Remote Forge): the two read a URL by different rules. So at schema 4 every substituted value of every server a target writes is checked before a plan is returned:
- a URL with credentials (
url-credentials), as the whole value or inside it, or a token of a known format (its kind, such asgithub-token) fails the plan:sync(also--checkand--dry-run),status,diff,lsandexplainexit 1 and write nothing, thedriftcommands fail the same way,add recipe,remove recipeandinitrefuse before they write,doctorreports it underplan,craftar workspacesandforge impactshowerror; - a high-entropy value under
envorargs(high-entropy-value) warns, and the plan stands — organisation and project names inargscan look like one. (A warning failsdoctor --strict.)
The message names the ingredient, the field and the kind — mcp/idp: the value rendered into server.headers.Authorization looks like a credential (github-token) — … — and never the value. The way out is to keep the secret out of a param: have the server read it from the environment, and declare the variable in authEnv so doctor and the example settings file name it. A URL whose userinfo is made only of ${NAME} references, such as https://${IDP_USER}:${IDP_TOKEN}@idp.example.com/, is let through. doctor's mcp-env checks the names it finds in the rendered server's env and in authEnv; it reads no other field.
The check narrows the road; it does not close it. Still passes, though it is a secret: a token of an unknown format (outside env and args nothing looks for it; inside them it can only warn, and only when the whole value is letters, digits and + / = _ -) — so Bearer <token>, KEY: <token> and a …;Password=… connection string pass when the token's format is not a known one; a secret in a URL's path or query (https://idp.example.com/mcp?api_key=<token>); a URL userinfo holding /, ?, #, a quote, , or ;, or white space other than a tab or a line break directly before the @ — so a token followed by a space (https://<token> @host/) passes, and so does a tab inside the userinfo; a quote, , or ; directly after the ://; a tab or a line break inside the :// itself (https:<TAB>//user:<secret>@host/ — there is then no :// to find); a user-only token whose scheme is broken by a tab, a line break or a space (ht<TAB>tps://<token>@host/ — it no longer reads as http(s); with a password it is still refused); credentials with no :// (user:<secret>@host); a user-only token on a scheme that is not http(s), ws(s) or <x>+ one of them (ssh://<token>@host/); anything that is neither a URL with a userinfo nor a token of a known format. One more way a value can show: a param value written as ${<something>} and rendered under env reads, to doctor's mcp-env, as a variable reference: it prints <something> as the name of a variable it expects when that has the shape of a name and is not set — a known token format inside the braces is refused before that, an unknown one is not. Refused, though it is not a secret: a URL on http(s), ws(s) or <x>+ one of them with a user and no password (https://acme@dev.azure.com/acme/Portal); a userinfo that is not made only of plain references — a literal mixed with one (postgres://acme:${DB_PASS}@db.example.com/app; write ${DB_USER}:${DB_PASS}) or a reference with a default (https://${A:-x}@host/); a URL with no path followed, with none of / ? #, white space, a quote, , or ; between, by text holding an @ (https://a.example.com&cc=ops@example.com); a URL on any scheme but file written with an empty authority — extra /, \ or white space after the :// — when what follows then reads as a userinfo (sqlite:///a:b@c.db; on http(s), ws(s) or <x>+ one of them an @ alone is enough: https:///ops@example.com; in prose, see :// a:b@c); a URL with no path, query or fragment followed by a tab or by line breaks — a blank line between too — and then an @ (https://registry.npmjs.org, then @acme/pkg on the next line; a markdown link or an unquoted CSS url(…) followed by a line that starts with @), since a tab, a line feed or a carriage return directly before an @ is read past — on a scheme that is not http(s), ws(s) or <x>+ one of them, only when the text before the @ holds a :, such as a port; a standalone run of 52 lowercase letters and digits that holds a digit. The only way round a refusal is to keep the value out of a param: write it literally in the ingredient, or have the server read the environment. A literal the Forge author wrote in a server, with no placeholder in it, is not checked, at any schema: it is emitted as written, and craftar import's scan stays the guard on what enters a Forge.
Sections. A body (its body files: the file of a rule, agent, command or steering — rule.md etc. by default; SKILL.md of a file-layout skill; every .md, .txt, .json, .yaml or .yml file of a dir-layout skill; the text files listed in files of a script or hook — a body file stays inside the ingredient directory; one declared outside it, or behind a symlinked directory, or spelled differently from the file on disk (letter case, on a case-insensitive file system), fails sync, status, diff, explain and ls, naming it) can hold blocks a profile or a workspace replaces. A file no target emits (a notes.md beside rule.md) is ignored — for sections and for {{param}} citations alike; a file a target emits but does not render as text is copied (see below). A block sits between two marker lines, and its content is the default:
Dispatch reviewers after every commit.
<!-- craftar:section flavors -->
| Repo | Reviewer |
|---|---|
| `acme-api` | backend-reviewer |
<!-- /craftar:section -->
Never edit what a reviewer reads.A marker is a whole line starting at column 0, with single spaces (trailing spaces or tabs are tolerated). Sections do not nest, a name is declared once per ingredient, and a line that looks almost like a marker is an error. An indented marker is plain text — indent it to show the syntax in a rule. The parser is line-based and not Markdown-aware, so a column-0 marker inside a fenced block is still a marker. Markers are read only in body files; in a file a target emits but does not render as text (a skill's .sh, an image) they are copied as they are, with a warning; in a file no target emits they are ignored. Agent and command frontmatter lives in ingredient.yaml and is never expanded; a rule's or skill's frontmatter is part of its body file, so a column-0 marker there is read like any other. A Forge whose bodies hold a marker must declare schema: 2 or higher in craftar.forge.yaml (3 includes it), so that craftar 0.6.2 and older refuse it instead of emitting the markers; import sets it to 2 when it relies on markers and the manifest is below 2. A section can also be extracted from a variant with forge unify (take: section), not only added by hand and re-imported.
Profile profile.yaml, with section values keyed by <type>/<name> (the output name: a variant's values are its base's), then by section name:
name: globex
recipes: [base]
targets: [claude-code, kiro]
params: {}
sections:
rule/review-posture:
flavors: |
| Repo | Reviewer |
|---|---|
| `globex-api` | backend-reviewer |
rule/repo-discovery:
desktop-client: "" # empty: the section renders nothing for globexA value is a string. It replaces the section's lines; a value without a final newline gets one, and "" empties the section. The blank lines around the markers stay when a section is emptied, so put a separating blank line inside the section when an emptied section should leave none extra. A value may cite {{param}}: sections are expanded before params are substituted.
Integrations. A profile's integrations block can select recipes and give params in a Forge that declares schema: 3 or above:
# profiles/acme/profile.yaml — in a Forge whose craftar.forge.yaml declares schema: 3
name: acme
recipes: [base, stack-dotnet]
integrations:
pm:
kind: azure-devops
org: acme
project: Platform
idp:
kind: mcp
url: https://idp.example.com/mcp
browser: # no kind: values only
chromeDevtools: "1.7.0"# recipes/pm-azure-devops.yaml
name: pm-azure-devops
slot: pm
ingredients: [mcp/azure-devops, rule/stories-azure-devops]<!-- ingredients/rules/stories-azure-devops/rule.md -->
Stories live in Azure DevOps, organisation {{integrations.pm.org}}, project
{{integrations.pm.project}}.integrations.<slot>.kind: <kind> selects the recipe named <slot>-<kind> — after the profile's own recipes, before the workspace's recipes.add; the mapping is by name, so a slot named like an unrelated recipe prefix selects that recipe (a slot stack with kind: dotnet selects stack-dotnet). Every scalar of the block, kind included, is the param integrations.<slot>.<key>, under the profile's own params (an explicit params entry of that key wins, silently) and under overrides.params; an integration with no kind gives params only; a mapping or a list is not a param and is warned wherever a resolution warning prints (sync, status, recipes, doctor, among others). Only in a Forge that declares schema: 3 or above (what this README says of a schema 3 Forge holds at 4) — set by hand, never by a command; below 3 the block is ignored without a warning: if nothing happens, look at craftar.forge.yaml. A kind with no recipe, or a malformed block: sync, status, diff, explain, ls exit 1; craftar recipes marks nothing as in use and warns; doctor reports it under plan; craftar workspaces and forge impact show error; craftar add recipe, craftar remove recipe and craftar init fail (they resolve the profile); import into that profile and drift promote in any of its workspaces fail; a workspace whose recipes.remove names the missing recipe (written by hand: craftar remove recipe never adds a name the Forge does not have) resolves and syncs, but import and drift promote still refuse for that profile (they resolve the profile alone). Malformed: a slot name is letters, digits and _, starting with a letter; a key inside a slot is letters, digits and _; kind is lowercase letters, digits, ., _, -, starting with a letter or digit; a slot whose value is not a mapping — nothing under it (pm:), a string (pm: azure-devops), a list — is an error; a key with no value is absent. Another profile's malformed block does not fail craftar recipes or forge unify --prune-recipes run for this one — both read that profile's recipes as written and warn (profile <q>: … — its recipes are read as written). An integration param reads (profile <name>) in craftar explain's params line. import never writes or edits a profile's integrations, and in a Forge at schema 3 or above never infers an integrations.* param. A workspace has no integrations of its own: recipes.remove takes the recipe out (the params stay), overrides.params changes a value; after add recipe <other> --replace on the same slot: the {{integrations.<slot>.*}} params still carry the block's values. Write the block in block style: a flow mapping with padding the YAML writer does not reproduce (pm: { kind: azure-devops }, two spaces after the colon) resolves but import, forge unify (take: param, take: section, --prune-recipes) and drift promote cannot edit that profile file in place, as for any file that does not round-trip. A URL written in the block is rendered into a rule, a generated and committed file — as one written in params is; keep credentials out of both.
Recipe:
name: stack-frontend-angular
extends: []
slot: frontend # optional: two recipes on one slot cannot coexist
ingredients: [rule/frontend-angular, agent/frontend-reviewer]
params: { angularVersion: { default: "20" } }Workspace craftar.yaml:
forge: ../forge # a path, or a git URL (see Remote Forge)
ref: v1.4.0 # optional, with a git URL: a branch, a tag or a full SHA
profile: acme-portal
targets: [claude-code, kiro] # optional, overrides the profile
recipes: {add: [], remove: []} # edited by craftar add recipe / remove recipe
overrides:
params: {}
sections: {} # same shape as the profile's sections
ingredients: {disable: []}Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → craftar.yaml → craftar.local.yaml (personal, git-ignored). Bodies may use {{param}} placeholders; a placeholder in a file no target emits is never rendered, and only body files count as a citation for forge unify and import — plus, in a schema 4 Forge, the keys an MCP server cites and its ingredient declares (see Server params); a placeholder in a body with no value in any layer is left untouched and reported as a warning by status and sync (Angular's {{ 'X' | localize }} does not look like a placeholder and passes silently). When the key is one an ingredient declares under params with no default, that is also an error: sync refuses and writes nothing (see sync in Commands). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — targets: [kiro] in craftar.local.yaml means exactly [kiro]. Omit a key in craftar.local.yaml to inherit it — an empty list there means empty.
Sections layer the same way, weakest → strongest: the body's default → the profile's sections → craftar.yaml's overrides.sections → craftar.local.yaml's, merged per section, so a workspace that sets one section of an ingredient keeps the profile's others. There is no recipe layer for sections.
craftar targets prints this matrix from src/core/capabilities.ts; a test plans one ingredient of every type for each target and fails when a cell and its emitter disagree. native — written in the tool's own place for that type, as the Forge holds it (for mcp, judged on the server file); converted — written in another form (frontmatter or a banner added, references rewritten, another container format, or folded into a shared file); unsupported — not written: sync warns and names an ingredient aimed at that target.
| type | claude-code |
kiro |
agents-md |
|---|---|---|---|
rule |
native | converted | converted |
agent |
native | converted | unsupported |
command |
native | converted | unsupported |
skill |
native | converted | unsupported |
mcp |
native | native | unsupported |
script |
native | unsupported | unsupported |
steering |
unsupported | native | unsupported |
hook |
native | unsupported | unsupported |
service |
unsupported | unsupported | unsupported |
service — unsupported in all three: a service is aimed at no AI coder (its targets defaults to [], and an explicit one is skipped with the usual warning). sync writes it under .craftar/ for no target — see Local services.
claude-code — emits .claude/rules|agents|commands|skills|scripts|hooks and .mcp.json from the Forge (frontmatter kept byte-for-byte). Each MCP server is written exactly as the Forge holds it, undeclared keys included, in its own key order — in a schema 4 Forge with the params its ingredient declares rendered into its string values (Server params). When a written MCP ingredient declares authEnv, sync also writes .claude/settings.craftar.example.json — env with each declared name and an empty value — for the developer to copy into their own, untracked .claude/settings.json; a hand-maintained .claude/settings.example.json is never touched; kiro and agents-md write no counterpart. Existing files keep their line endings and BOM; new files are LF without a BOM. Steering is Kiro-only (its targets default to [kiro]); one aimed at claude-code explicitly (targets: "*" or a list naming it) is skipped with a warning.
kiro — reproduces, then extends, the hand-written sync-steering.ps1 script it replaces:
steering = inclusion frontmatter + GENERATED banner + rule body, with .claude/rules/ rewritten to .kiro/steering/ — except a reference to a rule kiro does not write, which follows that rule: kept as .claude/rules/<x>.md when claude-code writes it, AGENTS.md (rule: <x>) when only AGENTS.md holds it, otherwise <x> (rule not in this workspace) with a warning; an unknown name is still rewritten and is reported. A reference inside a link's text follows the same rules (in a link kept for claude-code, only those references move), and a link whose target reads "rule not in this workspace" and whose text is exactly its own path becomes <x> (rule not in this workspace) (backticks kept); an unknown name keeps its link. Files are UTF-8 without BOM, CRLF. The same applies to agent prompts and descriptions, command bodies and descriptions, and skill text files; a steering ingredient is emitted as written. On top of what the script did, it also generates .kiro/agents/*.json (tools mapped to Kiro names, resources bound to the agent's stack rule + repo-discovery, or **/*.md for generic agents), .kiro/steering/commands/*.md, .kiro/skills/*/SKILL.md and .kiro/settings/mcp.json. .kiro/settings/mcp.json receives each MCP server exactly as the Forge holds it (in a schema 4 Forge, as .mcp.json gets it: declared params rendered), in its own key order — including keys Kiro may not use (type always went through). The banner text is a parameter (kiro.banner) so existing workspaces can adopt without a rewrite. Scripts and hooks have no Kiro equivalent: they are skipped with a warning.
agents-md — one AGENTS.md with the always-on rules concatenated and the scoped rules after them — each listed at the file a target of the workspace writes for it (.claude/rules/<name>.md when claude-code writes it, else .kiro/steering/<name>.md when kiro does), or, when no target writes it, embedded in full under a > Scoped rule — <scope> line, for tools that read the open standard (Codex, Cursor, Warp, Copilot, Kimi…). A .claude/rules/<x>.md reference inside a body follows the rule it names: it is kept when claude-code writes that rule, becomes .kiro/steering/<x>.md when only kiro writes that file (from the rule or from a steering ingredient of that name), becomes AGENTS.md (rule: <x>) when the rule's text is in this file, and otherwise becomes <x> (rule not in this workspace) with a warning (an unknown name is left as written when claude-code is a target); a reference inside a link's text is resolved the same way, and a link whose target reads "rule not in this workspace" and whose text is exactly its own path becomes <x> (rule not in this workspace) (backticks kept); without claude-code, the warning also names references to other .claude/ files, which are left as written. Every other ingredient type aimed at agents-md — including through the default targets: "*" — has no AGENTS.md equivalent: it is skipped with a warning — one line per type, naming every skipped ingredient. The file keeps the line endings and BOM of the one it replaces; a new one is LF without a BOM.
Conversion of hooks/subagents to other tools is out of scope here: the plan is to delegate that to rulesync rather than reimplement it.
These come straight from workspaces that removed an earlier generator, and the code enforces them:
- Only files in
craftar.lockare ever written or removed. A file that exists but was never generated is a collision: reported, never touched. A managed file that was hand-edited is drift: reported, never overwritten unless--overwrite-drift. - Hashes are EOL- and BOM-normalized. A CRLF checkout on Windows does not read as a hand edit. (
sha256over LF/no-BOM text.) - Orphans are removed. When an ingredient leaves a recipe, its generated files disappear in every target — no stale steering loading into Kiro sessions.
- No hand-kept mirrors. Kiro agents, commands and skills are generated from the same ingredients as their Claude counterparts.
- Reuse, don't clobber. Importing a second workspace into the same Forge reuses identical ingredients and creates explicit
--<profile>variants for the ones that differ, listing them so a human unifies or parameterizes.
A workspace's local stack — a queue emulator, a cache — can be an ingredient. A service ingredient is a Docker Compose fragment plus the files it mounts:
# ingredients/services/echo/ingredient.yaml
name: echo
type: service
files: [init/setup.sh] # every other file written next to the fragment
executable: [init/setup.sh] # a subset of files, written with mode 0755 (POSIX)
# binary: [init/logo.png] # a subset of files copied as bytes — list it under files too
# compose: compose.yaml # the fragment (this is the default)
params:
greeting: { default: hello }Every declared path — compose and each entry of files, executable, binary — is relative to the ingredient directory, with only letters, digits and . _ @ + - in each segment, no . or .. segment (a leading ./ aside), and never ingredient.yaml. files lists neither the compose file nor a path twice — both, like ingredient.yaml, compared without regard to case, so Init.sh beside init.sh is refused. A file may be both binary and executable.
# ingredients/services/echo/compose.yaml — a complete Compose file; paths are relative to it
services:
echo:
image: example/echo:1
ports: ["8080:80"]
environment:
GREETING: "{{greeting}}" # a craftar parameter, filled by sync
TOKEN: ${ACME_TOKEN:-local} # Compose's own variable, left for Compose
volumes:
- ./init:/init:ro
- ${CRAFTAR_WORKSPACE}/projects:/src:roA recipe lists it like any ingredient (service/echo). craftar sync then writes, and records in craftar.lock under the owner services:
| Path | Content |
|---|---|
.craftar/services/<name>/<compose> and each of its files |
the fragment and its files, with sections expanded and {{param}} substituted — always written LF without a BOM, whatever the Forge holds (an init script with CRLF does not run in a Linux container). A file listed in binary is copied as bytes. |
.craftar/compose.yaml |
the stack: a Compose include of every fragment, in resolution order |
.craftar/.gitattributes |
* -text, so git never converts those line endings |
They are generated files like any other: status, diff, sync --check and --overwrite-drift treat them the same, and removing the last service from the recipes makes the next sync delete them. sync never runs Docker.
The plan fails when a fragment is not a YAML mapping, declares no service, declares a top-level name (the project name is craftar's) or include (every file of the stack is in the lock) — also when they arrive through a YAML merge key (<<): a fragment is checked as Compose reads it; when a service's extends names a file (it may extend a service of the same fragment), or when two fragments declare the same key under services, volumes, networks, configs or secrets — Compose would keep one silently. A file not listed in binary must be UTF-8 text. It warns when two fragments publish the same literal host port, and when a rendered file holds a secret-like value (the kind and the line, never the value): keep values out of the Forge and use ${NAME}.
Running it. craftar services up runs docker compose up -d on the stack, services down stops it and services status shows it (see Commands). They need Docker with its Compose plugin, found on PATH by the lookup that finds dotnet and git (see Project templates); up needs Compose 2.20.0 or later (the first with include), while down and status work on an older one. sync needs no Docker. up reads the lock, not the Forge: it comes up on what the last sync wrote, and refuses a file that is missing or edited rather than run something the lock does not record. Nothing runs Docker implicitly — not sync, not init, and doctor only asks for the version. craftar adds no policy over what a fragment declares: a service runs with your Docker rights, as the hooks and scripts of a Forge run with yours.
- The project is
craftar-<slug>-<hash>: the workspace directory's name, lowercased, every character outsidea-z 0-9 _ -replaced by-, cut at 40, then the first 8 hex digits of the SHA-256 of the workspace's real path. It is computed, never written, so the files under.craftar/are the same on every machine and two checkouts are two projects.services statusprints it. Moving a workspace changes it: stop the old one withdocker compose -p <old name> down. A name that leaves nothing but dashes givescraftar--<hash>. ${CRAFTAR_WORKSPACE}is set bycraftar servicesto the workspace's real path, so a fragment can mount a repository of the workspace..envat the workspace root is passed to Compose (--env-file) byup, and bydownwhen it reads the stack, when it exists. craftar never reads its values;craftar doctor'sservices-envreads only its key names, and warns for each${NAME}a fragment uses with no default (${NAME},${NAME?…},${NAME:?…}) that is set neither in the environment nor there. Only the braced form is read. A key is read asNAME=at the start of a line.CRAFTAR_WORKSPACEis never reported.- Plain Compose works too, for
logs,execand the rest:CRAFTAR_WORKSPACE="$(pwd -P)" docker compose -p <project> -f .craftar/compose.yaml [--env-file .env] logs -f. - Sections in a fragment. A section marker is a whole line, so a Forge fragment holding one is not valid YAML until
syncrenders it, anddocker compose configcannot be run on the Forge copy;{{param}}has no such cost. - Older craftar. A Forge with a service ingredient needs this version in every workspace that reads it, whether or not the workspace uses the service — see Upgrading.
npm test runs two layers that need nothing outside the repository, on any OS:
- Unit tests build a Forge and a workspace in a temp dir (
test/helpers/forge.ts) and cover resolution, every file state, apply, each emitter, the importer, the secret guard and the CLI exit codes. - Golden workspaces —
test/golden/acme-portalandtest/golden/acme-web, synthetic — run the oracle script end to end: import → every fileadopt, sync byte-identical (a CRLF file and a BOM file included), second sync a no-op, Forge edits, drift, orphans, variants.test/golden/**is-textin.gitattributes, so line endings survive checkout. The golden.kiro/is a snapshot of the emitter's output: after an intended emitter change, regenerate it withnpx tsx test/helpers/regen-golden.tsand review the diff.
CI runs typecheck, build and tests on Linux and Windows with Node 22 and 24. The oracle below is skipped there — a green CI is not evidence against a real workspace.
test/oracle.test.ts imports a real client workspace (a private fixture, never committed) and asserts that craftar sync reproduces its 13 generated steering files, 5 hand-written steering files, 3 Kiro agent JSONs and every .claude/* file byte for byte, that a second sync is a no-op, that a Forge edit flows to both targets, that a hand edit becomes drift and survives, that orphans are removed, and that unrelated files are left alone.
The fixture is not in this repository. Point the suite at a workspace of your own:
set CRAFTAR_ORACLE_FIXTURE to its path, or drop a single workspace directory into
fixtures/. Without it the oracle suite is skipped and the remaining tests still run.
npm testCopy a workspace with .claude/ and .kiro/ into fixtures/ (keep local-stack/ and projects/ out).
forge: may be a git URL — https://…, ssh://…, file://…, or the SCP-like git@host:org/forge.git (any SSH user) — with an optional ref: a branch (followed: its tip at each fetch), a tag or a full 40-hex SHA (fixed); refs/heads/<x> / refs/tags/<x> when a name is both; no ref follows the remote's default branch. Abbreviated SHAs are refused. A ref next to a path forge: is ignored, with a warning.
- The cache. Each machine keeps one copy per URL in
$CRAFTAR_HOME/forges/(~/.craftarby default): a bare repository and one tree per resolved commit, never written by Craftar, shared by every workspace on the machine. Each tree holds the commit's bytes on every platform: the host'score.autocrlf,core.eoland global attributes file do not apply to it; the Forge's own.gitattributesdoes. Trees unused for 14 days are removed after a fetch.craftar cache pruneremoves whole entries nothing uses and old trees on demand (see Commands). The cache and the workspace registry (below) are the two places, both under$CRAFTAR_HOME, where Craftar writes outside a workspace and a Forge on its own. - The network.
sync(with--checkand--dry-run) anddiff --exit-codefetch first and exit 1 when they cannot;status,diff,explain,ls,recipes,ingredients,add recipe,remove recipe,forge variantsandforge difffall back to the cached copy with a warning;--offlineuses the cached copy on purpose; with no cached copy every one of them exits 1.targetsnever fetches: it reads the cached copy, and without one prints the matrix unmarked, with a warning. A full SHA already cached is not fetched again. - Authentication belongs to git (credential helper, SSH agent). A URL holding credentials (
https://user:token@…, a password in any scheme) is refused incraftar.yamlandcraftar.local.yaml, naming the file and never the value. In CI, configure git (e.g. a credential helper or an SSH deploy key), never a token inforge:; without a terminal, git does not prompt. - Writing into the Forge stays local:
import,forge unifyandforge promoteneed a clone passed as--forge <dir>; Craftar never pushes. After importing into the clone, push it forsyncto see the import. craftar.local.yamlmay overrideforge:(a local clone while you work on the Forge); every planning command then warns not to commitcraftar.lockor the generated files.
A writing sync records the workspace in $CRAFTAR_HOME/registry.json (~/.craftar by default): its real path, profile, the Forge it used and where that Forge lives, the resolved recipes and targets, and when sync last ran there. It is an index, not the memory — craftar.lock stays that, and nothing in a workspace changes because of it. craftar workspaces prints it as a table with every status computed on the spot — up to date, outdated, drift, no lock, missing or error — read without the network (a remote Forge from the cache; --fetch fetches first); forget and prune remove entries, and a workspace that is gone stays listed as missing until you prune it.
CRAFTAR_NO_REGISTRY— read the name as "no registry", the wayNO_COLORmeans "no colour". Set to any non-empty value, it turns the registry off:syncneither reads nor writes it, andcraftar workspacesexits 1. An empty value counts as unset. Set it on CI runners and throwaway checkouts, whose directories would only pile up asmissing.- A registry that cannot be written (an unwritable
$CRAFTAR_HOME, a busy lock after 60 s, a file this craftar cannot read) leavessync's files, lock and exit code as they would be, and adds one warning. Craftar never overwrites a registry it cannot read: repair or remove it yourself. - The registry is per machine: paths are native and absolute, and SSH and HTTPS spellings of one remote are two Forges in it, as they are two cache entries.
craftar initreads it for the Forge it offers when it asks: the Forge of the most recentsyncon this machine, skipping a directory that is gone. The questions never write it — the firstsyncregisters the new workspace as anysyncdoes. WithCRAFTAR_NO_REGISTRYset, or a registry it cannot read, no Forge is offered and the question is asked without a default.- The registry also tells a Forge its workspaces:
forge impact,forge unifyandforge promoteread it (never write it); a workspace synced on CI, another machine or underCRAFTAR_NO_REGISTRYis not known to them, which is why--prune-recipessays so.
craftar templates list shows what the profile declares; craftar templates install runs the installs. Nothing installs implicitly — not sync, not init, not doctor.
A profile can list templates to install:
# profiles/acme/profile.yaml
templates:
- kind: dotnet-new
source: https://pkgs.example.com/acme/nuget/v3/index.json
packages:
- Acme.Templates.Api
- Acme.Templates.Front::2.1.0A kind is a lowercase name (dotnet-new); a package is a NuGet ID, optionally with a version (<id>::<version>). A source is an https:// URL with a host, in printable ASCII, and no user or password. There is no workspace layer: craftar.yaml and craftar.local.yaml cannot add or remove a template.
- The store is the user's — one per machine user, shared by every workspace; the last install of a package ID wins. Remove one with
dotnet new uninstall <package>. - Feeds are additive — a declared
sourceis added to the feedsdotnetalready uses, so an ID nobody reserved on a public feed can be served from there. Pin it with the team's ownNuGet.Configat the workspace root (craftar looks there only, and generates none).installanddoctorwarn when an entry declares asourceand the root has noNuGet.Config. - Private feeds — authentication is NuGet's (
NuGet.Config, a credential provider,--interactive); craftar holds no token, and asourcecarrying a user or a password is refused and never printed. Asourceis printed as written bylist,--dry-runandinstall: craftar refuses a user or a password in it, not a token in its path or query — keep those out of the profile. dotnetcomes fromPATH— craftar looks for it itself, in the absolute entries ofPATHonly (on Windows, adotnet.exeordotnet.comin an entry with a drive or a UNC host), and never in the workspace root unlessPATHnames it: adotnetcommitted in a repository is not run byinstallor bydoctor's probe, which both run there. An empty or relative entry, an unsetPATHand a.cmd/.batshim are not used, and neither is anode_modules/.binof the directory craftar is started in or of a directory above it — the entriesnpxandnpmput first. The same lookup findsgitanddocker.- Unknown kind — an entry of a kind this craftar cannot install (
npm-create,nx) is listed, skipped and warned;installthen exits 1 (--dry-runstill exits 0).
Phase 0 — foundations (done): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; forge variants, forge diff and forge unify (take: param, take: section); sections.
Phase 1 — client profile (in progress). Shipped: the read-only catalogue (recipes, ingredients, targets with the capability matrix); remote Forge (git URL + ref, a per-machine cache, the schema 2 lock) and craftar cache prune; the workspace registry and craftar workspaces; craftar add recipe / remove recipe; craftar init with flags; craftar doctor; craftar forge impact, forge unify --prune-recipes and forge promote; an interactive craftar init (choose the Forge and the profile); authEnv on MCP ingredients (the environment variables a server needs, by name, and the generated .claude/settings.craftar.example.json); craftar drift show / drift discard / drift promote (list the files edited by hand, discard an edit, or carry one into the Forge as a profile value or a variant); a sync refused when a declared parameter has no value; craftar import --report (a Markdown report of an import, as paths and line counts) and import --prefer-kiro (a rule's source taken from its Kiro steering file); a profile's integrations block (PM tool and IDP as recipes selected by the profile); {{param}} inside an MCP server. Next: cloning a profile's repositories at init.
Phase 2 — environment (in progress). Shipped: local services (service ingredients, .craftar/, craftar services up | down | status); craftar templates list | install (dotnet new project templates declared in a profile).
Later: rulesync bridge for Codex/Kimi/Cursor specifics; craftar docs validate; GitHub Action and Azure Pipelines task around sync --check; craftar ui; craftar mcp + Claude Code / Agent Plugin packaging.
- A program is no longer looked for in a
node_modules/.binof the current directory or of a directory above it.npx craftar— and annpmscript — puts those entries first onPATH, as absolute ones, so agit, adotnetor adockercommitted there was the program craftar ran. They are now skipped, in the lookup and in thePATHthe program gets, so what it starts in turn (ssh, a credential helper) is not found there either. The current directory is the one craftar is started in, whatever--workspacenames. The entries are compared as written —.,..and repeated separators resolved, no link followed, without case on Windows. Anode_modules/.binanywhere else onPATH(npx's own cache, a global prefix) is looked in as before. dockeris now found asgitanddotnetare (see to 0.27.2): in the absolute entries ofPATHonly, never in the directory craftar runs in; on Windows onlydocker.exe/docker.com, in an entry with a drive or a UNC host — adocker.cmdshim is not a program.dockergets the same reducedPATH(whatdockerthen does with it when it starts a credential helper was not measured). This holds forcraftar services up | down | statusand for thedockercheck ofcraftar doctor.- A program reachable only that way is no longer found, and craftar then behaves as on a machine without it: for
git, what to 0.27.2 describes — a path Forge whosegitis no longer found is read without its commit, and its next writingsyncrewritescraftar.lockonce withforge.commit: null; fordotnet,craftar templates installandcraftar doctorsay the .NET SDK is not available; fordocker,craftar services upanddownstop withdocker not found — install Docker with the Compose plugin 2.20.0 or later,services statuswarns the same, shows every declared service asnot createdand exits 1 (docker.availableisfalseunder--json), anddoctorwarnsdocker not found. A toolchain that lives only in such anode_modules/.binhas to be installed in another directory thatPATHnames: an entry written by hand for that samenode_modules/.binis skipped like the onenpxadds. - No generated file changes, and no lock changes for a workspace whose programs are found as before.
- A Forge URL with credentials that an earlier release recorded is no longer printed from the registry. A release before
0.27.1could record such a URL (see to 0.27.1). For amissingor anerrorrow whose recorded URL holds credentials,craftar workspaces --jsonnow gives(a URL with credentials — not shown)asforge.sourceandnullasforge.key.craftar cache prune— its lines and, under--json,kept[].path,removed[].pathandwarnings— and thecachecheck ofcraftar doctorput the same label where the name of the cache entry such a row names would be:paththen readsforges/(a URL with credentials — not shown),forges/(a URL with credentials — not shown)/trees/<commit>orforges/~removing-(a URL with credentials — not shown)-<pid>, which is not a path, and two such entries read the same; a failure either command reports with a path inside such an entry names it the same way. No--jsonkey is added, removed or renamed, and no type changes:forge.keycould already benull(a path Forge that cannot be resolved); aremoterow can now carry it too. A row that loads gives whatcraftar.yamlholds, as before. - A cache entry that no registered workspace names any more is still printed by its name. Once every workspace that named the URL has synced again without the credentials, the registry no longer records it, and nothing in the cache says which entry was named after a refused URL.
craftar doctor(<name> — nothing names it,<name> — a fetch never completed) andcraftar cache prune(the line of the entry it removes or keeps, of a tree of one it keeps, and a warning about it) then print that name, which holds the user and the password. WithCRAFTAR_NO_REGISTRYset or a registry that cannot be read,cache prunenames every entry that way, anddoctorevery one whose fetch never completed. Runcraftar cache pruneright after the re-sync, in a terminal rather than where the output is kept (a CI log, a file) — and rotate the credential either way. - A
forge:that holds credentials behind a\in place of the//is refused —https:\\user:token@host\…, or any run of/and\after the scheme that holds a\. It was read as a path, andForge not found at …printed it whole. It is now refused wherever a URL with credentials is (… › forge holds credentials in the URL — remove them and let git authenticate):craftar.yamlandcraftar.local.yaml,init --forgeand its questions,import --write-config, a remote of a Forge clone. As with://, what counts is any user onhttp(s)and auser:passwordon another scheme, andfile:is left alone. A drive path (C:\…) is read as before — a name there cannot hold the:of auser:password— and so is such a spelling with no user and no password. - A
ref:that holds a URL with credentials is not quoted. The messages that name a ref —ref "…" is ignored: the Forge is a path,ref "…" not found in <url>,ref "…" is both a branch and a tag(which then gives nowrite refs/heads/…hint) and… the cached copy cannot resolve …— readref (a URL with credentials — not shown)for such a value, as the whole of it or inside it. The interactivecraftar initsaysRef: (a URL with credentials — not shown) (from craftar.local.yaml)for one it reads there, and refuses one typed as an answer (the answer holds credentials in a URL — a ref is a branch, a tag or a full SHA), where it offered it back as the next default. Every other ref is printed as before. - No byte change: no emitter changed, no lock changes and no workspace sees
update. No exit code changes for a workspace whoseforge:is still accepted. In one whoseforge:is spelled with the\and holds credentials, the commands that load it failed before (Forge not found at …) and fail now with the refusal;craftar import --write-config, which replaced the value, now exits 1 with the Forge untouched, where it exited 0;craftar cache prunerun there, or with--workspacenaming it, keeps every whole entry (unchecked: the checkedcraftar.yamldoes not read) where it pruned; and thecachecheck ofcraftar doctorthere no longer reports an orphan — its line, or lines, end(orphans not checked: the checked craftar.yaml does not read).
- A Forge can declare
schema: 4, which includes 3 and 2. Every craftar before0.28.0refuses such a Forge atcraftar.forge.yaml—0.27.0to0.27.2, which read a Forge atschema: 3, included — so none can write a templated server with its braces. Upgrade every machine and CI that reads the Forge before setting it. Nothing changes for a Forge at 1, 2 or 3. - Moving a Forge to 4 renders every server placeholder that is declared and valued: its workspaces see
updateon.mcp.json/.kiro/settings/mcp.jsonat their nextsync. An undeclared placeholder whose key has a value becomes a plan warning (and failsdoctor --strict); a declared one with no value refuses the sync. A Forge that moves to 4 from 1 or 2 also gets its profiles'integrationsblocks read — 4 includes 3; see to 0.27.0. - At schema 4, a param value that looks like a credential stops the plan when it is rendered into a server (see The credential check):
sync,statusanddiffexit 1 and write nothing. - At schema 4,
craftar importcompares an MCP base assyncrenders it: a workspace whose server equals the rendered base reuses it, where it was a variant before;forge unifyrefuses atake: paramplan that would write a profile param a server cites as a declared key. forge unify --json:metaDifferscan holdparamsfor an MCP pair at schema 4 — a value in the list, not a key.- The message for an invalid manifest
schemavalue now listsexpected 4among its alternatives.
gitis looked for in the absolute entries ofPATHonly — never in the directory craftar runs in, which is a workspace or a Forge, unlessPATHnames that directory by an absolute entry: agitcommitted in a repository is not run through an empty or a relative entry.npx craftaris the case to know: npm puts<cwd>/node_modules/.bin, and every ancestor's, first onPATHas absolute entries, so agitcommitted there is still run, as before. (Since0.28.2those entries are skipped: see to 0.28.2.) An empty entry (a leading or a trailing:,::), a relative one (.,bin, an unexpanded~/bin) and an unsetPATHare not looked in. On Windows onlygit.exe/git.comin an entry with a drive or a UNC host count.- A
gitreachable only that way is no longer found, and craftar then behaves as on a machine without git:craftar doctorsaysgit not found(anerrorwhen the workspace names a remote Forge, awarnotherwise); a remote Forge fails;forge unify(a--save-planrun aside),forge promoteanddrift promoterefuse, since git is their undo;forge impactno longer matches a workspace through a clone's remotes; and a path Forge is read without its commit — headers readno git, andcraftar workspacescannot say whether that Forge moved. - No generated file changes;
craftar.lockdoes, once, for a path Forge whosegitis no longer found: the next writingsyncrewrites it withforge.commit: null, and the machine's registry records the same;statusandsync --checkstay as they were, and the commit comes back with the first writingsyncthat findsgitagain. No lock changes for a workspace whosegitis found as before. gitanddotnetget aPATHof absolute entries only — everygitcraftar runs, anddotnetforcraftar templates installand fordoctor's probe;dockeris started as before (until0.28.2) — so what they start in turn (ssh, a credential helper) is not looked for in the working directory either (measured on Linux for thesshthatgitstarts; on Windows whatgititself searches was not measured, and whatdotnetstarts was not measured on either). By the same filter, a helper, agit-remote-*orgit-lfsthat lives only on a relativePATHentry — an unexpanded~/binis one — is no longer on thePATHthatgitsearches (not measured).
- A URL whose credentials sit behind an empty authority is refused, like any other URL holding credentials.
https:///user:token@host/…— extra slashes after the scheme, or a\among them — passed the check, though a client reads past them and uses the user and the password: curl past up to three slashes, a WHATWG URL parser past a\too. It is now refused wherever a URL with credentials is:forge:incraftar.yamlandcraftar.local.yaml(… › forge holds credentials in the URL — remove them and let git authenticate),init --forgeand its questions, and a remote of a Forge clone, whichforge impactthen does not match. For a profile's templatesourceonly the spelling with a\changes (a source must not carry a user or a password); the one with slashes was refused already. Every scheme butfileis read that way; a URL with no user and no password behind the extra slashes (https:///host/…) is still accepted, and so isssh:///git@host/…. - If a workspace named its Forge that way, the credential is already written down — rotate it. craftar recorded the URL as given in
craftar.lock(forge.source; the lock is a committed file) and in$CRAFTAR_HOME/registry.json, and the cache entry under$CRAFTAR_HOME/forges/carries it in its name; a templatesourcespelled with the\was printed bytemplates listandtemplates install. Take the credential out of the URL, let git (or NuGet) authenticate, andsyncagain in every workspace that named it: the lock and the registry then record the new URL, andcraftar cache pruneremoves the entry of the old one once no registered workspace names it (amissingone counts untilcraftar workspaces prune). Until thencraftar workspaces,craftar doctorandcraftar cache prunecan still print what was recorded — the URL, or the name of the cache entry. Since0.28.1they print a fixed label for what the registry still records (see to 0.28.1). - No byte change: no emitter changed, and no lock changes for a workspace whose
forge:is still accepted. No workspace seesupdate; no exit code changes for a workspace whoseforge:and whose profile's templatesources are still accepted, and no--jsonkey changes.
- A Forge can declare
schema: 3. craftar before0.27.0refuses such a Forge: every command that loads it fails oncraftar.forge.yaml, naming the file;doctorandcraftar workspacesreport it as anerror. Upgrade every machine and CI that reads the Forge before setting it. Nothing changes for a Forge at 1 or 2, whatever its profiles'integrationshold. - In a schema 3 Forge,
integrations.is a reserved param prefix forforge unifyand for import's inference:forge unifyno longer extracts into such a key and import no longer infers it; aparamsentry underintegrations.still renders, and wins over the block's value of the same key. - In a schema 3 Forge,
craftar importexits 1 for a profile whose block is malformed or selects a recipe and does not resolve. - In a schema 3 Forge,
drift promoterefuses to fork a recipe the block selects when it is not that profile's alone — another profile resolves it, or does not resolve at all; edit the ingredient in the Forge, orcraftar drift discard. --prefer-kiroon a rule left to an integration recipe warns and skips the rule; its steering file is not used, since the rule is not imported.drift show --json'sreason(a string exactly when a file is not promotable) can readprovided by integrations.<slot>in a schema 3 Forge.- The
import --reportfile can hold a new section,## Left to an integration recipe (<n>), only when something was left; a report that left nothing keeps its bytes. - The message for an invalid manifest
schemavalue now listsexpected 3among its alternatives.
- A profile's
templates:key is now kept at load and read bycraftar templatesand bycraftar doctor. Nothing else reads it:sync,statusand every other command behave exactly as before, whatever the key holds. craftar doctorgains two check ids,templatesanddotnet:templateswhen the profile declares atemplatesblock (an emptytemplates:line declares none);dotnetwhen that block is accepted and declares adotnet-newentry (the .NET SDK answeringdotnet --versionat the workspace root); neither goes abovewarn, so no exit code changes without--strict. With--strict, four new causes exit 1: a refused block (a mapping, as older examples showed, is one), an entry of an unknown kind, asourcewith noNuGet.Configat the workspace root, and the .NET SDK not being available.- No byte change: no emitter and no lock changed. No workspace sees
update.
- A Forge that holds a
serviceingredient is read only by this version and later. An older craftar loads every ingredient of a Forge and does not know the type: in every workspace of that Forge — also one whose recipes bring no service —status,syncand every other command that loads the Forge stop with a validation error namingingredients/services/<name>/ingredient.yaml, and write nothing.craftar workspaces, which reads each workspace on its own, shows a workspace stopped this way as anerrorrow. Add the first service to a shared Forge only once every workspace of it — every member and the CI — runs this version. With a remote Forge, a workspace pinned byrefto a commit from before the service does not read it. - A workspace that syncs a service gets a
craftar.lockofschema: 3— schema 2 plus one thing: a file entry'stargetcan be"services". Where the Forge still loads (the service was removed from it again, say), every craftar from 0.11.0 up to the release before this one refuses that lock by name (craftar.lock declares schema 3, which this craftar does not read — upgrade craftar) and writes nothing —workspacesandforge impactshow the workspace as anerrorrow, with that line. A workspace with no service keeps its schema 2 lock, byte for byte, and one that drops its last service goes back to schema 2 on the sync that removes the files — unless a hand-edited file under.craftar/is kept, which keeps the lock at 3 until it is deleted or discarded (craftar drift discard). - New ingredient type
service, new folderingredients/services/, new generated folder.craftar/(see Local services).craftar targetshas aservicerow,unsupportedin every target;targets --json:ingredientTypesgains"service"and each target'scapabilitiesaservicekey — no newstate. "target": "services"can now appear instatus --jsonanddrift show --json, for a file under.craftar/and for its lock entry — and in any other output that carries a file's target. A script that mapstargetto an AI coder needs a case for it.drift show --jsongives such a filepromotable: falsewith the new reasonservice file;craftar drift promoterefuses it.- New commands
craftar services up | down | status.services status --jsonis a tooling contract (its shape is in Commands). craftar doctorhas two more checks, bothworkspacescope and present only in a workspace with a service:docker(warnwhen Docker or its Compose plugin is missing or older than 2.20.0) andservices-env(warnper variable a fragment needs that is not set; it appears only when the plan succeeds).doctor --jsongains those two ids; no existing id changes.- Two text outputs changed for everyone:
craftar targetsprints one more line under its paths block (service: written by sync under .craftar/ for no target; see craftar services), andcraftar ingredientsprintstargets nonefor an ingredient aimed at no target. No--jsonshape changed beyond what is listed above. - No byte change for a workspace without a service: no emitter changed, and its lock is written as before.
craftar forge promote <type/name> --profile <p>is new: it makes a profile's variant the base, and gives the old base back to every other profile that resolved it as a variant of its own.forge promote --jsonis a new tooling contract. Nothing to do for a Forge or a workspace that does not run it.- No byte change: no emitter, no import decision and no lock changed;
forge unifyprints what it printed. No workspace seesupdate.
craftar import --prefer-kiro <rule,…>is new and additive: without the flag,importreads, decides, writes and prints what it did before. Nothing to do.- No byte change: no emitter, no import decision without the flag and no lock changed. No workspace sees
update.
- A file the schema refuses is reported on one line, and never with a value it holds.
invalid <file>: <key>: <what was expected>; <key>: …replaces zod's JSON list of issues, which quoted the refused value — so a credential written where one of a fixed set of words is expected reached the terminal. A key is still named, as a path (overrides.sections.<key>) or as an unrecognized key, and so is the type received (Expected string, received number) — never the value. It holds for a Forge file,craftar.yaml(merged withcraftar.local.yaml), the registry (invalid <file>: …), aforge unify --planfile (<file>: <key>: …, with noinvalid), and the refusals ofimportanddrift promote(does not fit the ingredient schema: …,does not load (…)),forge unify(the edit no longer loads: …), andadd recipe,remove recipeandimport --write-config(it no longer loads: …). - A
craftar.lockor a registry that does not read is named, and nothing of it is quoted.craftar.lock is not valid JSONandcraftar.lock is not a valid lock: <key>: …replace the raw parser and zod messages; a registry that is not valid JSON readscannot read <file>: not valid JSON.declares schema <n>, which this craftar does not readprints the schema only when it is a whole number, and(not a whole number)otherwise. craftar doctor: theconfig,lockandregistrymessages carry the same text —<what> (<key>: <what was expected>, and N more)keeps its shape, withoutreceived '<value>';craftar.lock is not valid JSONno longer ends with the parser's message in parentheses.- No exit code and no
--jsonkey changes; the text of an error string inside--jsonfollows the new wording — among themdoctor --jsonchecks[].message; thewarningsofworkspaces --json,targets --json,forge unify --jsonanddrift promote --json;forge impact --jsonerror;impact[].errorofforge unify --jsonandimpact.workspaces[].errorofdrift promote --json; andforge unify --jsonrecipes.kept[].reason. - No byte change: no emitter and no lock changed. No workspace sees
update.
- New commands
craftar drift show,craftar drift discardandcraftar drift promote: list hand-edited files, discard their edits, or carry one into the Forge.drift show --jsonanddrift promote --jsonare new tooling contracts. Thesyncskip lines fordriftandorphan-driftwere reworded (prose, not a contract).
- A YAML syntax error no longer prints the offending lines. In a Forge file — a manifest, a recipe, a profile, an
ingredient.yaml— the message is nowinvalid <file>: <code> at line N, column M, orinvalid <file>: YAML syntax errorwhen the parser gives no code (it was the yaml parser's message, source lines included, so a credential on a broken line reached the terminal). yaml warnings — an unresolved tag, a collection used as a key — are no longer printed either, frontmatter included.forge unify --planreports a plan that is not valid YAML the same way; a plan that cannot be read or does not match its schema still reads<file>: <error>. No exit code and no--jsonshape changes; no generated byte changes. - No byte change: no emitter and no lock changed. No workspace sees
update.
craftar import --report <file.md>is new and additive: without the flag,importreads, writes and prints what it did before. Nothing to do.- No byte change: no emitter, no import decision and no lock changed. No workspace sees
update.
- A declared parameter with no value now stops
sync. If an ingredient declares a key underparamswith nodefault, a file cites{{key}}, and no layer sets it,craftar sync,sync --check,statusanddiff --exit-codeexit 1 andsyncwrites nothing. Before, the file was written with{{key}}in it and a warning. Runcraftar doctorto list the keys; set each in the profile'sparamsor inoverrides.params. A{{key}}no ingredient declares is left verbatim with its warning, as before. craftar doctorreports such a key aserror(it waswarn), so its exit code is 1 without--strict. A declared key no file cites stays awarn.craftar workspacesandcraftar forge impactshow such a workspace aserror, with the reason. Neither--jsongains a key or a value.craftar initon such a profile writescraftar.yaml, runs no sync and exits 1;init --no-sync,add recipeandremove recipeexit 0 and saynext sync: refused — ….status --jsongainsunsetParams(always present, afterwarnings);craftar explaingains aparamsline.- No byte change: no emitter and no lock changed. No workspace sees
update.
- A Forge that declares
authEnvneeds this version — an older craftar refuses the unknown key, naming the file. craftar targetslists one more path forclaude-code · mcp:.claude/settings.craftar.example.jsonnext to.mcp.json.
- In a terminal,
craftar initwith--forgeor--profilemissing asks instead of failing. Off a terminal (stdin or stdout is not a TTY) it still exits 1 on stderr, but the line changed: commander'srequired option '--profile <name>' not specifiedbecame--profile is required when craftar init does not run in a terminal — pass it, or run craftar init in a terminal to be asked(and the--forge/ both forms). A script matching the old text must match the new. - The check moved. An existing
craftar.yaml, a--workspacethat is a file and a bad flag value are now reported before the missing flag. craftar init --workspace <dir>under a path that is a file is refused withcannot use <path> as a workspace: it is not a directoryinstead of a rawENOTDIRerror.- No byte change: no emitter,
plan,status,syncor lock changed. No workspace seesupdate.
- A change confined to bytes that are not valid UTF-8 is now seen. The content hash decoded every file as UTF-8, which folds each invalid byte into the same replacement character, so two files differing only there hashed the same:
syncreported a raw-copied file (a script or hook outside the text extensions, a binary in a skill directory)unchangedafter its Forge copy changed and never wrote it,forge variantscalled such a pair identical, andforge diffshowed no hunk. A file that is not valid UTF-8 is now hashed as its bytes, with line endings and a leading UTF-8 BOM normalized as for any other file; valid UTF-8 hashes exactly as before. - No generated file changes;
craftar.lockdoes, once, for a workspace holding such a file. Its lock entry was written with the old hash. While the file equals what the Forge produces,statusandsync --checkread itunchanged, and the next writingsyncrewrites that entry'shash(and the lock'sgeneratedAt) and nothing else. A workspace whose generated files and files on disk are all valid UTF-8 sees no change at all. - Until that sync, a file that differs from the Forge reads
driftwhenever the old lock cannot say who changed it. The old hash recorded such a file without its invalid bytes. So when the Forge's copy still matches the lock under the old hash and the workspace file differs from it — the Forge changed only in those bytes (the very change 0.17.3 never delivered), the file was hand-edited there, or an editor re-saved it as UTF-8 — craftar cannot tell a Forge change from a hand edit: the file readsdrift,sync --checkfails and the file is kept untilsync --overwrite-drifttakes the Forge's bytes. A Forge change the old hash could record still readsupdate, and a file that left the Forgeorphan, as in 0.17.3. - A file restored by hand to bytes that are not valid UTF-8 reads
drift, where it readunchanged. A text file saved in a legacy encoding is emitted with replacement characters (the limit below); a workspace copy put back to the original bytes equalled the generated one under the old hash, so 0.17.3 called itunchanged. It differs, and now readsdrift: kept, reported bysync --check, overwritten only bysync --overwrite-drift. Two cases are as in 0.17.3, because a lock entry does not say which hash wrote it: when the Forge then changes that file in a way the old hash could record, such a copy readsupdateand is rewritten; and once the file leaves the Forge, it readsorphanand is removed. - A workspace file not yet in the lock that equals the Forge's copy only under the old hash reads
collision, where it readadoptand was then rewritten: a text file (.md,.ps1,.sh…) that is not valid UTF-8 is still emitted with replacement characters — a known limit, unchanged — so the two copies do differ. A.jsonfile is the exception, as before: one that parses to the planned value is still adopted and rewritten. - Fingerprints of ingredients holding a file that is not valid UTF-8 move: a
forge unifyplan saved for one before 0.17.4 is refused as stale — save it again. forge diffandforge variantsreport a difference in a file that is not valid UTF-8 as one hunk of kindbinary, with no lines and no line position, counted as ablock; in--jsonit is a hunk whosekindisbinaryand whosea.linesandb.linesare empty — a newkindvalue besideblockandinline. A pair that differs only in line endings or a BOM has no hunk, as for any other file.forge unifystill refuses to merge such a file, as before.- A
__proto__key is refused instead of dropped. In a profile'sparams,integrationsorrepos, a recipe's or an ingredient'sparams, or a workspace'soverrides.params(the top-level keys of each), a key named__proto__used to vanish with no message; the file now fails to load witha __proto__ key is not allowed, andcraftar importrefuses such acraftar.yamlthe same way. An MCPserverkeeps such a key, as before.
syncno longer writes or removes anything outside the workspace through a..path. An ingredient whoseas, or a script or hook whosefilesentry, climbs out of its folder with..used to be written where the path led — outside its own folder (.claude/scripts/,.claude/rules/,.kiro/steering/…), or outside the workspace — and acraftar.lockentry holding such a path was removed there once it became an orphan. Now a Forge holding one fails to load (as must not climb out of its folder with a .. segment,a files entry must not climb out of the ingredient directory with a .. segment), and a lock holding one is refused (craftar.lock: entry <path> is outside the workspace): fix the ingredient, or remove the entry from the lock and delete the stray file by hand.craftar importnever wrote such a spelling.- No generated byte changes: a spelling that does not climb out (
./run.sh,/run.sh,a/../run.sh, anaswith a/) keeps the path it had. No workspace seesupdate.
- Documentation only: the README status reads phase 1 (phase 0, the foundations, done). No emitter,
plan,status,syncor lock changed. No workspace seesupdate.
forge unify --prune-recipesno longer exits 1 when the Forge stops loading between the recipe cascade's reload and the prune's own reload (something else edited the Forge during the run): every candidate is kept with the reload's message (the Forge no longer loads when the prune reloads it: …). A Forge thatunify's own writes leave unloadable still failsunifythrough the recipe cascade's reload, naming the paths written and thegitcommands that restore them. No emitter,plan,status,syncor lock changed. No workspace seesupdate.
- New command
craftar cache prune: removes the Forge cache entries nothing uses, entries whose first fetch never completed, and trees unused for 14 days. Its--jsonoutput is a tooling contract. craftar doctor'scachefix is nowcraftar cache prune(it was "remove<dir>by hand (craftar cache prune is planned)"): a value change offixindoctor --json. A~removing-*directory underforges/(an interrupted prune's leftover) is no longer reported or counted as an entry.- A process waiting on a cache entry's lock re-creates the entry when another process removed it meanwhile, instead of failing with
ENOENT. - No byte change: no emitter,
plan,status,syncor lock changed. No workspace seesupdate.
- New command
craftar forge impact: lists every workspace registered on this machine that reads a Forge with what its next sync would do against it. Its--jsonoutput is a tooling contract. forge unifyplans the registered workspaces of the Forge before and after writing:impact:lines replace thenext:hint when at least one workspace was checked;--no-impactrestores the previous output except the identical-to-sibling warning's new ending; the three "unify cannot reach workspaces" warnings name the concerned workspaces, vanish when none is concerned and every workspace was checked, or gain the reason in parentheses.forge unify --jsongainsimpactandrecipes.pruned/recipes.kept(additive; always present).- New flag
--prune-recipes: deletes a recipe left identical to its sibling and repoints the profiles that name it, only when every registered workspace plans the same files. - No byte change: no emitter,
plan,status,syncor lock changed. No workspace seesupdate. A prune changescraftar.lock'srecipesat the nextsyncof a workspace on that profile, and that workspace's registry entry (recipes,stack) with it.
- New command
craftar doctor: reports, one line per finding, what is off on this machine and in this workspace. Its--jsonoutput is a tooling contract, likestatus --json. - No byte change: no emitter,
planoutput,statusor lock changed. No workspace seesupdate.
- New command
craftar init: writescraftar.yamlin a workspace that has none, then runs the firstsync. It never edits an existingcraftar.yaml. - A missing
craftar.yamlis now reported withcraftar init --workspace "<dir>" --forge <dir> --profile <name>first andcraftar import … --write-configsecond, by every command that reads the workspace. Only the message changes; the exit code does not. - No byte change: no emitter,
plan,status,syncor lock changed. No workspace seesupdate.
- New commands
craftar add recipeandcraftar remove recipe: they editrecipes.add/recipes.removeincraftar.yamland nothing else; they never sync. - A
recipes.addname the Forge does not hold now fails every resolving command withrecipe "<x>" not found (referenced by craftar.yaml recipes.add)instead of… (referenced by profile <name>), andrecipes/ingredientsshow that text in their warnings (--jsonincluded). Only the message changes; the exit code does not. - No byte change: no emitter,
plan,status,syncor lock changed. No workspace seesupdate.
- A writing
syncnow writes outside the workspace:$CRAFTAR_HOME/registry.json(~/.craftarby default) gains or updates the workspace's entry. On CI runners and throwaway checkouts setCRAFTAR_NO_REGISTRY=1. A registry that cannot be written is a warning; the exit code ofsyncdoes not change. - New command
craftar workspaces(withforgetandprune). Its--jsonoutput is a tooling contract, likestatus --json. - No byte change: no emitter,
plan,statusor lock changed. No workspace seesupdate.
craftar.lockis schema 2, written by the nextsyncof every workspace, path Forge or remote: it addsforge.ref,recipesandtargets. Once a schema 2 lock is committed, every craftar up to 0.10.1 stops on it (a zod error onschema) until upgraded — upgrade the whole team, and CI, before committing it. This craftar reads schema 1 and 2, and refuses a later one by name.- The lock is rewritten only when its content changes (
generatedAtis when it last did): asyncthat changes nothing no longer touches it. The firstsyncafter upgrading rewrites it once. - A
user@host:pathvalue offorge:is a URL now (any SSH user, not onlygit@); before, it was read as a relative path. - A
forge:value holding credentials is refused; arefnext to a pathforge:is ignored with a warning;forge:fromcraftar.local.yamladds a do-not-commit warning. status --jsongains aforgekey (additive).diff,explain,ls,forge variantsandforge diffprint warnings about loading the workspace on stderr. A missing path Forge readsForge not found at <path>.
craftar diffshows orphans. A file the Forge no longer produces is printed as the removal of the whole file (+++ <path> (forge: no longer produced — sync removes it)), and a hand-edited one as a header and one line, sincesynckeeps it. Output changes only in a workspace that has anorphanor anorphan-drift— one whose only pending change was an orphan used to printno differences; elsewhere it is byte for byte 0.9.0's.craftar diff --exit-codeexits 1 when there are differences — exactly whensync --checkwould — and refuses a[path]that names no file craftar manages. Without the flagdiffstill exits 0.- No byte change: no emitter,
plan,statusorsyncchanged. No workspace seesupdate.
- Three new read-only commands:
craftar recipes,craftar ingredientsandcraftar targets. They write nothing — no generated file, no lock, no Forge file. - No byte change: no emitter changed, and
craftar lsprints what it printed before. No workspace seesupdate. - Their
--jsonoutput is a tooling contract, likestatus --json: removing or renaming a key, changing a value's type, or adding a capability state will be announced here.
- A rule reference inside the text of a Markdown link is now resolved like any other, in
AGENTS.mdand in the kiro files. Before, the text of a link thatAGENTS.mdor kiro rewrote was copied as the Forge held it, or blanket-rewritten to.kiro/steering/.- A link whose target reads "rule not in this workspace" and whose text is exactly its own path —
[.claude/rules/x.md](.claude/rules/x.md), backticks or not — becomesx (rule not in this workspace), backticks kept. Any other text keeps the link's usual wording, with its references resolved. - In a link kiro keeps for
claude-code, only the references in the text move; the rest of the text stays as written.
- A link whose target reads "rule not in this workspace" and whose text is exactly its own path —
- No byte change for the
.kiro/files of a workspace whose kiro texts cite only rules kiro writes or names that are no rule, nor for anAGENTS.mdwritten withclaude-codewhose links cite only rulesclaude-codewrites or names that are neither a rule nor asteeringfile kiro writes — a workspacecraftar importproduced keeps its.kiro/bytes, and itsAGENTS.mdbytes unless a link's text names one of its hand-written steering files. AnAGENTS.mdwritten withoutclaude-codeupdates wherever a link's text holds a.claude/rules/<x>.mdreference, known rule or not. In a kiro file, a link's text that cites an unknown name now adds an entry to thekiro:warning; its bytes do not change, except in a link kept forclaude-code, where that name moves to.kiro/steering/. - An affected workspace shows
AGENTS.mdor the.kiro/files asupdate, andcraftar sync --checkexits 1 until it syncs.
- The kiro target stops turning a reference to a rule it does not write into a
.kiro/steering/path that does not exist. Only files under.kiro/change; no other generated file and no lock field.- No byte change: a workspace in which every rule a kiro text cites is one kiro writes, and whose hand-written commands cite no
.claude/rules/path in theirdescription. Every workspacecraftar importproduced is in this case. A cited unknown name now adds a warning. - A cited rule kiro does not write: the reference goes back to
.claude/rules/<x>.mdwhenclaude-codewrites the rule, becomesAGENTS.md (rule: <x>)when onlyAGENTS.mdholds it, or else<x> (rule not in this workspace). - A command written by hand in the Forge (no raw frontmatter): its
descriptionis now resolved like its body, and that includes the directory rewrite.
- No byte change: a workspace in which every rule a kiro text cites is one kiro writes, and whose hand-written commands cite no
- At most one
kiro:warning about rule references per plan. It names every reference reworded to "rule not in this workspace", and every unknown name, which is still rewritten to.kiro/steering/. It never changes an exit code. - An affected workspace shows those
.kiro/files asupdate, andcraftar sync --checkexits 1 until it syncs. - An agent's
resourcesdo not change.
- A
.claude/rules/<x>.mdreference inside a rule body that goes intoAGENTS.mdis resolved by the rule it names. OnlyAGENTS.mdchanges; no other generated file and no lock field.- No change, no warning: a workspace whose cited rules are all written by
claude-code— every workspacecraftar importproduced — and one whose bodies cite no.claude/rules/path and, withoutclaude-code, no other.claude/path either. Withoutclaude-code, a body citing other.claude/files keeps its bytes but gets the warning. - No
claude-code: each reference moves. It becomes.kiro/steering/<x>.md(kiro writes it),AGENTS.md (rule: <x>)(its text is in the file) or<x> (rule not in this workspace). - With
claude-code: only a reference to a ruleclaude-codedoes not write, or to asteeringingredientkirowrites, moves. An unknown name is left as written.
- No change, no warning: a workspace whose cited rules are all written by
- At most one warning per
AGENTS.mdnames every reference turned into<x> (rule not in this workspace)(a reference made to point at.kiro/steering/or intoAGENTS.mdis not reported). Withoutclaude-code, it also names references to other.claude/files (agents, commands, skills, scripts, hooks), which are left as written. It repeats on every run until the body, the cited rule'stargetsor the workspace's targets change, and it never changes an exit code. - An affected workspace shows
AGENTS.mdasupdate, andcraftar sync --checkexits 1 until it syncs. - The kiro emitter still rewrites every
.claude/rules/to.kiro/steering/; 0.8.5 addresses it in part — see to 0.8.5.
AGENTS.mdlists a scoped rule (fileMatch,manual,auto) only at a file a target of the workspace really writes, and embeds it when none does. OnlyAGENTS.mdchanges; no other generated file and no lock field.- A workspace with
claude-codewhose scoped rules keep the defaulttargets— every workspacecraftar importproduced — sees no change, and neither does one with no scoped rule aimed atagents-md. - With
kiroandagents-mdbut noclaude-code, each scoped line moves from.claude/rules/to.kiro/steering/. - With
agents-mdalone, each scoped line becomes the embedded rule, and the file grows. - A scoped rule whose own
targetsexcludeclaude-codemoves to.kiro/steering/, or is embedded when no file-writing target of the workspace admits it.
- A workspace with
- An affected workspace shows
AGENTS.mdasupdate, andcraftar sync --checkexits 1 until it syncs. To keep a scoped rule's text out ofAGENTS.md, takeagents-mdout of that rule'stargets. - References to
.claude/rules/…inside a rule body are emitted as the Forge holds them. In a workspace withoutclaude-codesuch a reference points at no file; a later release (0.8.4) addresses it.
- A file no target emits (e.g. notes beside
rule.md) is no longer read for sections or{{param}}citations — a malformed marker there no longer failssync/status/diff/explain/ls/importor trips theschema: 1gate, and a{{key}}there no longer makesunify/importrefuse. No emitted byte changes. take: paramandtake: sectionon such a file are now refused (… is not emitted by any target). 0.8.1 accepted both there, so a plan saved under 0.8.1 that extracts from such a file no longer applies. A Forge that already ran such atake: sectionunder 0.8.1 has markers in that file and a value in the profile'ssections; 0.8.2 ignores those markers and warns on everysyncandstatusthat the profile sets a section with no such marker — delete the value from the profile, or move the section into the body file. Atake: paramrun under 0.8.1 on such a file left a default iningredient.yamland a value in the profile'sparams; both are inert now — remove them by hand.
- 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.jsonbecomes two-space JSON and keeps its BOM and line endings; Kiro's JSON files become CRLF without a BOM. Only differencesJSON.parseerases are adopted; a different key order is acollision. - 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.
forge unifycan writesectionsinto a profile and setschema: 2incraftar.forge.yaml. craftar 0.6.2 and older then refuse that Forge, as after an import that relies on markers.- A plan that uses
take: sectionis refused by craftar 0.7.x, which does not know the value; a plan saved by 0.8.0 with every decision stillkeeploads in 0.7.x. - U1's advice now ends
take base for the marker lines, or take: section to fill the section.
- A Forge with section markers declares
schema: 2. 0.7.0 refuses to sync a workspace that resolves a marked body whilecraftar.forge.yamlsaysschema: 1(or nothing), andimportsetsschema: 2itself when it writes a section value or compares a workspace file with a marked base. craftar 0.6.2 and older refuse aschema: 2Forge at load, so upgrade every workspace that uses the Forge before its first marker, and when you add markers by hand, setschema: 2in the same commit — an older CLI syncing aschema: 1Forge with markers writes the marker lines into every target. sectionsandoverrides.sectionsare validated. A key of the wrong shape (review-posture.flavorsinstead ofrule/review-posture: { flavors: … }) or a value that is not a string fails the load. A key naming no ingredient, or a section that an ingredient the workspace resolves does not declare, is a warning.importmay refuse more, and write more. It refuses a workspace file holding a section marker at column 0, a Forge base with malformed markers, and acraftar.forge.yamlit cannot bump in place. It may writesectionsinto a profile and bump the manifest.- Recipe and profile bytes can differ from 0.6.2's on re-import. A re-import that swaps a profile's
<name>--<profile>variant for its base — in any Forge, with markers or not — now keeps the base in the variant's place: the profile moves to the shared recipe when that orders the base where the variant was (profile.yamlrecipes), otherwise its owned recipe gets the base in the variant's slot (recipes/<recipe>--<profile>.yaml). 0.6.2 appended the base, soAGENTS.mdchanged order at the next sync; 0.7.0 keeps it. - An in-place profile edit keeps untouched empty fields. 0.6.2 rewrote an untouched
params: {}(ortargets: []) as a block on every import that editedprofile.yaml; 0.7.0 leaves it on its line and switches only the collection it fills. A profile 0.6.2 already rewrote stays as it is. forge unifyrefuses a merge that changes section markers — takebasefor the marker lines.
importrefuses a recipe it writes whose file andnamedisagree. Withrecipes/shared.yamldeclaringname: base, 0.6.0 wrote a secondbasenext to it and the Forge then loaded only one of the two; withrecipes/base.yamldeclaring another name, it read that recipe asbase. Rename the file after thenameit declares and re-run. In a Forge with no manifest yet, a recipe that does not load refuses the import too, even one import does not write, and a profile that does not load now names its file.- A key a run already relied on renders at its current value. Import no longer infers it again, so a line that was ambiguous only through it can now be reused.
- Some sources 0.6.0 reused become variants. A profile value is no longer set when it would move a Forge ingredient whose workspace copy was skipped or rejected for a secret, nor over a default an earlier ingredient of the run rendered with (0.6.0 set it; when another earlier ingredient rendered the key at a different default, that one changed at the next
sync).
- Re-import is template-aware. A workspace whose text an extracted base renders (through its defaults or the profile's
params) is reused instead of becoming a variant, and a new client's values are inferred into its profile. - A second client no longer widens
base. A profile whose ingredients differ from a shared recipe gets its own<recipe>--<profile>. Profiles that already resolve a widenedbasekeep it until their next re-import. - An existing profile, owned recipe and
craftar.yamlare edited in place. Their comments and every other field survive, but the lists import computes are set to this workspace's: an owned recipe'singredients, the import-owned entries of the profile'srecipes(base,stack-*, their--<profile>recipes and<profile>-steering) andcraftar.yaml'sforge,profileandtargets. A file that does not round-trip through the YAML writer is refused (reformat it and re-run). Thecreatedcount no longer lists an existing profile. - A profile value can change on re-import. When a workspace shows another value for a key its profile sets, the profile is updated and the report says
param <key>: <old> → <new>— every workspace on that profile renders it at its next sync. importnow loads an existing Forge first, and refuses one that does not load.
- A Forge edited by
forge unifywithtake: paramneeds 0.5.0 everywhere. The base'singredient.yamlgainsparams, whichcraftar0.4.0 and older refuse as an unknown key. Upgrade every workspace that syncs that Forge. importis not template-aware yet. Re-importing a workspace into a Forge whose base now holds{{key}}creates a new variant of it and rewrites the profile'sprofile.yamlwithout the extractedparams. Avoid re-importing over an extracted base until 0.6.0.{{constructor}},{{toString}}and{{__proto__}}stay literal.substituteused to resolve them to built-in JavaScript members; a body holding one now keeps it verbatim (andstatuswarns that it has no value).
- Unknown
ingredient.yamlkeys are refused. A Forge with a key the schema does not declare stops loading; the error names the file and every unknown key. Rename or remove the key and commit the Forge (forge unifyrefuses paths git does not hold), then re-run. MCPserverkeys are the exception: they pass through. - MCP files may show
update..mcp.jsonand.kiro/settings/mcp.jsonnow carry every key of a server as the Forge holds it (headers,timeout,disabled…) in its own key order. Workspaces whose servers have undeclared keys, or keys stored out ofcommand/args/url/env/typeorder, seeupdateon those files once; an unlocked.mcp.jsonthat readcollisionbecause of undeclared keys ortypefirst now adopts. - Saved
forge unifyplans may go stale. Fingerprints now hash validated metadata, so a hand-writteningredient.yamlthat omits defaulted fields fingerprints differently.--planrefuses a stale plan; re-run--save-plan. importis stricter and reuses more. An ingredient that would not load back (a non-string MCPenvvalue, a name that is not slug-like) refuses the import, naming its source, and so does an existing Forge ingredient with an unknown key or a YAML syntax error (0.2.4 made a variant or an error naming no file). A re-import may now reuse an ingredient where it used to create a variant.
Merging a pull request that bumps the version in package.json, package-lock.json and
src/cli.ts starts a release: when that merge's CI run is green, .github/workflows/release.yml
checks that the three files agree and that the version is not on npm yet, dry-runs the package,
and waits for a maintainer to approve the npm environment. After approval it publishes
craftar@<version> with npm trusted publishing and provenance — no npm token is stored in the
repository — and creates the v<version> tag and a GitHub Release. A merge that does not change
the version publishes nothing.
MIT