craftar 0.12.0 → 0.14.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  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.
4
4
 
5
- > Status: **phase 0 prototype**. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) also work, covered by the unit and golden suites rather than the oracle, and `import` is 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's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). Everything else in the spec (profiles with IDP/PM-tool integrations, local services, `dotnet new` project templates, `craftar ui`, `craftar mcp`) is not built yet.
5
+ > Status: **phase 0 prototype**. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) also work, covered by the unit and golden suites rather than the oracle, and `import` is 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's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, 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 recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). Everything else in the spec (profiles with IDP/PM-tool integrations, local services, `dotnet new` project templates, `craftar ui`, `craftar mcp`) is not built yet.
6
6
 
7
7
  ## The idea in one paragraph
8
8
 
@@ -27,6 +27,17 @@ node bin/craftar.js --help
27
27
  npx tsx src/cli.ts --help
28
28
  ```
29
29
 
30
+ ## Quick start: start an empty workspace from a Forge
31
+
32
+ ```bash
33
+ # Writes craftar.yaml (forge, profile) once it resolves and plans, then runs the first sync.
34
+ craftar init --workspace C:/Projects/acme-portal-workspace \
35
+ --forge C:/Projects/forge \
36
+ --profile acme-portal
37
+ ```
38
+
39
+ The 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.
40
+
30
41
  ## Quick start: bring an existing workspace into a Forge
31
42
 
32
43
  ```bash
@@ -52,6 +63,7 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
52
63
 
53
64
  | Command | What it does |
54
65
  |---|---|
66
+ | `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. 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; 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. |
55
67
  | `craftar import --from claude-code --forge <dir> --profile <name> [--workspace .] [--write-config]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering). Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `<profile>-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml`. A reused base takes the place its `<name>--<profile>` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `<name>--<profile>` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `<recipe>--<profile>`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections <names>` when a section value filled the render), `inferred`, `sectioned`, `param <key>: <old> → <new>`, `section <key> <name>: <old> → <new>`, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, and a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand). Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. |
56
68
  | `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). |
57
69
  | `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`. |
@@ -63,11 +75,13 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
63
75
  | `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}`. |
64
76
  | `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}`. |
65
77
  | `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}`. |
