craftar 0.16.0 → 0.17.1
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 +16 -4
- package/dist/cli.js +90 -2
- package/dist/cli.js.map +1 -1
- package/dist/core/cache.js +226 -0
- package/dist/core/cache.js.map +1 -0
- package/dist/core/doctor.js +3 -3
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/home-lock.js +25 -4
- package/dist/core/home-lock.js.map +1 -1
- package/dist/core/remote.js +204 -31
- package/dist/core/remote.js.map +1 -1
- package/dist/core/unify.js +13 -2
- package/dist/core/unify.js.map +1 -1
- package/package.json +1 -1
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*). `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*). `craftar forge impact` lists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, and `forge unify` plans them before and after its writes, names the ones its warnings concern, and with `--prune-recipes` deletes a recipe left identical to its sibling once every one of them plans the same. `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. 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*). `craftar forge impact` lists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, and `forge unify` plans them before and after its writes, names the ones its warnings concern, and with `--prune-recipes` deletes a recipe left identical to its sibling once every one of them plans the same. `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. `craftar cache prune` removes the cache entries and trees nothing uses. 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
|
|
|
@@ -72,6 +72,7 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
72
72
|
| `craftar ls` | Recipes and ingredients resolved for this workspace. |
|
|
73
73
|
| `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}`. |
|
|
74
74
|
| `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 (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}`. |
|
|
75
|
+
| `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}`. |
|
|
75
76
|
| `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. |
|
|
76
77
|
| `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}`. |
|
|
77
78
|
| `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}`. |
|
|
@@ -81,7 +82,7 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
81
82
|
| `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`. |
|
|
82
83
|
| `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). |
|
|
83
84
|
| `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`. |
|
|
84
|
-
| `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,
|
|
85
|
+
| `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`. |
|
|
85
86
|
|
|
86
87
|
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`.
|
|
87
88
|
|
|
@@ -273,7 +274,7 @@ Copy a workspace with `.claude/` and `.kiro/` into `fixtures/` (keep `local-stac
|
|
|
273
274
|
|
|
274
275
|
`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.
|
|
275
276
|
|
|
276
|
-
- **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.
|
|
277
|
+
- **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. `craftar cache prune` removes whole entries nothing uses and old trees on demand (see *Commands*). The cache and the workspace registry (below) are the two places, both under `$CRAFTAR_HOME`, where Craftar writes outside a workspace and a Forge on its own.
|
|
277
278
|
- **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.
|
|
278
279
|
- **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.
|
|
279
280
|
- **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.
|
|
@@ -290,11 +291,22 @@ A writing `sync` records the workspace in `$CRAFTAR_HOME/registry.json` (`~/.cra
|
|
|
290
291
|
|
|
291
292
|
## Roadmap (from the functional spec)
|
|
292
293
|
|
|
293
|
-
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; `craftar doctor`; `craftar forge impact` and `forge unify --prune-recipes`.
|
|
294
|
+
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; `craftar doctor`; `craftar cache prune`; `craftar forge impact` and `forge unify --prune-recipes`.
|
|
294
295
|
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.
|
|
295
296
|
|
|
296
297
|
## Upgrading
|
|
297
298
|
|
|
299
|
+
### to 0.17.1
|
|
300
|
+
|
|
301
|
+
- **`forge unify --prune-recipes` no longer exits 1 when the Forge stops loading between the recipe cascade's reload and the prune's own reload** (something else edited the Forge during the run): every candidate is kept with the reload's message (`the Forge no longer loads when the prune reloads it: …`). A Forge that `unify`'s own writes leave unloadable still fails `unify` through the recipe cascade's reload, naming the paths written and the `git` commands that restore them. No emitter, `plan`, `status`, `sync` or lock changed. No workspace sees `update`.
|
|
302
|
+
|
|
303
|
+
### to 0.17.0
|
|
304
|
+
|
|
305
|
+
- **New command `craftar cache prune`**: removes the Forge cache entries nothing uses, entries whose first fetch never completed, and trees unused for 14 days. Its `--json` output is a tooling contract.
|
|
306
|
+
- **`craftar doctor`'s `cache` fix is now `craftar cache prune`** (it was "remove `<dir>` by hand (craftar cache prune is planned)"): a value change of `fix` in `doctor --json`. A `~removing-*` directory under `forges/` (an interrupted prune's leftover) is no longer reported or counted as an entry.
|
|
307
|
+
- **A process waiting on a cache entry's lock re-creates the entry** when another process removed it meanwhile, instead of failing with `ENOENT`.
|
|
308
|
+
- **No byte change**: no emitter, `plan`, `status`, `sync` or lock changed. No workspace sees `update`.
|
|
309
|
+
|
|
298
310
|
### to 0.16.0
|
|
299
311
|
|
|
300
312
|
- **New command `craftar forge impact`**: lists every workspace registered on this machine that reads a Forge with what its next sync would do against it. Its `--json` output is a tooling contract.
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { classifyForge } from "./core/remote.js";
|
|
|
8
8
|
import { forget, listWorkspaces, prune, register, registryFile } from "./core/registry.js";
|
|
9
9
|
import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState } from "./core/impact.js";
|
|
10
10
|
import { runDoctor } from "./core/doctor.js";
|
|
11
|
+
import { pruneCache } from "./core/cache.js";
|
|
11
12
|
import { resolveHome } from "./core/home-lock.js";
|
|
12
13
|
import { loadWorkspace, plan, readLock, status, apply, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
|
|
13
14
|
import { resolve, sectionKey } from "./core/resolve.js";
|
|
@@ -34,7 +35,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
|
34
35
|
*/
|
|
35
36
|
const DIFF_EXIT_CODE_FETCH_MODE = "sync";
|
|
36
37
|
const program = new Command();
|
|
37
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
38
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.17.1");
|
|
38
39
|
/* ---------------------------------------------------------------- import */
|
|
39
40
|
program
|
|
40
41
|
.command("import")
|
|
@@ -1252,7 +1253,7 @@ forge
|
|
|
1252
1253
|
});
|
|
1253
1254
|
}
|
|
1254
1255
|
catch (e) {
|
|
1255
|
-
// A throw in pruneRecipes (e.g.
|
|
1256
|
+
// A throw in pruneRecipes (e.g. a profile edit that fails to re-parse) after unify's writes: name what was touched.
|
|
1256
1257
|
throw new Error(lateFailure(e, f.root, journal), { cause: e });
|
|
1257
1258
|
}
|
|
1258
1259
|
// Apply the prune writes
|
|
@@ -1527,6 +1528,34 @@ forge
|
|
|
1527
1528
|
console.log(` next: run \`craftar status --workspace <dir>\` in a workspace on profile ${o.profile} to see what moved`);
|
|
1528
1529
|
}
|
|
1529
1530
|
});
|
|
1531
|
+
/* ---------------------------------------------------------------- cache ---------------------------------------------------------------- */
|
|
1532
|
+
const cache = program.command("cache").description("The per-machine Forge cache in $CRAFTAR_HOME/forges/");
|
|
1533
|
+
cache
|
|
1534
|
+
.command("prune")
|
|
1535
|
+
.description("Remove the Forge cache entries nothing uses — no registered workspace and not this one — the entries whose first fetch never completed, and trees unused for 14 days; --dry-run only shows. Never touches a workspace, a lock or the registry; no network")
|
|
1536
|
+
// No commander default: an explicit --workspace without craftar.yaml exits 1, the current directory without one runs (spec 26 §4.1).
|
|
1537
|
+
.option("--dry-run", "show what would be removed; remove nothing", false)
|
|
1538
|
+
.option("--workspace <dir>", "the workspace whose Forge counts as used besides the registry (default: the current directory, when it holds craftar.yaml)")
|
|
1539
|
+
.option("--json", "machine-readable output", false)
|
|
1540
|
+
.action(async (o) => {
|
|
1541
|
+
let workspace = null;
|
|
1542
|
+
if (o.workspace !== undefined) {
|
|
1543
|
+
if (!(await exists(path.join(o.workspace, WORKSPACE_FILE))))
|
|
1544
|
+
fail(`no ${WORKSPACE_FILE} in ${path.resolve(o.workspace)} — run cache prune inside a workspace, or without --workspace`);
|
|
1545
|
+
workspace = o.workspace;
|
|
1546
|
+
}
|
|
1547
|
+
else if (await exists(path.join(process.cwd(), WORKSPACE_FILE)))
|
|
1548
|
+
workspace = process.cwd();
|
|
1549
|
+
const result = await pruneCache(craftarHome(), {
|
|
1550
|
+
dryRun: o.dryRun,
|
|
1551
|
+
workspace,
|
|
1552
|
+
registryOff: registryOff(),
|
|
1553
|
+
});
|
|
1554
|
+
if (o.json)
|
|
1555
|
+
console.log(JSON.stringify(result.report, null, 2));
|
|
1556
|
+
else
|
|
1557
|
+
printPrune(result);
|
|
1558
|
+
});
|
|
1530
1559
|
program.parseAsync().catch((e) => fail(e instanceof Error ? e.message : String(e)));
|
|
1531
1560
|
/* ---------------------------------------------------------------- helpers */
|
|
1532
1561
|
/** The network rule of a command, `--offline`, and `$CRAFTAR_HOME` (spec 13 §4.1, §6.3). */
|
|
@@ -1557,6 +1586,65 @@ function printDoctor(r) {
|
|
|
1557
1586
|
console.log(` ${paint(c.level)} ${c.id.padEnd(13)} ${c.message}${suffix(c)}`);
|
|
1558
1587
|
console.log(`summary: ${r.summary.ok} ok, ${r.summary.warn} warn, ${r.summary.error} error`);
|
|
1559
1588
|
}
|
|
1589
|
+
/** The text report of `craftar cache prune` (spec 26 §4.4) — presentation, not contract. */
|
|
1590
|
+
function printPrune(result) {
|
|
1591
|
+
const mb = (b) => `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
|
1592
|
+
if (result.header === null) {
|
|
1593
|
+
console.log("nothing to prune");
|
|
1594
|
+
for (const w of result.report.warnings)
|
|
1595
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
const { forges, entries, bytes } = result.header;
|
|
1599
|
+
console.log(pc.bold(`craftar cache prune — ${forges} (${entries} ${entries === 1 ? "entry" : "entries"}, ${mb(bytes)})`));
|
|
1600
|
+
// Compute label width: for entries/leftovers, just the key; for trees, `<key>/trees/<first 10 of commit>…`.
|
|
1601
|
+
const labelOf = (r) => {
|
|
1602
|
+
const p = r.path;
|
|
1603
|
+
if ("kind" in r && r.kind === "tree") {
|
|
1604
|
+
// forges/<key>/trees/<commit> → <key>/trees/<commit10>…
|
|
1605
|
+
const parts = p.split("/");
|
|
1606
|
+
return `${parts[1]}/trees/${parts[3].slice(0, 10)}…`;
|
|
1607
|
+
}
|
|
1608
|
+
// forges/<key> or forges/~removing-<...> → last segment
|
|
1609
|
+
return p.split("/").pop();
|
|
1610
|
+
};
|
|
1611
|
+
const whyRemoved = (r) => {
|
|
1612
|
+
if (r.reason === "orphan")
|
|
1613
|
+
return "orphan: nothing names it";
|
|
1614
|
+
if (r.reason === "incomplete")
|
|
1615
|
+
return "incomplete: a fetch never completed";
|
|
1616
|
+
if (r.reason === "unused")
|
|
1617
|
+
return "unused for 14 days or more";
|
|
1618
|
+
return "leftover of an interrupted prune";
|
|
1619
|
+
};
|
|
1620
|
+
const whyKept = (k) => {
|
|
1621
|
+
if (k.reason === "named")
|
|
1622
|
+
return `named by ${k.namedBy.join(", ")}`;
|
|
1623
|
+
if (k.reason === "busy")
|
|
1624
|
+
return "in use";
|
|
1625
|
+
if (k.reason === "held-open")
|
|
1626
|
+
return "held open";
|
|
1627
|
+
return "not checked";
|
|
1628
|
+
};
|
|
1629
|
+
// Compute column widths W (label) and V (why) across removed and kept rows.
|
|
1630
|
+
const allLabels = [...result.report.removed, ...result.report.kept].map(labelOf);
|
|
1631
|
+
const removedWhys = result.report.removed.map(whyRemoved);
|
|
1632
|
+
const keptWhys = result.report.kept.map(whyKept);
|
|
1633
|
+
const W = Math.max(1, ...allLabels.map((l) => l.length));
|
|
1634
|
+
const V = Math.max(1, ...removedWhys.map((w) => w.length), ...keptWhys.map((w) => w.length));
|
|
1635
|
+
const verb = result.report.dryRun ? "would remove" : "removed";
|
|
1636
|
+
for (const r of result.report.removed) {
|
|
1637
|
+
console.log(` ${verb.padEnd(12)} ${labelOf(r).padEnd(W)} ${whyRemoved(r).padEnd(V)} ${mb(r.bytes)}`);
|
|
1638
|
+
}
|
|
1639
|
+
for (const k of result.report.kept) {
|
|
1640
|
+
console.log(` ${"kept".padEnd(12)} ${labelOf(k).padEnd(W)} ${whyKept(k)}`);
|
|
1641
|
+
}
|
|
1642
|
+
for (const w of result.report.warnings) {
|
|
1643
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
1644
|
+
}
|
|
1645
|
+
const freedVerb = result.report.dryRun ? "would free" : "freed";
|
|
1646
|
+
console.log(`${freedVerb} ${mb(result.report.freedBytes)}`);
|
|
1647
|
+
}
|
|
1560
1648
|
/** `CRAFTAR_NO_REGISTRY` — "no registry", read like NO_COLOR: any non-empty value turns it off (spec 21 §4.1). */
|
|
1561
1649
|
function registryOff() {
|
|
1562
1650
|
return Boolean(process.env.CRAFTAR_NO_REGISTRY);
|