craftar 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -11
- package/dist/cli.js +120 -37
- package/dist/cli.js.map +1 -1
- package/dist/core/doctor.js +3 -17
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/impact.js +5 -1
- package/dist/core/impact.js.map +1 -1
- package/dist/core/import-report.js +91 -0
- package/dist/core/import-report.js.map +1 -0
- package/dist/core/init-flow.js +9 -4
- package/dist/core/init-flow.js.map +1 -1
- package/dist/core/registry.js +15 -3
- package/dist/core/registry.js.map +1 -1
- package/dist/core/resolve.js +24 -3
- package/dist/core/resolve.js.map +1 -1
- package/dist/core/sync.js +59 -3
- package/dist/core/sync.js.map +1 -1
- package/dist/importers/claude-code.js +7 -1
- package/dist/importers/claude-code.js.map +1 -1
- package/dist/importers/kiro-report.js +102 -0
- package/dist/importers/kiro-report.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,24 +68,24 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
68
68
|
|
|
69
69
|
| Command | What it does |
|
|
70
70
|
|---|---|
|
|
71
|
-
| `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. 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. |
|
|
72
|
-
| `craftar import --from claude-code --forge <dir> --profile <name> [--workspace .] [--write-config]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering). Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `<profile>-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml`. A reused base takes the place its `<name>--<profile>` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `<name>--<profile>` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `<recipe>--<profile>`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections <names>` when a section value filled the render), `inferred`, `sectioned`, `param <key>: <old> → <new>`, `section <key> <name>: <old> → <new>`, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, and a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand). Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. `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. |
|
|
73
|
-
| `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. 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). |
|
|
74
|
-
| `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: 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). 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`. |
|
|
75
|
-
| `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. |
|
|
76
|
-
| `craftar explain <path>` | Which ingredient, recipe chain, target and origin produced a file and, for an ingredient with sections, which layer filled each one: `sections flavors (profile globex), extra (default)` (`default`, `profile <p>` or `workspace`). `AGENTS.md` gets
|
|
71
|
+
| `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. |
|
|
72
|
+
| `craftar import --from claude-code --forge <dir> --profile <name> [--workspace .] [--write-config] [--report <file.md>]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering). Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `<profile>-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml`. A reused base takes the place its `<name>--<profile>` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `<name>--<profile>` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `<recipe>--<profile>`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections <names>` when a section value filled the render), `inferred`, `sectioned`, `param <key>: <old> → <new>`, `section <key> <name>: <old> → <new>`, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, and a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand). Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. `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. |
|
|
73
|
+
| `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). |
|
|
74
|
+
| `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: 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). 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. |
|
|
75
|
+
| `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. |
|
|
76
|
+
| `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. |
|
|
77
77
|
| `craftar ls` | Recipes and ingredients resolved for this workspace. |
|
|
78
|
-
| `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), 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}`. |
|
|
79
|
-
| `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) — the lock, `status`, and registration. One line per finding — `ok`, `warn` or `error` — each problem with the command that fixes it; each cause is reported once. 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}`. |
|
|
78
|
+
| `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}`. |
|
|
79
|
+
| `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) — the lock, `status`, and registration. 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}`. |
|
|
80
80
|
| `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}`. |
|
|
81
81
|
| `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. |
|
|
82
82
|
| `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`, `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}`. |
|
|
83
83
|
| `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}`. |
|
|
84
84
|
| `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}`. |
|
|
85
|
-
| `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`. Refused when `craftar.local.yaml` sets `recipes` (its lists would replace the edited ones). |
|
|
85
|
+
| `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.yaml` sets `recipes` (its lists would replace the edited ones). |
|
|
86
86
|
| `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`. |
|
|
87
87
|
| `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`. |
|
|
88
|
-
| `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>`; ` · 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). |
|
|
88
|
+
| `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). |
|
|
89
89
|
| `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`. |
|
|
90
90
|
| `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. 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), 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`. |
|
|
91
91
|
|
|
@@ -211,7 +211,7 @@ overrides:
|
|
|
211
211
|
ingredients: {disable: []}
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
-
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`; a placeholder 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). 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.
|
|
214
|
+
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`; a placeholder 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.
|
|
215
215
|
|
|
216
216
|
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.
|
|
217
217
|
|
|
@@ -310,6 +310,20 @@ Later: `service` ingredients with compose fragments (`craftar services up`); `do
|
|
|
310
310
|
|
|
311
311
|
## Upgrading
|
|
312
312
|
|
|
313
|
+
### to 0.21.0
|
|
314
|
+
|
|
315
|
+
- **`craftar import --report <file.md>` is new** and additive: without the flag, `import` reads, writes and prints what it did before. Nothing to do.
|
|
316
|
+
- **No byte change**: no emitter, no import decision and no lock changed. No workspace sees `update`.
|
|
317
|
+
|
|
318
|
+
### to 0.20.0
|
|
319
|
+
|
|
320
|
+
- **A declared parameter with no value now stops `sync`.** If an ingredient declares a key under `params` with no `default`, a file cites `{{key}}`, and no layer sets it, `craftar sync`, `sync --check`, `status` and `diff --exit-code` exit 1 and `sync` writes nothing. Before, the file was written with `{{key}}` in it and a warning. Run `craftar doctor` to list the keys; set each in the profile's `params` or in `overrides.params`. A `{{key}}` no ingredient declares is left verbatim with its warning, as before.
|
|
321
|
+
- **`craftar doctor` reports such a key as `error`** (it was `warn`), so its exit code is 1 without `--strict`. A declared key no file cites stays a `warn`.
|
|
322
|
+
- **`craftar workspaces` and `craftar forge impact` show such a workspace as `error`, with the reason.** Neither `--json` gains a key or a value.
|
|
323
|
+
- **`craftar init`** on such a profile writes `craftar.yaml`, runs no sync and exits 1; `init --no-sync`, `add recipe` and `remove recipe` exit 0 and say `next sync: refused — …`.
|
|
324
|
+
- **`status --json` gains `unsetParams`** (always present, after `warnings`); `craftar explain` gains a `params` line.
|
|
325
|
+
- **No byte change**: no emitter and no lock changed. No workspace sees `update`.
|
|
326
|
+
|
|
313
327
|
### to 0.19.0
|
|
314
328
|
|
|
315
329
|
- **A Forge that declares `authEnv` needs this version** — an older craftar refuses the unknown key, naming the file.
|
package/dist/cli.js
CHANGED
|
@@ -10,12 +10,13 @@ import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistry
|
|
|
10
10
|
import { runDoctor } from "./core/doctor.js";
|
|
11
11
|
import { pruneCache } from "./core/cache.js";
|
|
12
12
|
import { resolveHome } from "./core/home-lock.js";
|
|
13
|
-
import { loadWorkspace, plan, readLock, status, apply, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
|
|
13
|
+
import { loadWorkspace, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
|
|
14
14
|
import { resolve, sectionKey } from "./core/resolve.js";
|
|
15
15
|
import { catalogueContext, listRecipes, listIngredients, checkType } from "./core/catalogue.js";
|
|
16
16
|
import { listTargets } from "./core/capabilities.js";
|
|
17
17
|
import { canonicalValue } from "./core/sections.js";
|
|
18
18
|
import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
|
|
19
|
+
import { renderImportReport } from "./core/import-report.js";
|
|
19
20
|
import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
|
|
20
21
|
import { hashNormalized, toLf, stripBom } from "./core/text.js";
|
|
21
22
|
import { exists, gitDirty, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
|
|
@@ -38,8 +39,9 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
|
38
39
|
*/
|
|
39
40
|
const DIFF_EXIT_CODE_FETCH_MODE = "sync";
|
|
40
41
|
const program = new Command();
|
|
41
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
42
|
-
|
|
42
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.21.0");
|
|
43
|
+
const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
|
|
44
|
+
const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
|
|
43
45
|
program
|
|
44
46
|
.command("import")
|
|
45
47
|
.description("Import an existing workspace harness into a Forge: creates or updates ingredients, recipes and a profile, reusing a templated base when it renders the workspace text or infers it into params or sections")
|
|
@@ -48,13 +50,16 @@ program
|
|
|
48
50
|
.requiredOption("--profile <name>", "client profile to create or update")
|
|
49
51
|
.option("--workspace <dir>", "workspace to import", ".")
|
|
50
52
|
.option("--write-config", "write craftar.yaml into the workspace, merging an existing one (forge, profile, targets)", false)
|
|
53
|
+
.option("--report <file.md>", "also write a Markdown report of the import — what was created, reused, made a variant or rejected, and the .kiro/ files that differ from what Craftar would generate (paths and line counts, never content); refused when the path is inside the Forge or already exists")
|
|
51
54
|
.action(async (o) => {
|
|
52
55
|
if (o.from !== "claude-code")
|
|
53
56
|
fail(`unsupported source "${o.from}" (only claude-code for now)`);
|
|
54
57
|
// import writes a local Forge; a URL would become a directory named after it (spec 13 §4.4).
|
|
55
58
|
if (classifyForge(o.forge) === "url")
|
|
56
59
|
fail("--forge takes a directory; to read a remote Forge, run inside a workspace that names it");
|
|
57
|
-
|
|
60
|
+
// Spec 29 §4.2: the report's path is refused before the import reads anything, so a refusal leaves the Forge untouched.
|
|
61
|
+
const reportAbs = o.report === undefined ? null : await gateOutsideForge(o.report, o.forge, REPORT_GATE);
|
|
62
|
+
const r = await importClaudeCode({ workspaceRoot: o.workspace, forgeRoot: o.forge, profileName: o.profile, writeWorkspaceConfig: o.writeConfig, report: reportAbs !== null });
|
|
58
63
|
console.log(pc.bold(`Imported ${path.resolve(o.workspace)} → ${path.resolve(o.forge)} as profile "${r.profile}"`));
|
|
59
64
|
console.log(` ${pc.green(String(r.created.length))} created, ${pc.cyan(String(r.reused.length))} reused, ${pc.yellow(String(r.variants.length))} variants, ${pc.red(String(r.rejected.length))} rejected`);
|
|
60
65
|
for (const v of r.variants)
|
|
@@ -90,11 +95,24 @@ program
|
|
|
90
95
|
: ` workspace craftar.yaml edited (forge, profile, targets)`);
|
|
91
96
|
for (const w of r.warnings)
|
|
92
97
|
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
98
|
+
if (reportAbs !== null) {
|
|
99
|
+
const text = renderImportReport({ workspace: path.resolve(o.workspace), forge: path.resolve(o.forge), report: r, now: new Date(), version: program.version() });
|
|
100
|
+
// After the summary, so a report that cannot be written never costs the summary of an import that succeeded.
|
|
101
|
+
try {
|
|
102
|
+
await fs.mkdir(path.dirname(reportAbs), { recursive: true });
|
|
103
|
+
await fs.writeFile(reportAbs, text, { flag: "wx" });
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
fail(`The Forge was written in full and the import succeeded; writing the report failed (${e instanceof Error ? e.message : String(e)}) — ` +
|
|
107
|
+
`the summary above stands; re-run with another --report path to get the file`);
|
|
108
|
+
}
|
|
109
|
+
console.log(` report ${o.report}`);
|
|
110
|
+
}
|
|
93
111
|
});
|
|
94
112
|
/* ---------------------------------------------------------------- status */
|
|
95
113
|
program
|
|
96
114
|
.command("status")
|
|
97
|
-
.description("Show what sync would do: new, update, drift, orphan, collision")
|
|
115
|
+
.description("Show what sync would do: new, update, drift, orphan, collision; exits 1 when a declared parameter a file cites has no value")
|
|
98
116
|
.option("--workspace <dir>", "workspace root", ".")
|
|
99
117
|
.option("--json", "machine-readable output", false)
|
|
100
118
|
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
|
|
@@ -103,10 +121,27 @@ program
|
|
|
103
121
|
const p = await plan(ws);
|
|
104
122
|
const lock = await readLock(ws.root);
|
|
105
123
|
const st = await status(ws, p, lock);
|
|
106
|
-
|
|
107
|
-
|
|
124
|
+
const unset = unsetDeclared(p);
|
|
125
|
+
if (o.json) {
|
|
126
|
+
console.log(JSON.stringify({ forge: forgeJson(ws, lock), statuses: st.map(({ planned, ...s }) => s), warnings: p.warnings, unsetParams: unset }, null, 2));
|
|
127
|
+
// exitCode, not process.exit: a piped object is not cut (spec 29 §4.1)
|
|
128
|
+
if (unset.length)
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
108
132
|
printStatus(st, p.warnings, ws.config.profile, p.resolution.recipes, false, forgeLine(ws, lock));
|
|
133
|
+
if (refusalBlock(unset))
|
|
134
|
+
process.exitCode = 1;
|
|
109
135
|
});
|
|
136
|
+
/**
|
|
137
|
+
* The refusal of spec 29 §4.1 for a command that goes on to print or exit by itself: the block on stderr, as
|
|
138
|
+
* `fail()` would print it, without exiting. Says whether there was one.
|
|
139
|
+
*/
|
|
140
|
+
function refusalBlock(unset, opts = {}) {
|
|
141
|
+
if (unset.length)
|
|
142
|
+
console.error(pc.red("error: ") + unsetRefusal(unset, opts));
|
|
143
|
+
return unset.length > 0;
|
|
144
|
+
}
|
|
110
145
|
/**
|
|
111
146
|
* A writing sync on a planned workspace, then its report: `apply`, the registration of spec 21 (unless
|
|
112
147
|
* `CRAFTAR_NO_REGISTRY`), and the lines `sync` prints. `sync` and `init` both call it (spec 23 §5.2).
|
|
@@ -261,11 +296,22 @@ program
|
|
|
261
296
|
console.log(` ${initLine(init)}`);
|
|
262
297
|
for (const n of init.notes)
|
|
263
298
|
console.log(` note ${n}`);
|
|
299
|
+
const unset = unsetDeclared(p);
|
|
264
300
|
if (!o.sync) {
|
|
265
|
-
console.log(nextSyncLine(init.statuses));
|
|
301
|
+
console.log(nextSyncLine(init.statuses, unset));
|
|
302
|
+
for (const w of p.warnings)
|
|
303
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
304
|
+
again();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Spec 29 §4.1, §6 case 11: craftar.yaml is written — the workspace layer is a place for the value — and the
|
|
308
|
+
// first sync is not run. exitCode, not fail(): the `again` line still follows the block.
|
|
309
|
+
if (unset.length) {
|
|
266
310
|
for (const w of p.warnings)
|
|
267
311
|
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
312
|
+
refusalBlock(unset, { thenSync: true });
|
|
268
313
|
again();
|
|
314
|
+
process.exitCode = 1;
|
|
269
315
|
return;
|
|
270
316
|
}
|
|
271
317
|
const r = await applyAndReport(ws, p, init.statuses, init.lock);
|
|
@@ -277,7 +323,7 @@ program
|
|
|
277
323
|
/* ---------------------------------------------------------------- sync */
|
|
278
324
|
program
|
|
279
325
|
.command("sync")
|
|
280
|
-
.description("Generate the harness for every target from the Forge and update craftar.lock; a writing sync also records the workspace in $CRAFTAR_HOME/registry.json (see craftar workspaces)")
|
|
326
|
+
.description("Generate the harness for every target from the Forge and update craftar.lock; a writing sync also records the workspace in $CRAFTAR_HOME/registry.json (see craftar workspaces); refuses, writing nothing, when a declared parameter a file cites has no value")
|
|
281
327
|
.option("--workspace <dir>", "workspace root", ".")
|
|
282
328
|
.option("--check", "exit 1 when the workspace is out of date or has drift (CI mode)", false)
|
|
283
329
|
.option("--dry-run", "show the plan, write nothing", false)
|
|
@@ -291,19 +337,32 @@ program
|
|
|
291
337
|
if (o.check) {
|
|
292
338
|
const bad = st.filter((s) => !["unchanged", "adopt"].includes(s.state));
|
|
293
339
|
printStatus(st, p.warnings, ws.config.profile, p.resolution.recipes, true, forgeLine(ws, lock));
|
|
294
|
-
|
|
340
|
+
const unset = unsetDeclared(p);
|
|
341
|
+
if (bad.length)
|
|
295
342
|
console.log(pc.red(`\n${bad.length} file(s) out of sync`));
|
|
343
|
+
// A refused sync is not "in sync", whatever the files say: the listing, then the block, and the exit is
|
|
344
|
+
// exitCode's, so nothing queued is cut (spec 29 §4.1)
|
|
345
|
+
if (refusalBlock(unset))
|
|
346
|
+
process.exitCode = 1;
|
|
347
|
+
else if (bad.length)
|
|
296
348
|
process.exit(1);
|
|
297
|
-
|
|
298
|
-
|
|
349
|
+
else
|
|
350
|
+
console.log(pc.green("\nworkspace in sync"));
|
|
299
351
|
return;
|
|
300
352
|
}
|
|
353
|
+
// Asked here, not left to apply()'s gate: the plan's warnings come before the block (spec 29 §4.1)
|
|
354
|
+
const unset = unsetDeclared(p);
|
|
355
|
+
if (unset.length) {
|
|
356
|
+
for (const w of p.warnings)
|
|
357
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
358
|
+
fail(unsetRefusal(unset));
|
|
359
|
+
}
|
|
301
360
|
await applyAndReport(ws, p, st, lock, { dryRun: o.dryRun, overwriteDrift: o.overwriteDrift });
|
|
302
361
|
});
|
|
303
362
|
/* ---------------------------------------------------------------- diff */
|
|
304
363
|
program
|
|
305
364
|
.command("diff")
|
|
306
|
-
.description("Unified diff between the files on disk and what the Forge would generate, orphans included (files the Forge no longer produces)")
|
|
365
|
+
.description("Unified diff between the files on disk and what the Forge would generate, orphans included (files the Forge no longer produces); also says when a declared parameter a file cites has no value, which sync refuses")
|
|
307
366
|
.option("--workspace <dir>", "workspace root", ".")
|
|
308
367
|
.option("--exit-code", "exit 1 when there are differences (exactly when `sync --check` would fail); a [path] that names no file craftar manages becomes an error; with a remote Forge, a failed fetch exits 1 as `sync --check` does (--offline to use the cached copy)", false)
|
|
309
368
|
.argument("[path]", "limit to one file")
|
|
@@ -356,14 +415,20 @@ program
|
|
|
356
415
|
// exitCode, not process.exit: exit drops a piped diff still queued for a slow reader (spec 19 §3.1)
|
|
357
416
|
else if (o.exitCode)
|
|
358
417
|
process.exitCode = 1;
|
|
418
|
+
// Spec 29 §4.1: with no [path], --exit-code fails exactly when `sync --check` does — a refused sync included
|
|
419
|
+
if (refusalBlock(unsetDeclared(p)) && o.exitCode && !only)
|
|
420
|
+
process.exitCode = 1;
|
|
359
421
|
});
|
|
360
422
|
/* ---------------------------------------------------------------- add / remove recipe */
|
|
361
423
|
/**
|
|
362
424
|
* Spec 22's line: what the next sync would do (also `init --no-sync`, spec 23 §4.4).
|
|
363
425
|
* Uses `countStates` from `src/core/impact.ts`; a new `FileState` does not compile until it is
|
|
364
|
-
* placed in `FILE_STATE_ORDER_MAP` there.
|
|
426
|
+
* placed in `FILE_STATE_ORDER_MAP` there. A plan with unset declared parameters has no next sync
|
|
427
|
+
* to count: the line says it is refused (spec 29 §4.1).
|
|
365
428
|
*/
|
|
366
|
-
function nextSyncLine(st) {
|
|
429
|
+
function nextSyncLine(st, unset) {
|
|
430
|
+
if (unset.length)
|
|
431
|
+
return `next sync: ${unsetRefused(unset)}`;
|
|
367
432
|
const counts = countStates(st).map(([k, n]) => `${n} ${k}`);
|
|
368
433
|
return `next sync: ${counts.length ? `${counts.join(", ")} — run \`craftar sync\`` : "nothing to sync"}`;
|
|
369
434
|
}
|
|
@@ -385,7 +450,7 @@ function recipeCommand(op) {
|
|
|
385
450
|
const next = { ...ws, config: { ...ws.config, recipes: edit.recipes } };
|
|
386
451
|
const p = await plan(next);
|
|
387
452
|
const st = await status(next, p, await readLock(ws.root));
|
|
388
|
-
const nextLine = nextSyncLine(st);
|
|
453
|
+
const nextLine = nextSyncLine(st, unsetDeclared(p));
|
|
389
454
|
await fs.writeFile(file, content);
|
|
390
455
|
console.log(`${WORKSPACE_FILE}: ${recipeDiffLine(ws.config.recipes, edit.recipes)}`);
|
|
391
456
|
console.log(`recipes: ${p.resolution.recipes.join(" → ")}`);
|
|
@@ -416,7 +481,7 @@ program
|
|
|
416
481
|
/* ---------------------------------------------------------------- explain */
|
|
417
482
|
program
|
|
418
483
|
.command("explain")
|
|
419
|
-
.description("Why does this file exist? Which ingredient, recipe chain and target produced it, and which layer filled each section")
|
|
484
|
+
.description("Why does this file exist? Which ingredient, recipe chain and target produced it, and which layer filled each section and each parameter it cites")
|
|
420
485
|
.argument("<path>", "workspace-relative path of a generated file")
|
|
421
486
|
.option("--workspace <dir>", "workspace root", ".")
|
|
422
487
|
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
|
|
@@ -439,6 +504,12 @@ program
|
|
|
439
504
|
const layer = (l) => (l === "profile" ? `profile ${p.resolution.profile.name}` : l);
|
|
440
505
|
console.log(` sections ${sections.map((x) => `${x.name} (${layer(x.layer)})`).join(", ")}`);
|
|
441
506
|
}
|
|
507
|
+
// Spec 29 §4.1: which layer filled each parameter the file's ingredient cites — or `unset`.
|
|
508
|
+
const params = p.params.get(f.ingredient);
|
|
509
|
+
if (params?.length) {
|
|
510
|
+
const layer = (l) => (typeof l === "string" ? l : "recipe" in l ? `recipe ${l.recipe}` : `profile ${l.profile}`);
|
|
511
|
+
console.log(` params ${params.map((x) => `${x.key} (${layer(x.layer)})`).join(", ")}`);
|
|
512
|
+
}
|
|
442
513
|
console.log(` profile ${ws.config.profile} (recipes: ${p.resolution.recipes.join(", ")})`);
|
|
443
514
|
console.log(` hash ${hashNormalized(f.content)}`);
|
|
444
515
|
});
|
|
@@ -1176,22 +1247,7 @@ forge
|
|
|
1176
1247
|
// Forge — so that is enforced, not assumed, and a plan never overwrites anything. Both
|
|
1177
1248
|
// refusals come before any write; the comparison runs on real paths, so neither a `..`
|
|
1178
1249
|
// segment nor a symlink can carry the target back into the Forge.
|
|
1179
|
-
const savePlanAbs =
|
|
1180
|
-
let targetReal;
|
|
1181
|
-
try {
|
|
1182
|
-
targetReal = await realpathOfNearest(savePlanAbs);
|
|
1183
|
-
}
|
|
1184
|
-
catch (e) {
|
|
1185
|
-
fail(`refusing to write the plan to ${o.savePlan}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1186
|
-
}
|
|
1187
|
-
const rootReal = await fs.realpath(path.resolve(f.root));
|
|
1188
|
-
const rel = path.relative(rootReal, targetReal);
|
|
1189
|
-
if (rel === "" || !(rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))) {
|
|
1190
|
-
fail(`refusing to write the plan to ${o.savePlan}: it resolves inside the Forge (${f.root}) — save plans outside the Forge`);
|
|
1191
|
-
}
|
|
1192
|
-
if (await pathTaken(savePlanAbs)) {
|
|
1193
|
-
fail(`refusing to write the plan to ${o.savePlan}: the file already exists — unify never overwrites; choose a new path`);
|
|
1194
|
-
}
|
|
1250
|
+
const savePlanAbs = await gateOutsideForge(o.savePlan, f.root, SAVE_PLAN_GATE);
|
|
1195
1251
|
const saved = await planFrom(base, variant, diff, o.profile);
|
|
1196
1252
|
await fs.mkdir(path.dirname(savePlanAbs), { recursive: true });
|
|
1197
1253
|
await fs.writeFile(savePlanAbs, YAML.stringify(saved), { flag: "wx" });
|
|
@@ -1886,7 +1942,7 @@ async function readText(p) {
|
|
|
1886
1942
|
* other outcome throws instead. A component `lstat` cannot inspect (a symlink loop or a permission
|
|
1887
1943
|
* error on Linux) "cannot be inspected"; one it can inspect but `realpath` cannot follow (a
|
|
1888
1944
|
* dangling symlink or junction, a loop on Windows) "exists but cannot be resolved". Resolving the
|
|
1889
|
-
* rest lexically would let a link that points into the Forge pass the containment check, so
|
|
1945
|
+
* rest lexically would let a link that points into the Forge pass the containment check, so the gate
|
|
1890
1946
|
* fails closed rather than guess where the target lands.
|
|
1891
1947
|
*/
|
|
1892
1948
|
async function realpathOfNearest(abs) {
|
|
@@ -1917,13 +1973,40 @@ async function realpathOfNearest(abs) {
|
|
|
1917
1973
|
}
|
|
1918
1974
|
}
|
|
1919
1975
|
/**
|
|
1920
|
-
* The refusal for a
|
|
1921
|
-
* when `lstat` itself failed, so the path is not known to exist, and "exists but cannot be
|
|
1976
|
+
* The refusal for a target that cannot be resolved; the caller adds which command cannot prove it. `what` is
|
|
1977
|
+
* "cannot be inspected" when `lstat` itself failed, so the path is not known to exist, and "exists but cannot be
|
|
1922
1978
|
* resolved" when `lstat` succeeded and `realpath` did not.
|
|
1923
1979
|
*/
|
|
1924
1980
|
function cannotResolve(p, e, what) {
|
|
1925
1981
|
const code = e?.code ?? (e instanceof Error ? e.message : String(e));
|
|
1926
|
-
return new Error(`${p} ${what} (${code})
|
|
1982
|
+
return new Error(`${p} ${what} (${code})`);
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* The gate of a user-supplied output path (Ruling 30 of spec 06; spec 29 §4.2): the file a command writes where the
|
|
1986
|
+
* user says must lie outside the Forge and must not exist. Both sides are compared as real paths, so neither a `..`
|
|
1987
|
+
* segment nor a symlink can carry the target back into the Forge; the Forge side goes through its nearest existing
|
|
1988
|
+
* ancestor too, since `import --forge` may name a directory that does not exist yet. Returns the absolute target, or
|
|
1989
|
+
* fails before anything is written.
|
|
1990
|
+
*/
|
|
1991
|
+
async function gateOutsideForge(target, forgeRoot, w) {
|
|
1992
|
+
const refusing = `refusing to write the ${w.what} to ${target}`;
|
|
1993
|
+
const abs = path.resolve(target);
|
|
1994
|
+
let targetReal;
|
|
1995
|
+
let rootReal;
|
|
1996
|
+
try {
|
|
1997
|
+
targetReal = await realpathOfNearest(abs);
|
|
1998
|
+
rootReal = await realpathOfNearest(path.resolve(forgeRoot));
|
|
1999
|
+
}
|
|
2000
|
+
catch (e) {
|
|
2001
|
+
fail(`${refusing}: ${e instanceof Error ? e.message : String(e)} — ${w.who} cannot prove the target lies outside the Forge`);
|
|
2002
|
+
}
|
|
2003
|
+
const rel = path.relative(rootReal, targetReal);
|
|
2004
|
+
if (rel === "" || !(rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))) {
|
|
2005
|
+
fail(`${refusing}: it resolves inside the Forge (${forgeRoot}) — ${w.outside}`);
|
|
2006
|
+
}
|
|
2007
|
+
if (await pathTaken(abs))
|
|
2008
|
+
fail(`${refusing}: the file already exists — ${w.never}; choose a new path`);
|
|
2009
|
+
return abs;
|
|
1927
2010
|
}
|
|
1928
2011
|
/** Whether anything — a file, a directory, even a dangling symlink — already sits at `p`. */
|
|
1929
2012
|
async function pathTaken(p) {
|