78
+ | `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). |
79
+ | `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`. |
66
80
  | `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`. |
67
81
  | `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`. |
68
82
  | `craftar forge unify <type/name> --profile <p> (--take base\|variant \| --plan <file> \| --save-plan <file>) [--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 deletes a recipe and never edits an `extends`; it edits a profile only to write the values of a `take: param` or `take: section` extraction into the variant's own profile, and otherwise never: a `<recipe>--<profile>` left identical to `<recipe>` is reported as a warning, to be removed by hand after repointing the lists that name it — unify does not, because a workspace or an `extends` chain may also name `<recipe>` and the recipe order or param precedence would change. Unify cannot reach workspaces, so a removed variant is reported as a warning for any `craftar.yaml` that disables it in `overrides.ingredients.disable`. 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, `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}, metaDiffers, params, profileEdited, sections, manifestEdited, warnings}` for `--take`/`--plan` (every key always present, `[]` when empty), or `{base, profile, plan, unresolved}` for `--save-plan`. |
69
83
 
70
- All commands but `workspaces` take `--workspace <dir>` (default: current directory); the `forge` commands, `recipes` and `ingredients` also take `--forge <dir>` as an alternative to it — a directory: a URL there is refused — and `targets` needs no Forge. With a remote Forge (see *Remote Forge*), `status`, `sync`, `diff`, `explain`, `ls`, `recipes`, `ingredients`, `forge variants` and `forge diff` fetch first and take `--offline` to use the cached copy; `targets` never fetches; `forge unify` refuses a workspace whose Forge is remote — clone it and pass `--forge <dir>`; `import --write-config` keeps a remote `forge:` and sets only `profile` and `targets`.
84
+ All commands but `workspaces` take `--workspace <dir>` (default: current directory); the `forge` commands, `recipes` and `ingredients` also take `--forge <dir>` as an alternative to it — a directory: a URL there is refused — and `targets` needs no Forge. With a remote Forge (see *Remote Forge*), `status`, `sync`, `diff`, `explain`, `ls`, `recipes`, `ingredients`, `add recipe`, `remove recipe`, `forge variants` and `forge diff` fetch first and take `--offline` to use the cached copy; `targets` never fetches; `forge unify` refuses a workspace whose Forge is remote — clone it and pass `--forge <dir>`; `import --write-config` keeps a remote `forge:` and sets only `profile` and `targets`.
71
85
 
72
86
  ## Forge layout
73
87
 
@@ -173,15 +187,15 @@ params: { angularVersion: { default: "20" } }
173
187
  Workspace `craftar.yaml`:
174
188
 
175
189
  ```yaml
176
- forge: ../forge # a path, or a git URL (see Remote Forge)
177
- ref: v1.4.0 # optional, with a git URL: a branch, a tag or a full SHA
190
+ forge: ../forge # a path, or a git URL (see Remote Forge)
191
+ ref: v1.4.0 # optional, with a git URL: a branch, a tag or a full SHA
178
192
  profile: acme-portal
179
- targets: [claude-code, kiro] # optional, overrides the profile
180
- recipes: { add: [], remove: [] }
193
+ targets: [claude-code, kiro] # optional, overrides the profile
194
+ recipes: {add: [], remove: []} # edited by craftar add recipe / remove recipe
181
195
  overrides:
182
- params: { }
183
- sections: { } # same shape as the profile's sections
184
- ingredients: { disable: [] }
196
+ params: {}
197
+ sections: {} # same shape as the profile's sections
198
+ ingredients: {disable: []}
185
199
  ```
186
200
 
187
201
  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.
@@ -258,7 +272,7 @@ Copy a workspace with `.claude/` and `.kiro/` into `fixtures/` (keep `local-stac
258
272
  `forge:` may be a git URL — `https://…`, `ssh://…`, `file://…`, or the SCP-like `git@host:org/forge.git` (any SSH user) — with an optional `ref`: a branch (followed: its tip at each fetch), a tag or a full 40-hex SHA (fixed); `refs/heads/<x>` / `refs/tags/<x>` when a name is both; no `ref` follows the remote's default branch. Abbreviated SHAs are refused. A `ref` next to a path `forge:` is ignored, with a warning.
259
273
 
260
274
  - **The cache.** Each machine keeps one copy per URL in `$CRAFTAR_HOME/forges/` (`~/.craftar` by default): a bare repository and one tree per resolved commit, never written by Craftar, shared by every workspace on the machine. Each tree holds the commit's bytes on every platform: the host's `core.autocrlf`, `core.eol` and global attributes file do not apply to it; the Forge's own `.gitattributes` does. Trees unused for 14 days are removed after a fetch. The cache and the workspace registry (below) are the two places, both under `$CRAFTAR_HOME`, where Craftar writes outside a workspace and a Forge on its own.
261
- - **The network.** `sync` (with `--check` and `--dry-run`) and `diff --exit-code` fetch first and exit 1 when they cannot; `status`, `diff`, `explain`, `ls`, `recipes`, `ingredients`, `forge variants` and `forge diff` fall back to the cached copy with a warning; `--offline` uses the cached copy on purpose; with no cached copy every one of them exits 1. `targets` never fetches: it reads the cached copy, and without one prints the matrix unmarked, with a warning. A full SHA already cached is not fetched again.
275
+ - **The network.** `sync` (with `--check` and `--dry-run`) and `diff --exit-code` fetch first and exit 1 when they cannot; `status`, `diff`, `explain`, `ls`, `recipes`, `ingredients`, `add recipe`, `remove recipe`, `forge variants` and `forge diff` fall back to the cached copy with a warning; `--offline` uses the cached copy on purpose; with no cached copy every one of them exits 1. `targets` never fetches: it reads the cached copy, and without one prints the matrix unmarked, with a warning. A full SHA already cached is not fetched again.
262
276
  - **Authentication belongs to git** (credential helper, SSH agent). A URL holding credentials (`https://user:token@…`, a password in any scheme) is refused in `craftar.yaml` and `craftar.local.yaml`, naming the file and never the value. In CI, configure git (e.g. a credential helper or an SSH deploy key), never a token in `forge:`; without a terminal, git does not prompt.
263
277
  - **Writing into the Forge** stays local: `import` and `forge unify` need a clone passed as `--forge <dir>`; Craftar never pushes. After importing into the clone, push it for `sync` to see the import.
264
278
  - **`craftar.local.yaml`** may override `forge:` (a local clone while you work on the Forge); every planning command then warns not to commit `craftar.lock` or the generated files.
@@ -273,11 +287,23 @@ A writing `sync` records the workspace in `$CRAFTAR_HOME/registry.json` (`~/.cra
273
287
 
274
288
  ## Roadmap (from the functional spec)
275
289
 
276
- Phase 0 (this): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock); the workspace registry and `craftar workspaces`.
277
- Next: `craftar init` from a profile; profile-driven integrations (PM tool → MCP, IDP → MCP); `service` ingredients with compose fragments (`craftar services up`); `dotnet new` template registration; rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; `craftar ui`; `craftar mcp` + Claude Code / Agent Plugin packaging.
290
+ Phase 0 (this): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock); the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags.
291
+ Next: an interactive `craftar init` (choose the Forge and the profile); cloning a profile's repositories at init; profile-driven integrations (PM tool → MCP, IDP → MCP); `service` ingredients with compose fragments (`craftar services up`); `dotnet new` template registration; rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; `craftar ui`; `craftar mcp` + Claude Code / Agent Plugin packaging.
278
292
 
279
293
  ## Upgrading
280
294
 
295
+ ### to 0.14.0
296
+
297
+ - **New command `craftar init`**: writes `craftar.yaml` in a workspace that has none, then runs the first `sync`. It never edits an existing `craftar.yaml`.
298
+ - **A missing `craftar.yaml`** is now reported with `craftar init --workspace "<dir>" --forge <dir> --profile <name>` first and `craftar import … --write-config` second, by every command that reads the workspace. Only the message changes; the exit code does not.
299
+ - **No byte change**: no emitter, `plan`, `status`, `sync` or lock changed. No workspace sees `update`.
300
+
301
+ ### to 0.13.0
302
+
303
+ - **New commands `craftar add recipe` and `craftar remove recipe`**: they edit `recipes.add` / `recipes.remove` in `craftar.yaml` and nothing else; they never sync.
304
+ - **A `recipes.add` name the Forge does not hold** now fails every resolving command with `recipe "<x>" not found (referenced by craftar.yaml recipes.add)` instead of `… (referenced by profile <name>)`, and `recipes` / `ingredients` show that text in their warnings (`--json` included). Only the message changes; the exit code does not.
305
+ - **No byte change**: no emitter, `plan`, `status`, `sync` or lock changed. No workspace sees `update`.
306
+
281
307
  ### to 0.12.0
282
308
 
283
309
  - **A writing `sync` now writes outside the workspace**: `$CRAFTAR_HOME/registry.json` (`~/.craftar` by default) gains or updates the workspace's entry. On CI runners and throwaway checkouts set `CRAFTAR_NO_REGISTRY=1`. A registry that cannot be written is a warning; the exit code of `sync` does not change.
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { importClaudeCode } from "./importers/claude-code.js";
7
7
  import { classifyForge } from "./core/remote.js";
8
8
  import { forget, listWorkspaces, prune, register, registryFile } from "./core/registry.js";
9
9
  import { resolveHome } from "./core/home-lock.js";
10
- import { loadWorkspace, plan, readLock, status, apply, resolveForge, resolveForgeSource, WORKSPACE_FILE } from "./core/sync.js";
10
+ import { loadWorkspace, plan, readLock, status, apply, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
11
11
  import { resolve, sectionKey } from "./core/resolve.js";
12
12
  import { catalogueContext, listRecipes, listIngredients, checkType } from "./core/catalogue.js";
13
13
  import { listTargets } from "./core/capabilities.js";
@@ -19,6 +19,9 @@ import { exists, gitDirty, gitIsRepo, gitUnheld } from "./core/forge.js";
19
19
  import { fingerprintDir } from "./core/fingerprint.js";
20
20
  import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, } from "./core/unify.js";
21
21
  import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
22
+ import { planInit } from "./core/init.js";
23
+ import { editRecipesText, planRecipeEdit, recipeDiffLine } from "./core/recipe-edit.js";
24
+ import { localKeys } from "./core/workspace-yaml.js";
22
25
  import { HUNK_CLASSES, INGREDIENT_TYPES, UnifyPlanSchema } from "./schema/index.js";
23
26
  process.stdout.on("error", (e) => { if (e.code === "EPIPE")
24
27
  process.exit(0); });
@@ -29,7 +32,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
29
32
  */
30
33
  const DIFF_EXIT_CODE_FETCH_MODE = "sync";
31
34
  const program = new Command();
32
- program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.12.0");
35
+ program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.14.0");
33
36
  /* ---------------------------------------------------------------- import */
34
37
  program
35
38
  .command("import")
@@ -98,34 +101,15 @@ program
98
101
  return console.log(JSON.stringify({ forge: forgeJson(ws, lock), statuses: st.map(({ planned, ...s }) => s), warnings: p.warnings }, null, 2));
99
102
  printStatus(st, p.warnings, ws.config.profile, p.resolution.recipes, false, forgeLine(ws, lock));
100
103
  });
101
- /* ---------------------------------------------------------------- sync */
102
- program
103
- .command("sync")
104
- .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)")
105
- .option("--workspace <dir>", "workspace root", ".")
106
- .option("--check", "exit 1 when the workspace is out of date or has drift (CI mode)", false)
107
- .option("--dry-run", "show the plan, write nothing", false)
108
- .option("--overwrite-drift", "regenerate files that were hand-edited (their edits are lost)", false)
109
- .option("--offline", "use the cached copy of a remote Forge, without fetching", false)
110
- .action(async (o) => {
111
- const ws = await loadWorkspace(o.workspace, load(o, "sync"));
112
- const p = await plan(ws);
113
- const lock = await readLock(ws.root);
114
- const st = await status(ws, p, lock);
115
- if (o.check) {
116
- const bad = st.filter((s) => !["unchanged", "adopt"].includes(s.state));
117
- printStatus(st, p.warnings, ws.config.profile, p.resolution.recipes, true, forgeLine(ws, lock));
118
- if (bad.length) {
119
- console.log(pc.red(`\n${bad.length} file(s) out of sync`));
120
- process.exit(1);
121
- }
122
- console.log(pc.green("\nworkspace in sync"));
123
- return;
124
- }
125
- const r = await apply(ws, p, st, { dryRun: o.dryRun, overwriteDrift: o.overwriteDrift });
104
+ /**
105
+ * A writing sync on a planned workspace, then its report: `apply`, the registration of spec 21 (unless
106
+ * `CRAFTAR_NO_REGISTRY`), and the lines `sync` prints. `sync` and `init` both call it (spec 23 §5.2).
107
+ */
108
+ async function applyAndReport(ws, p, st, lock, opts = {}) {
109
+ const r = await apply(ws, p, st, opts);
126
110
  // A writing sync records the workspace (spec 21 §4.1); the registry is an index, so a failure is a warning.
127
111
  let registryWarning = null;
128
- if (!o.dryRun && !registryOff()) {
112
+ if (!opts.dryRun && !registryOff()) {
129
113
  const home = craftarHome();
130
114
  try {
131
115
  await register(home, ws, p);
@@ -134,7 +118,7 @@ program
134
118
  registryWarning = `registry not updated (${registryFile(home)}): ${e instanceof Error ? e.message : String(e)}`;
135
119
  }
136
120
  }
137
- const verb = o.dryRun ? "would write" : "wrote";
121
+ const verb = opts.dryRun ? "would write" : "wrote";
138
122
  console.log(pc.bold(`craftar sync — profile ${ws.config.profile} · recipes ${p.resolution.recipes.join(" → ")} · targets ${p.resolution.targets.join(", ")}`));
139
123
  const fl = forgeLine(ws, lock);
140
124
  if (fl)
@@ -150,6 +134,92 @@ program
150
134
  console.log(` ${pc.yellow("warn")} ${w}`);
151
135
  if (registryWarning)
152
136
  console.log(` ${pc.yellow("warn")} ${registryWarning}`);
137
+ return r;
138
+ }
139
+ /* ---------------------------------------------------------------- init */
140
+ program
141
+ .command("init")
142
+ .description("Start a workspace from a Forge and a profile: write a craftar.yaml proved to resolve and plan, then run the first sync; refused when craftar.yaml already exists")
143
+ .requiredOption("--forge <dir|url>", "the Forge: a directory (written relative to the workspace) or a git URL")
144
+ .requiredOption("--profile <name>", "the client profile in the Forge")
145
+ .option("--ref <ref>", "branch, tag or full SHA of a remote Forge")
146
+ .option("--targets <a,b>", "targets to write in craftar.yaml (claude-code, kiro, agents-md); omitted, the workspace follows the profile's")
147
+ .option("--add-recipe <name>", "add a recipe to the profile's (repeatable)", collect, [])
148
+ .option("--remove-recipe <name>", "remove a recipe of the profile's (repeatable)", collect, [])
149
+ .option("--replace", "let --add-recipe take a slot another recipe holds", false)
150
+ .option("--no-sync", "write craftar.yaml only, and say what the first sync would do")
151
+ .option("--offline", "use the cached copy of a remote Forge, without fetching", false)
152
+ .option("--workspace <dir>", "workspace root, created when missing", ".")
153
+ .action(async (o) => {
154
+ const root = path.resolve(o.workspace);
155
+ const n1 = () => fail(`${WORKSPACE_FILE} already exists in ${root} — change recipes with craftar add recipe / remove recipe, or edit it`);
156
+ const file = path.join(root, WORKSPACE_FILE);
157
+ if (await exists(file))
158
+ n1();
159
+ const st = await fs.stat(root).catch(() => null);
160
+ if (st && !st.isDirectory())
161
+ fail(`cannot use ${root} as a workspace: it is not a directory`);
162
+ const init = await planInit(root, {
163
+ forge: o.forge,
164
+ profile: o.profile,
165
+ ref: o.ref,
166
+ targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()),
167
+ addRecipes: o.addRecipe,
168
+ removeRecipes: o.removeRecipe,
169
+ replace: o.replace,
170
+ }, load(o, "sync"));
171
+ const { ws, plan: p } = init;
172
+ // Every refusal is above: only now does the directory, and craftar.yaml, come to exist (§13 items 1, 9).
173
+ await fs.mkdir(root, { recursive: true });
174
+ try {
175
+ await fs.writeFile(file, init.text, { flag: "wx" });
176
+ }
177
+ catch (e) {
178
+ if (e.code === "EEXIST")
179
+ n1();
180
+ throw e;
181
+ }
182
+ const from = { flag: "", local: ` (from ${LOCAL_FILE})`, profile: " (from the profile)" }[init.targetsFrom];
183
+ console.log(pc.bold(`craftar init — wrote ${WORKSPACE_FILE} in ${root}`));
184
+ console.log(` forge ${ws.config.forge} · profile ${ws.config.profile} · recipes ${p.resolution.recipes.join(" → ")} · targets ${p.resolution.targets.join(", ")}${from}`);
185
+ for (const n of init.notes)
186
+ console.log(` note ${n}`);
187
+ if (!o.sync) {
188
+ console.log(nextSyncLine(init.statuses));
189
+ for (const w of p.warnings)
190
+ console.log(` ${pc.yellow("warn")} ${w}`);
191
+ return;
192
+ }
193
+ const r = await applyAndReport(ws, p, init.statuses, init.lock);
194
+ const collisions = r.skipped.filter((s) => s.state === "collision").length;
195
+ if (collisions)
196
+ console.log(` ${pc.yellow("warn")} ${collisions} file(s) already in the workspace differ from the Forge and were left as they are — to bring them into the Forge, run craftar import`);
197
+ });
198
+ /* ---------------------------------------------------------------- sync */
199
+ program
200
+ .command("sync")
201
+ .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)")
202
+ .option("--workspace <dir>", "workspace root", ".")
203
+ .option("--check", "exit 1 when the workspace is out of date or has drift (CI mode)", false)
204
+ .option("--dry-run", "show the plan, write nothing", false)
205
+ .option("--overwrite-drift", "regenerate files that were hand-edited (their edits are lost)", false)
206
+ .option("--offline", "use the cached copy of a remote Forge, without fetching", false)
207
+ .action(async (o) => {
208
+ const ws = await loadWorkspace(o.workspace, load(o, "sync"));
209
+ const p = await plan(ws);
210
+ const lock = await readLock(ws.root);
211
+ const st = await status(ws, p, lock);
212
+ if (o.check) {
213
+ const bad = st.filter((s) => !["unchanged", "adopt"].includes(s.state));
214
+ printStatus(st, p.warnings, ws.config.profile, p.resolution.recipes, true, forgeLine(ws, lock));
215
+ if (bad.length) {
216
+ console.log(pc.red(`\n${bad.length} file(s) out of sync`));
217
+ process.exit(1);
218
+ }
219
+ console.log(pc.green("\nworkspace in sync"));
220
+ return;
221
+ }
222
+ await applyAndReport(ws, p, st, lock, { dryRun: o.dryRun, overwriteDrift: o.overwriteDrift });
153
223
  });
154
224
  /* ---------------------------------------------------------------- diff */
155
225
  program
@@ -202,6 +272,63 @@ program
202
272
  else if (o.exitCode)
203
273
  process.exitCode = 1;
204
274
  });
275
+ /* ---------------------------------------------------------------- add / remove recipe */
276
+ /** The states `next sync:` counts, in `FileState` declaration order (spec 22 §14 item 6); a new state does not compile until it is placed here. */
277
+ const NEXT_SYNC = { new: true, update: true, drift: true, adopt: true, collision: true, orphan: true, "orphan-drift": true };
278
+ const NEXT_SYNC_STATES = Object.keys(NEXT_SYNC);
279
+ /** Spec 22's line: what the next sync would do, over `NEXT_SYNC_STATES` (also `init --no-sync`, spec 23 §4.4). */
280
+ function nextSyncLine(st) {
281
+ const counts = NEXT_SYNC_STATES.map((k) => [k, st.filter((s) => s.state === k).length])
282
+ .filter(([, n]) => n > 0)
283
+ .map(([k, n]) => `${n} ${k}`);
284
+ return `next sync: ${counts.length ? `${counts.join(", ")} — run \`craftar sync\`` : "nothing to sync"}`;
285
+ }
286
+ function recipeCommand(op) {
287
+ return async (names, o) => {
288
+ const ws = await loadWorkspace(o.workspace, load(o, "read"));
289
+ warnStderr(ws.warnings);
290
+ // R1: arrays replace across layers, so an edit of craftar.yaml would not take effect (spec 22 Ruling 1).
291
+ if ((await localKeys(ws.root)).includes("recipes"))
292
+ fail(`${LOCAL_FILE} sets recipes, which replaces ${WORKSPACE_FILE}'s lists — edit it by hand, or remove its recipes key and re-run`);
293
+ const edit = planRecipeEdit(ws.forge, ws.config, op, names, { replace: o.replace === true });
294
+ if (!edit.changed) {
295
+ console.log(`nothing to change${edit.reasons.length ? `: ${edit.reasons.join(", ")}` : ""}`);
296
+ return;
297
+ }
298
+ const file = path.join(ws.root, WORKSPACE_FILE);
299
+ const content = editRecipesText(await fs.readFile(file, "utf8"), edit.recipes, `${op} recipe`);
300
+ // The report is computed before the write, so a plan that fails leaves craftar.yaml untouched (§5.2 step 4).
301
+ const next = { ...ws, config: { ...ws.config, recipes: edit.recipes } };
302
+ const p = await plan(next);
303
+ const st = await status(next, p, await readLock(ws.root));
304
+ const nextLine = nextSyncLine(st);
305
+ await fs.writeFile(file, content);
306
+ console.log(`${WORKSPACE_FILE}: ${recipeDiffLine(ws.config.recipes, edit.recipes)}`);
307
+ console.log(`recipes: ${p.resolution.recipes.join(" → ")}`);
308
+ console.log(nextLine);
309
+ // plan() already carries ws.warnings first, and they were printed on load.
310
+ warnStderr(p.warnings.slice(ws.warnings.length));
311
+ };
312
+ }
313
+ program
314
+ .command("add")
315
+ .description("Add to the workspace's choices in craftar.yaml")
316
+ .command("recipe")
317
+ .description("Add recipes to craftar.yaml's recipes.add (or cancel their recipes.remove entry), after proving the workspace still resolves; writes nothing else and does not sync — it says what the next sync would do")
318
+ .argument("<name...>", "the recipes to add, applied in order, all or nothing")
319
+ .option("--replace", "swap out every other recipe that holds the same slot", false)
320
+ .option("--workspace <dir>", "workspace root", ".")
321
+ .option("--offline", "use the cached copy of a remote Forge, without fetching", false)
322
+ .action(recipeCommand("add"));
323
+ program
324
+ .command("remove")
325
+ .description("Remove from the workspace's choices in craftar.yaml")
326
+ .command("recipe")
327
+ .description("Remove recipes through craftar.yaml's recipes.remove (or cancel their recipes.add entry); refuses one another recipe brings in through extends; writes nothing else and does not sync")
328
+ .argument("<name...>", "the recipes to remove, applied in order, all or nothing")
329
+ .option("--workspace <dir>", "workspace root", ".")
330
+ .option("--offline", "use the cached copy of a remote Forge, without fetching", false)
331
+ .action(recipeCommand("remove"));
205
332
  /* ---------------------------------------------------------------- explain */
206
333
  program
207
334
  .command("explain")
@@ -1044,6 +1171,10 @@ async function forgeFor(o) {
1044
1171
  warnStderr(workspace?.warnings ?? []);
1045
1172
  return forge;
1046
1173
  }
1174
+ /** A repeatable option's values, in the order given. */
1175
+ function collect(value, previous) {
1176
+ return [...previous, value];
1177
+ }
1047
1178
  /** `$CRAFTAR_HOME`, as the cache reads it (spec 13 §6.3). */
1048
1179
  function craftarHome() {
1049
1180
  return resolveHome(process.env.CRAFTAR_HOME || undefined);