craftar
v0.37.1
Published
Craft, sync and convert AI-coding workspace harnesses across clients and tools.
Readme
Craftar
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.craftar mcpserves a workspace to an AI coder over the Model Context Protocol — seven reading tools with the data ofstatus,diff,explain,recipes,ingredients,targetsanddoctor; started with--allow-writes, it also servessync,add_recipeandremove_recipe, which write only in a workspace where a person typedcraftar mcp allow-writesand never discard a hand edit.craftar uiserves a local, read-only dashboard of every registered workspace — the table ofcraftar workspaces, and for one workspace its file states, a file's diff and explanation, and its Forge's recipes and ingredients — on127.0.0.1only; started with--allow-writes, it also syncs a workspace or discards one hand-edited file, each confirmed in the page and never with--overwrite-drift.craftar init --cloneandcraftar repos cloneclone a profile's repositories (itsreposlist) into the workspace, andcraftar reposlists them.craftar init --new-profilecreates a profile in a Forge directory (one file, not committed) and starts the workspace on it. Withagents-md.primary, in a Forge that declaresschema: 5, an always-on rule lives inAGENTS.mdalone, and the files that cite it follow it (see Targets).
The idea in one paragraph
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.
Install
npx craftar --help # run without installing
npm install -g craftar # or install the craftar commandRequires Node ≥ 22.
From source
npm install
npm run build # → dist/
node bin/craftar.js --help
# or during development
npx tsx src/cli.ts --helpQuick start: start an empty workspace from a Forge
# 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.
A new client's first workspace starts its profile too:
# Creates profiles/initech/profile.yaml in the Forge directory (not committed), then the workspace on it
craftar init --workspace C:/Projects/initech-portal-workspace \
--forge C:/Projects/forge \
--new-profile initech --like acme-portalQuick start: bring an existing workspace into a 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.
Commands
| Command | What it does |
|---|---|
| craftar init [--forge <dir\|url>] [--profile <name>] [--new-profile <name> [--like <profile>]] [--ref <ref>] [--targets <a,b>] [--add-recipe <name>]... [--remove-recipe <name>]... [--replace] [--no-sync] [--clone \| --no-clone] [--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. --clone clones every entry of the profile's repos (see Forge layout › Repositories) into the workspace after the first sync — one git clone --no-recurse-submodules [--branch=<ref>] -- <url> <path> per entry, one after another, with the workspace root as working directory, git found in the absolute entries of PATH and GIT_ALLOW_PROTOCOL=https:ssh; git's own output and credential prompt go to the terminal, with no timeout. With --no-sync, --clone still clones. Without --clone nothing is cloned: with both --forge and --profile given nothing is asked and nothing is said about repositories; in a terminal, when a flag is missing, a profile with entries is listed — name, URL, destination, ref — and Clone them into the workspace [no]: is asked (Enter is no), except under --offline or --no-clone. A clone that fails is reported with the command to run it by hand and craftar repos clone <names>, the other entries are still tried, and init exits 1 with the workspace written, synced and registered. A first sync that would be refused clones nothing (repositories: not cloned — the first sync is refused). Refused before anything is written: --clone with --no-clone, or with --offline; --clone with no git on PATH, on a profile with no entry, with an entry that does not read, or with a destination that exists, holds a file sync writes, lies inside the Forge, leaves the workspace through a link or sits under a file. A clone is recorded nowhere — not in craftar.lock, not in the registry; sync never updates or removes it, and craftar does not edit a .gitignore (in a workspace that is itself a repository the clones show as untracked directories). --new-profile <name> creates the profile first: one new file, profiles/<name>/profile.yaml, in the Forge's working tree — its name, recipes and targets only — then starts the workspace on it as for a profile that was there. The Forge must be a directory that is a git repository with a commit (git is how the write is undone); nothing is committed, no branch is made and nothing is pushed — commit the file in the Forge, on a branch, before anyone else syncs. With --new-profile, --add-recipe, --remove-recipe, --replace and --targets shape the profile, and craftar.yaml gets no recipes and no targets. --like <profile> starts the new profile's recipes and targets from that profile's written lists — nothing else of it is copied: no params, sections, integrations, repos, templates or description; in a Forge at schema 3 or above a recipe only its integrations block selects does not come along, and a note line says so. The name is lowercase letters and digits in groups joined by one - (no --, not a Windows device name). Refused before anything is written: --new-profile with --profile, --like without --new-profile, a name of another shape, a Forge URL (clone it and pass the directory), a tree of craftar's Forge cache, a directory that is not a git repository with a commit, a name a profile already carries or a profiles/<name> that exists, a path git ignores or that goes through a symbolic link, a recipe named for another profile (<recipe>--<p>, <p>-steering — add the shared recipe it stands for), and a profile that does not resolve or plan. The Forge is planned in memory, the file written, then the Forge read back from disk must plan the same bytes before craftar.yaml is written; a failure after the profile was written exits 1 and prints the git -C <forge> clean -fd -- profiles/<name> line that removes it. In a terminal the profile list offers 0) a new profile (for a Forge directory that can take one), and a Forge directory with no profile goes straight to the new profile's questions; the again line then names --profile <name>, the run a second workspace of that client uses. |
| craftar repos [--workspace .] [--offline] [--json] | Lists the profile's repositories (its repos, see Forge layout › Repositories) with their state in the workspace — present (the destination exists; craftar does not look inside), missing, or blocked with the reason a clone would be refused — and each one's destination, URL and ref. It loads the Forge as status does (a remote Forge is fetched unless --offline; a failed fetch is a warning over the cached copy), starts no other program, contacts no repository's host and writes nothing. A profile with no entry prints profile <p> lists no repositories. Exits 0 whenever the block reads, missing entries included; 1 for a block that does not read, or a workspace that does not load or plan, as status. --json, a tooling contract: {workspace, profile, repos: [{name, url, path, ref, state, reason}], warnings} (ref and reason null when absent). |
| craftar repos clone [<name>…] [--workspace .] | Clones the missing entries — every one, or the names given (compared without case) — as init --clone clones them; typing the command is the consent, and nothing is asked. A present entry is skipped with a line; a named one is refused. Refused before any clone, exit 1: no git on PATH; a remote Forge that cannot be fetched (it never clones from a cached copy, and has no --offline); a profile with no entry; a block that does not read; any blocked entry; a name that is not an entry, or is given twice. It clones whatever the workspace's sync state, and exits 1 when a clone fails, with the command to run it by hand. It writes the clones only: no craftar.yaml, lock or registry entry. |
| 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). Scripts and hooks sharing the same base name (e.g., run-x.ps1 and run-x.sh) become one ingredient listing all those files; two that differ only in case are rejected (the rest of the import proceeds). A rule's scope comes, in this order, from: a hand-written .kiro/steering/<name>.md whose frontmatter sets inclusion; a usable paths: in the rule's own frontmatter — a non-empty one-line string or a list of them, quoted or not (- **/*.ts is read as Claude Code reads it) — which gives inclusion: fileMatch and fileMatchPattern; a steering mirror craftar generated (its default banner); any other steering file, as before; and always. When paths: is usable the rule's frontmatter is kept verbatim apart from its body (frontmatterRaw), whichever source set the scope, so .claude/rules/<name>.md is written back byte for byte and neither .kiro/steering/<name>.md nor AGENTS.md carries it — except a file whose closing --- is not followed by a newline, which keeps its frontmatter in its body and is written back as is; its steering mirror — and AGENTS.md, where it embeds the rule — then carry the block. What the workspace sees next, for a rule a craftar before this one read as always-on: its .kiro/steering/<name>.md, AGENTS.md and the .kiro/agents/*.json of agents bound to the rule now differ from the plan — where craftar.lock records them unedited, sync updates them; with no lock entry for them (a first import), they read collision and are left alone: delete them and run craftar sync. Four warnings name the rule and its files, never the pattern: the hand-written steering file and paths: disagree (the steering file's scope is kept); the generated mirror disagrees with paths: (scope taken from paths:); the steering file sets no inclusion (scope taken from paths:); paths: is not a string or a list of strings (ignored). With --prefer-kiro, a steering body that starts with a frontmatter block (as an earlier craftar wrote a paths: rule) is taken with it, as before, and the rule keeps no separate frontmatter. 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 inside craftar's Forge cache (a tree there, or a directory it would create there), 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 (not one --prefer-kiro took the rule from); 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 two exceptions: when a declared parameter a file cites has no value (see sync) it prints the listing, then the refusal on stderr, and exits 1; and when AGENTS.md is over an explicit budget (see Targets › agents-md) it prints the listing, then that block on stderr, and exits 1. In both cases --json still prints the whole object — with unsetParams: [{key, declaredBy, citedBy}] ([] when there is none) and the budget line in warnings — and prints no block on stderr. 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, or when AGENTS.md is over an explicit budget (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 left verbatim silently, 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, or when AGENTS.md is over an explicit budget — 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. The AGENTS.md budget block (see Targets › agents-md) is printed the same way, on stderr in every form, and sets the exit only for --exit-code with no [path]. |
| 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. show does not plan a promote, so a file drift promote refuses by a check only its plan makes reads promote: yes too — a recipe chain (D17), or a recipe a profile's block selects that another profile resolves, or that a profile which does not resolve might (D19). 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. A rule's frontmatter travels as read (see import); its scope is re-derived only when the edit changed paths: — a removed paths: takes the scope it gave with it — and an unusable paths: is warned as import warns it. 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; a Forge directory that is a tree of craftar's Forge cache — the workspace's forge: or --forge <dir> — is refused before the --forge match and the git checks. 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), mcp-launcher when the plan writes an MCP server (a warn per server whose command is cmd (or a path to cmd.exe) and whose first argument is /c or /k — it starts on Windows only — naming the server and the files, never its command or arguments; see MCP servers on Windows and Linux), 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 — agents-md-size when the plan writes AGENTS.md (ok within the budget or with the budget off, warn over the default budget, error over an explicit one — the cause is reported there, not by the plan's warnings), under agents-md.primary agents-md-primary (a CLAUDE.md that keeps Claude Code from reading AGENTS.md, at the root or above it; at most warn) and kiro-resources (a hand-written Kiro agent resources entry the plan does not write: error, or warn when the file is only on disk), 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 mcp [--workspace <dir>] [--fetch] [--allow-writes] | An MCP server (Model Context Protocol, revision 2025-06-18) over stdin and stdout, started by an AI coder as a subprocess — not typed in a terminal, where it refuses to start. It serves one workspace, the one it was started in, through seven tools that only read: status, diff (the pending files; with a path, that file's diff), explain, recipes, ingredients, targets and doctor (the checks about this workspace, plus Node and git; like the command, it asks docker compose for its version when the workspace has a service and dotnet for its version when the profile declares a template it can install) — each answers with the data its command prints, as one JSON object. Without --allow-writes nothing is written to the workspace, the lock, craftar.yaml, the registry or the Forge. With --allow-writes it also serves three writing tools — sync (a craftar sync that skips and lists every drift, orphan-drift and collision; a remote Forge needs --fetch), add_recipe and remove_recipe (an add recipe / remove recipe, which do not sync) — and each call writes only when this machine's registry holds the consent craftar mcp allow-writes recorded for this workspace, for the forge, ref and profile the server started with; otherwise the call is an error and nothing is written. A writing result carries what the command prints, never a file's content or the registry's path. 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). Without --fetch no tool touches the network: a remote Forge is read from the cached copy, and the answer says so — for doctor in its forge check, for the others but targets in warnings unless ref is a full commit SHA, which cannot go stale (targets reads only which targets the configuration resolves, never fetches and carries no such line). The forge, ref and profile of craftar.yaml / craftar.local.yaml are fixed when the server starts: after one of them changes, every tool but targets answers with an error until it is put back or the server is restarted (targets answers the matrix unmarked, with a warning). A hand-edited .mcp.json, .kiro/settings/mcp.json or .claude/settings.craftar.example.json is never returned — a note stands in for its diff. A line that is not JSON, or longer than 1 MiB, is not answered — the server reads on. Exits 0 when stdin closes; exits 1, with one line on stderr, in a terminal, without a craftar.yaml, or when the configuration does not read. See Craftar inside the AI coder. |
| craftar mcp allow-writes [--workspace <dir>] | Records, in this machine's registry, the consent that lets craftar mcp --allow-writes write in this workspace. It runs only in a terminal (stdin and stdout), prints the workspace, the Forge, the ref and the profile the consent is for, and asks Let craftar mcp --allow-writes write here [no]: — only y / yes records it. Exits 1, writing nothing, outside a terminal, without a craftar.yaml, with CRAFTAR_NO_REGISTRY set, when the registry does not read or cannot be written, when forge: or ref: holds credentials, or on any other answer. The consent is bound to that forge, ref and profile: after one of them changes, a writing call is refused until the consent is given again. craftar workspaces forget and prune drop the consent of what they remove. |
| craftar mcp revoke-writes [--workspace <dir>] | Removes that consent; needs no terminal; takes effect on the server's next call, without a restart. Exits 0 also when there was none (no consent recorded for <path>); exits 1 without a craftar.yaml, with CRAFTAR_NO_REGISTRY set, or when the registry does not read or cannot be written. |
| 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 ui [--port <n>] [--allow-writes] | A local dashboard, in a browser: the table of craftar workspaces and, for a registered workspace, what status, diff and explain say about it and what recipes and ingredients say about its Forge. It prints one address and serves until interrupted (Ctrl-C exits 0). Read-only unless --allow-writes: without the flag nothing is written to a workspace, a lock, the registry or a Forge — the Forge cache is used as every reader uses it (.used stamps, a rebuilt tree; git runs as for craftar workspaces) — and the page never runs sync — it prints the command to copy. With --allow-writes (typing it is the consent) the page also offers Sync for a workspace and Discard edit for one hand-edited file (drift or orphan-drift, from its open diff), each confirmed in the page: a sync that never passes --overwrite-drift — every hand edit and collision is skipped and listed — or a craftar drift discard of that one path; never for .claude/settings.craftar.example.json, never for a workspace whose Forge is remote, and refused when the workspace changed since the page showed it (refresh and confirm again). Loopback only: it listens on 127.0.0.1 (no --host; localhost is refused), answers GET only (and, with --allow-writes, POST to its two writing routes, with an Origin of its own address), and every answer that carries data needs a token generated for that run — it travels in the address's fragment, which the browser does not send, so open the address exactly as printed (pasted, not clicked from a web page; a reload keeps working, a new tab needs the address again); it stays in the terminal's scrollback (and in a file, if the output is redirected) and may stay in the browser's history, and is worth nothing once the command stops. No network: a remote Forge is read from the cached copy, never fetched, and the page says so. --port (default 4747; 0 takes a free one); a taken port is refused, never replaced. A Forge URL the registry recorded with credentials is shown as (a URL with credentials — not shown), with no cache key — as craftar workspaces --json gives it since 0.28.1 (see Upgrading › to 0.28.1). Takes no --workspace; exits 1 when the registry is off (CRAFTAR_NO_REGISTRY) or cannot be read. The page's routes are not a contract. |
| 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 ] [--offline]| Removes recipes from the workspace's choice: deletes each fromrecipes.add, and appends a profile recipe to recipes.remove. A recipe another one still brings in through extendsis refused, naming the top-level recipe to remove instead. A name the Forge does not hold is accepted only to clean it out ofrecipes.addorrecipes.remove. Same output, refusals and all-or-nothing rule as add recipe. |
| craftar forge variants [--forge | --workspace ] [--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. --jsonprints{groups, orphans}; each variant carries classes: {evolution, value, block}, which add up to distance.hunks. |
| craftar forge impact [--forge | --workspace ] [--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 nextsync would do against the Forge as it is now: a header, then one line per workspace sorted by path (unchanged, the next-sync counts, missingorerror: — a workspace whosesyncwould refuse for a declared parameter with no value iserror: sync refused: declared parameter(s) with no value: ; · after push ()for a remote match, · after push and pull ()for a clone match;, pins when the workspace pins aref). When no registered workspace reads this Forge, the short header (craftar forge impact — ) 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 of holds credentials in its URL — not matched(the URL is never printed). Read-only; no fetch.--jsonis a contract:{forge, registry, workspaces: [{path, profile, match, via, ref, state, counts, error}]}(every key always present). |
|craftar forge diff <type/name> [--against ] [--forge | --workspace ] [--json]| Shows the differences between a base ingredient and each of its variants: a header carrying the same distanceforge variantsreports, 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 suggestedparam.) 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.--jsonprints an array of{ref, profile, distance, diff}; each hunk carries suggestion: {class, reason, tokens?}next to itskind. |
| craftar forge unify <type/name> --profile (--take base|variant | --plan | --save-plan ) [--no-impact] [--prune-recipes] [--forge | --workspace ] [--json]| Resolves one variant back into its base, hunk by hunk.--save-planwrites a reviewable plan with every decision set tokeep, each hunk annotated with its suggested class (which --planignores) and, for avaluehunk, a pre-filledparams list (token→ suggestedkey); on a blockhunk, a pre-filledsection: { name }from the Markdown heading above (orsection-); 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 totake: paramturns each listed token into{{key}}in the base, declares the base's text as the key's default in the base'singredient.yamland writes the variant's text into the variant profile'sprofile.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 underintegrations.(the prefix is reserved there). A hunk set totake: section(withsection: { 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: "-"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: paramandtake: sectionmay share a plan, never a hunk. It refuses (Forge untouched) atake: paramortake: sectionon 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 (includingtake: baseon 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 atschema: 1it setsschema: 2incraftar.forge.yamlin place (refused if the file does not round-trip).--planapplies 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: paramortake: sectionextraction into the variant's own profile. A--left identical tois reported as a warning unless pruned;--prune-recipesdeletes 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'sextendsnames 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'srecipes.addorrecipes.removenames 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 itsintegrationsblock, 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 asunifyleft it.--prune-recipeswith--no-impactis refused before anything is read.unifyplans every registered workspace of the Forge before its writes and again after them, and reports oneimpact:line per workspace —no effect, 1 file changes — / files change — , missingorerror: (each line carries the same · after push ()/ · after push and pull ()/, pins suffix asforge 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 inoverrides.ingredients.disable, a new parameter a workspace's overrides.paramsnow overrides, a new section a workspace'soverrides.sectionsnow 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-impactskips 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 thegit checkout/git cleancommands that undo them. A merge never adds, removes or changes a section marker other than the sections the plan declares; a plan or--take variantthat would is refused before the first write — takebasefor the marker lines, ortake: sectionto fill the section; the citation checks oftake: paramalso read the profiles' section values, which can cite{{key}}. ingredient.yamlis never merged: when the two sides' metadata differs (beyondname, asandorigin), the variant stays unresolved and the differing fields are named — edit it by hand, or --take base to discard the variant, metadata included. Refuses a tree of craftar's Forge cache passed as the Forge directory, and requires a clean git checkout in the Forge with at least one commit (--save-planexcepted from both) — 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-recipesrepoints,craftar.forge.yaml` when it is bumped) is ignored, untracked, modified, or flagged skip-worktree or assume-uncha
