craftar 0.34.2 → 0.35.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 +15 -4
- package/dist/cli.js +121 -72
- package/dist/cli.js.map +1 -1
- package/dist/core/mcp-protocol.js +135 -18
- package/dist/core/mcp-protocol.js.map +1 -1
- package/dist/core/registry.js +61 -0
- package/dist/core/registry.js.map +1 -1
- package/dist/core/report.js +24 -1
- package/dist/core/report.js.map +1 -1
- package/dist/core/sync.js +3 -1
- package/dist/core/sync.js.map +1 -1
- package/dist/core/write-actions.js +197 -0
- package/dist/core/write-actions.js.map +1 -0
- package/dist/schema/index.js +13 -0
- package/dist/schema/index.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 1 in progress** — phase 0, the foundations, is done. `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) and `forge promote` (a profile's variant becomes the base) 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` or above (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. An MCP ingredient can declare `authEnv`, the environment variables its server needs (names only, never a value), and `craftar doctor` checks them; with `claude-code`, `sync` writes an example file listing them. A recipe can bring **local services**: a `service` ingredient is a Docker Compose fragment that `sync` writes under `.craftar/`, and `craftar services up | down | status` run Compose on what the lock records (see *Local services*). A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above (see *Integrations*), since `0.27.0`. `craftar templates list | install` shows and runs the profile's `templates` block — `install` runs `dotnet new install` per entry into the user's template store; nothing installs implicitly. An MCP `server` takes the params its ingredient declares, in a Forge that declares `schema: 4` (see *Server params*), since `0.28.0`. `craftar mcp` serves a workspace to an AI coder over the Model Context Protocol — seven reading tools with the data of `status`, `diff`, `explain`, `recipes`, `ingredients`, `targets` and `doctor`; it
|
|
5
|
+
> Status: **phase 1 in progress** — phase 0, the foundations, is done. `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) and `forge promote` (a profile's variant becomes the base) 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` or above (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. An MCP ingredient can declare `authEnv`, the environment variables its server needs (names only, never a value), and `craftar doctor` checks them; with `claude-code`, `sync` writes an example file listing them. A recipe can bring **local services**: a `service` ingredient is a Docker Compose fragment that `sync` writes under `.craftar/`, and `craftar services up | down | status` run Compose on what the lock records (see *Local services*). A profile's `integrations` block can select recipes and give params in a Forge that declares `schema: 3` or above (see *Integrations*), since `0.27.0`. `craftar templates list | install` shows and runs the profile's `templates` block — `install` runs `dotnet new install` per entry into the user's template store; nothing installs implicitly. An MCP `server` takes the params its ingredient declares, in a Forge that declares `schema: 4` (see *Server params*), since `0.28.0`. `craftar mcp` serves a workspace to an AI coder over the Model Context Protocol — seven reading tools with the data of `status`, `diff`, `explain`, `recipes`, `ingredients`, `targets` and `doctor`; started with `--allow-writes`, it also serves `sync`, `add_recipe` and `remove_recipe`, which write only in a workspace where a person typed `craftar mcp allow-writes` and never discard a hand edit. `craftar ui` serves a local, read-only dashboard of every registered workspace — the table of `craftar workspaces`, and for one workspace its file states, a file's diff and explanation, and its Forge's recipes and ingredients — on `127.0.0.1` only. `craftar init --clone` and `craftar repos clone` clone a profile's repositories (its `repos` list) into the workspace, and `craftar repos` lists them. `craftar init --new-profile` creates a profile in a Forge directory (one file, not committed) and starts the workspace on it.
|
|
6
6
|
|
|
7
7
|
## The idea in one paragraph
|
|
8
8
|
|
|
@@ -93,7 +93,9 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
93
93
|
| `craftar doctor [--workspace <dir>] [--fetch] [--strict] [--json]` | Checks this machine — Node ≥ 22, `git`, `$CRAFTAR_HOME`, the registry (entries whose workspace is gone), the Forge cache (its size, entries nothing names, a fetch that never completed) — and, when run inside one (the current directory, or `--workspace`), this workspace: the configuration and its Forge (cached, fetched or pinned), whether the workspace sits inside its Forge, the plan's warnings, declared parameters with no value, the `${NAME}` variables a written MCP server expects and this environment lacks — `mcp-env` reads `authEnv` next to `${NAME}` (names only, never values), `templates` when the profile declares a `templates` block (an empty `templates:` line declares none), `dotnet` when that block is accepted and declares a `dotnet-new` entry (the .NET SDK answering `dotnet --version` at the workspace root); neither goes above `warn` — `agents-md-size` when the plan writes `AGENTS.md` (`ok` within the budget or with the budget off, `warn` over the default budget, `error` over an explicit one — the cause is reported there, not by the plan's warnings), the lock, `status`, and registration; in a workspace with a service: `docker` — Docker and its Compose plugin, 2.20.0 or later — and `services-env` — the variables a fragment needs. One line per finding — `ok`, `warn` or `error` — each problem with the command that fixes it; each cause is reported once. A declared parameter with no value is an `error` when a file the plan writes cites it (`sync` refuses that workspace) and a `warn` when nothing cites it. It only reports: nothing is written to a workspace, a lock or the registry, and the Forge cache is used as every reader uses it (`.used` stamps, a rebuilt tree; under `--fetch`, a fetch and the cleanup of trees unused for 14 days). Read without the network; `--fetch` fetches a remote Forge first. Exits 1 on an `error`, or on a `warn` under `--strict`; an explicit `--workspace` without `craftar.yaml` exits 1, while the current directory without one runs the machine checks only. `--json` is a contract: `{version, workspace, fetch, strict, checks: [{id, scope, level, message, fix}], summary}`. In a schema 4 Forge `mcp-env` reads the server as rendered, so a `${NAME}` a param brings into `env` is checked. |
|
|
94
94
|
| `craftar templates list [--json] [--workspace <dir>] [--offline]` | What the profile's `templates` block declares: one line per package — kind, package, version or `latest`, source or `default feeds` — and one per entry this craftar cannot install. Runs no program and does not know what is installed. `--json` is a contract: `{profile, templates: [{entry, kind, package, version, source, installable}], warnings}`. Exits 1 when the block is refused, naming the position and the rule, never a value. |
|
|
95
95
|
| `craftar templates install [--dry-run] [--force] [--interactive] [--workspace <dir>] [--offline]` | Runs `dotnet new install <pkg>[::<version>]... [--add-source <source>]` once per `dotnet-new` entry, in the profile's order, at the workspace root. `--dry-run` prints the exact lines, needs no `dotnet` and exits 0 over an accepted block, even with a skipped entry. A real run exits 1 when the .NET SDK is not available. Both exit 1 when the block is refused or a package ID exists as a file or folder at the workspace root. `--force` and `--interactive` are passed to `dotnet` as typed, never otherwise; `--interactive` also gives `dotnet` the terminal's input. An entry that fails does not stop the others; a real run exits 1 when any failed or was skipped. When an entry declares a `source` and the workspace root has no `NuGet.Config`, a warning line is printed before the runs. Writes nothing in the workspace, the lock or the registry; nothing else (`sync`, `init`, `doctor`) runs it. See *Project templates*. |
|
|
96
|
-
| `craftar mcp [--workspace <dir>] [--fetch]` | An MCP server (Model Context Protocol, revision `2025-06-18`) over stdin and stdout, started by an AI coder as a subprocess — not typed in a terminal, where it refuses to start. It serves **one workspace**, the one it was started in, through seven tools that only read: `status`, `diff` (the pending files; with a `path`, that file's diff), `explain`, `recipes`, `ingredients`, `targets` and `doctor` (the checks about this workspace, plus Node and `git`; like the command, it asks `docker compose` for its version when the workspace has a service and `dotnet` for its version when the profile declares a template it can install) — each answers with the data its command prints, as one JSON object.
|
|
96
|
+
| `craftar mcp [--workspace <dir>] [--fetch] [--allow-writes]` | An MCP server (Model Context Protocol, revision `2025-06-18`) over stdin and stdout, started by an AI coder as a subprocess — not typed in a terminal, where it refuses to start. It serves **one workspace**, the one it was started in, through seven tools that only read: `status`, `diff` (the pending files; with a `path`, that file's diff), `explain`, `recipes`, `ingredients`, `targets` and `doctor` (the checks about this workspace, plus Node and `git`; like the command, it asks `docker compose` for its version when the workspace has a service and `dotnet` for its version when the profile declares a template it can install) — each answers with the data its command prints, as one JSON object. Without `--allow-writes` nothing is written to the workspace, the lock, `craftar.yaml`, the registry or the Forge. With `--allow-writes` it also serves three writing tools — `sync` (a `craftar sync` that skips and lists every `drift`, `orphan-drift` and `collision`; a remote Forge needs `--fetch`), `add_recipe` and `remove_recipe` (an `add recipe` / `remove recipe`, which do not sync) — and each call writes only when this machine's registry holds the consent `craftar mcp allow-writes` recorded for this workspace, for the `forge`, `ref` and `profile` the server started with; otherwise the call is an error and nothing is written. A writing result carries what the command prints, never a file's content or the registry's path. The Forge cache is used as every reader uses it (`.used` stamps, a rebuilt tree; under `--fetch`, a fetch and the cleanup of trees unused for 14 days). Without `--fetch` no tool touches the network: a remote Forge is read from the cached copy, and the answer says so — for `doctor` in its `forge` check, for the others but `targets` in `warnings` unless `ref` is a full commit SHA, which cannot go stale (`targets` reads only which targets the configuration resolves, never fetches and carries no such line). The `forge`, `ref` and `profile` of `craftar.yaml` / `craftar.local.yaml` are fixed when the server starts: after one of them changes, every tool but `targets` answers with an error until it is put back or the server is restarted (`targets` answers the matrix unmarked, with a warning). A hand-edited `.mcp.json`, `.kiro/settings/mcp.json` or `.claude/settings.craftar.example.json` is never returned — a note stands in for its diff. A line that is not JSON, or longer than 1 MiB, is not answered — the server reads on. Exits 0 when stdin closes; exits 1, with one line on stderr, in a terminal, without a `craftar.yaml`, or when the configuration does not read. See *Craftar inside the AI coder*. |
|
|
97
|
+
| `craftar mcp allow-writes [--workspace <dir>]` | Records, in this machine's registry, the consent that lets `craftar mcp --allow-writes` write in this workspace. It runs only in a terminal (stdin and stdout), prints the workspace, the Forge, the ref and the profile the consent is for, and asks `Let craftar mcp --allow-writes write here [no]:` — only `y` / `yes` records it. Exits 1, writing nothing, outside a terminal, without a `craftar.yaml`, with `CRAFTAR_NO_REGISTRY` set, when the registry does not read or cannot be written, when `forge:` or `ref:` holds credentials, or on any other answer. The consent is bound to that `forge`, `ref` and `profile`: after one of them changes, a writing call is refused until the consent is given again. `craftar workspaces forget` and `prune` drop the consent of what they remove. |
|
|
98
|
+
| `craftar mcp revoke-writes [--workspace <dir>]` | Removes that consent; needs no terminal; takes effect on the server's next call, without a restart. Exits 0 also when there was none (`no consent recorded for <path>`); exits 1 without a `craftar.yaml`, with `CRAFTAR_NO_REGISTRY` set, or when the registry does not read or cannot be written. |
|
|
97
99
|
| `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}`. |
|
|
98
100
|
| `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. |
|
|
99
101
|
| `craftar ui [--port <n>]` | A local dashboard, in a browser: the table of `craftar workspaces` and, for a registered workspace, what `status`, `diff` and `explain` say about it and what `recipes` and `ingredients` say about its Forge. It prints one address and serves until interrupted (`Ctrl-C` exits 0). **Read-only**: nothing is written to a workspace, a lock, the registry or a Forge — the Forge cache is used as every reader uses it (`.used` stamps, a rebuilt tree; `git` runs as for `craftar workspaces`) — and the page never runs `sync` — it prints the command to copy. **Loopback only**: it listens on `127.0.0.1` (no `--host`; `localhost` is refused), answers `GET` only, and every answer that carries data needs a token generated for that run — it travels in the address's fragment, which the browser does not send, so open the address exactly as printed (pasted, not clicked from a web page; a reload keeps working, a new tab needs the address again); it stays in the terminal's scrollback (and in a file, if the output is redirected) and may stay in the browser's history, and is worth nothing once the command stops. **No network**: a remote Forge is read from the cached copy, never fetched, and the page says so. `--port` (default `4747`; `0` takes a free one); a taken port is refused, never replaced. A Forge URL the registry recorded with credentials is shown as `(a URL with credentials — not shown)`, with no cache key — as `craftar workspaces --json` gives it since `0.28.1` (see *Upgrading › to 0.28.1*). Takes no `--workspace`; exits 1 when the registry is off (`CRAFTAR_NO_REGISTRY`) or cannot be read. The page's routes are not a contract. |
|
|
@@ -507,20 +509,29 @@ server:
|
|
|
507
509
|
|
|
508
510
|
The server answers for the directory it is started in; a client that starts its servers elsewhere needs `args: [mcp, --workspace, <dir>]`. The server also sees only the environment the client starts it with: `git`, `dotnet` and `docker` are looked for in the absolute entries of that `PATH` (a `node_modules/.bin` of the directory the server is started in, or of one above it, aside — see *to 0.28.2*), and the variables `doctor` checks are read there — so a client that starts its servers with a reduced `PATH` gets `git not found` from `doctor` (its `dotnet` and `docker` checks warn too, when the workspace uses them and its Forge loads), no commit for a Forge that is a directory and, when the Forge is a git URL, an error from `status`, `diff`, `explain`, `recipes` and `ingredients` — `targets` then answers the matrix unmarked, with a warning, and `doctor` reports `git` as an `error` and, of the workspace's checks, runs only `lock`.
|
|
509
511
|
|
|
512
|
+
**Letting it write.** Started with `--allow-writes` (`args: [mcp, --allow-writes]`), the server also lists `sync`, `add_recipe` and `remove_recipe`. The flag grants nothing by itself: each writing call reads this machine's registry and acts only when a person typed `craftar mcp allow-writes` in this workspace, in a terminal, for the Forge, ref and profile the server is pinned to — so a Forge that puts `--allow-writes` in its `craftar` ingredient makes the tools appear in every workspace that syncs it, and write in none until someone there consents. `craftar mcp revoke-writes` takes the consent back. The consent refuses to be recorded outside a terminal; the shell tools of Claude Code and Kiro run without one (measured on 2026-10-23: neither gives a program a terminal on stdin or stdout), so an agent cannot grant it through them — an agent that drives a real terminal could, and could type `craftar sync` there as well. `sync` never overwrites or removes a hand edit: a `drift`, an `orphan-drift` and a `collision` are skipped and listed, and a person resolves them with `craftar drift discard` or `craftar drift promote`. Writes are not locked against a `craftar sync` run at the same moment in a terminal; each re-reads the disk just before it writes.
|
|
513
|
+
|
|
510
514
|
**What the results hold.** A tool result goes wherever the AI coder sends its context, and the tools return the commands' data with three things left out: `doctor`'s checks about the rest of the machine (`$CRAFTAR_HOME`, the registry, the Forge cache), the on-disk content of a hand-edited MCP configuration file, and — from `diff` without a path — the bodies (it lists counts). Nothing else is filtered. So a result can hold: the content of a generated file with its hand edits, or of a file craftar never wrote that sits where it would write one (`diff` with a path, on a `drift` or a `collision`) — a hand-edited service file under `.craftar/` included; values a profile or `overrides.params` put into a generated file; the Forge's path or URL as `craftar.yaml` names it; the absolute path of the workspace and of a path Forge, the Docker Compose and .NET SDK versions found on this machine and the names of the variables a service fragment expects — names, never values (`doctor`); and **the names of every profile of the Forge and the workspace an ingredient was imported from** — the profiles beside each recipe in `recipes`, the origin in `explain`. In a Forge shared across clients those names can be client names — register the server only where that is acceptable.
|
|
511
515
|
|
|
512
516
|
## Roadmap (from the functional spec)
|
|
513
517
|
|
|
514
518
|
Phase 0 — foundations (done): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; `forge variants`, `forge diff` and `forge unify` (`take: param`, `take: section`); sections.
|
|
515
519
|
|
|
516
|
-
Phase 1 — client profile (in progress). Shipped: the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock) and `craftar cache prune`; the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`; `craftar forge impact`, `forge unify --prune-recipes` and `forge promote`; an interactive `craftar init` (choose the Forge and the profile); `authEnv` on MCP ingredients (the environment variables a server needs, by name, and the generated `.claude/settings.craftar.example.json`); `craftar drift show` / `drift discard` / `drift promote` (list the files edited by hand, discard an edit, or carry one into the Forge as a profile value or a variant); a sync refused when a declared parameter has no value; `craftar import --report` (a Markdown report of an import, as paths and line counts) and `import --prefer-kiro` (a rule's source taken from its Kiro steering file); a profile's `integrations` block (PM tool and IDP as recipes selected by the profile); `{{param}}` inside an MCP server; `craftar mcp` (a read-only MCP server: seven reading tools for an AI coder, over stdin and stdout); `craftar ui` (a local, read-only dashboard of the registered workspaces, on `127.0.0.1` only); cloning a profile's repositories (`init --clone`, `craftar repos`); a new profile from `craftar init` (`--new-profile`, `--like`).
|
|
520
|
+
Phase 1 — client profile (in progress). Shipped: the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock) and `craftar cache prune`; the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`; `craftar forge impact`, `forge unify --prune-recipes` and `forge promote`; an interactive `craftar init` (choose the Forge and the profile); `authEnv` on MCP ingredients (the environment variables a server needs, by name, and the generated `.claude/settings.craftar.example.json`); `craftar drift show` / `drift discard` / `drift promote` (list the files edited by hand, discard an edit, or carry one into the Forge as a profile value or a variant); a sync refused when a declared parameter has no value; `craftar import --report` (a Markdown report of an import, as paths and line counts) and `import --prefer-kiro` (a rule's source taken from its Kiro steering file); a profile's `integrations` block (PM tool and IDP as recipes selected by the profile); `{{param}}` inside an MCP server; `craftar mcp` (a read-only MCP server: seven reading tools for an AI coder, over stdin and stdout); `craftar ui` (a local, read-only dashboard of the registered workspaces, on `127.0.0.1` only); cloning a profile's repositories (`init --clone`, `craftar repos`); a new profile from `craftar init` (`--new-profile`, `--like`); writing tools for `craftar mcp` (`--allow-writes`: `sync`, `add_recipe`, `remove_recipe`, behind a consent typed per workspace).
|
|
517
521
|
|
|
518
522
|
Phase 2 — environment (in progress). Shipped: local services (`service` ingredients, `.craftar/`, `craftar services up | down | status`); `craftar templates list | install` (`dotnet new` project templates declared in a profile).
|
|
519
523
|
|
|
520
|
-
Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`;
|
|
524
|
+
Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; the Claude Code / Agent Plugin packaging of `craftar mcp`.
|
|
521
525
|
|
|
522
526
|
## Upgrading
|
|
523
527
|
|
|
528
|
+
### to 0.35.0
|
|
529
|
+
|
|
530
|
+
- **New flag: `craftar mcp --allow-writes`**, and two commands, `craftar mcp allow-writes` and `craftar mcp revoke-writes` (see *Commands* and *Craftar inside the AI coder*). Without the flag, `craftar mcp` answers byte for byte as before — `initialize`, `tools/list` and every tool. Its `--help` description now reads `Reads only unless --allow-writes`.
|
|
531
|
+
- **`registry.json` may hold a top-level `writeConsent` list** (the consents of `craftar mcp allow-writes`); the registry stays `schema: 1`. A craftar from `0.12.0` on keeps the key when it rewrites the registry, and a registry that never held a consent is written as before (an empty list is omitted). `craftar workspaces --json` gains no key.
|
|
532
|
+
- **A Forge may register the server with `--allow-writes`**: every workspace that syncs it then lists the writing tools, which refuse every call until a person in that workspace runs `craftar mcp allow-writes`. On a craftar before `0.35.0` the flag is an unknown option and the server fails to start — upgrade every machine before adding it to a shared Forge.
|
|
533
|
+
- No emitted byte, lock, `--json` shape or exit code of an existing command changes.
|
|
534
|
+
|
|
524
535
|
### to 0.34.2
|
|
525
536
|
|
|
526
537
|
- **`import` now creates one script/hook ingredient per base name**, listing every file that shares that name (e.g., `run-x.ps1` and `run-x.sh` become one `script/run-x` with `files: ["run-x.ps1", "run-x.sh"]`). A Forge imported before keeps its shape (a base with one file and a `<name>--<profile>` variant with the other); a re-import rewrites that variant with both files. Two files that differ only in case (on a case-insensitive checkout they would collide) are rejected and named; the rest of the import proceeds. A file left in a variant directory after a re-import that grouped it is warned. No emitted byte changes; a Forge with the old shape may be fixed with `forge promote` or by hand.
|
package/dist/cli.js
CHANGED
|
@@ -8,22 +8,23 @@ import { parsePreferKiro } from "./importers/prefer-kiro.js";
|
|
|
8
8
|
import { planPromote, applyPromote, UnheldError } from "./importers/drift-promote.js";
|
|
9
9
|
import { providedRefs } from "./importers/provided.js";
|
|
10
10
|
import { holdsBaseCitations, rendersServers } from "./core/mcp-server.js";
|
|
11
|
-
import { assertNotCacheTree, classifyForge } from "./core/remote.js";
|
|
12
|
-
import { forget, listWorkspaces, prune, readRegistry,
|
|
13
|
-
import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState,
|
|
11
|
+
import { assertNotCacheTree, classifyForge, CREDENTIAL_HIDDEN, refHoldsCredentials } from "./core/remote.js";
|
|
12
|
+
import { allowWrites, forget, listWorkspaces, prune, readRegistry, revokeWrites } from "./core/registry.js";
|
|
13
|
+
import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState, countStatesObject, NEXT_SYNC_STATES } from "./core/impact.js";
|
|
14
14
|
import { runDoctor } from "./core/doctor.js";
|
|
15
15
|
import { launchRefusal, pinOf } from "./core/mcp-protocol.js";
|
|
16
16
|
import { serve } from "./mcp.js";
|
|
17
|
+
import { mcpWriters } from "./core/write-actions.js";
|
|
17
18
|
import { pruneCache } from "./core/cache.js";
|
|
18
19
|
import { resolveHome } from "./core/home-lock.js";
|
|
19
|
-
import { loadWorkspace, loadForgeSource, readWorkspaceConfig, plan, readLock, status,
|
|
20
|
+
import { loadWorkspace, loadForgeSource, readWorkspaceConfig, plan, readLock, status, unsetDeclared, unsetRefusal, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE } from "./core/sync.js";
|
|
20
21
|
import { sectionKey } from "./core/resolve.js";
|
|
21
22
|
import { catalogueContext, listRecipes, listIngredients, checkType, withLoadWarnings } from "./core/catalogue.js";
|
|
22
23
|
import { canonicalValue } from "./core/sections.js";
|
|
23
24
|
import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
|
|
24
25
|
import { preferredLine, renderImportReport } from "./core/import-report.js";
|
|
25
26
|
import { driftList, unmanaged } from "./core/drift.js";
|
|
26
|
-
import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, explainSkip, notProduced, readText, statusPath, statusReport, targetsReport } from "./core/report.js";
|
|
27
|
+
import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, explainSkip, forgeLine, nextSyncLine, notProduced, readText, statusPath, statusReport, targetsReport } from "./core/report.js";
|
|
27
28
|
import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
|
|
28
29
|
import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge, realpathOfNearest } from "./core/forge.js";
|
|
29
30
|
import { applyBasePromotion, planBasePromotion, proveBasePromotion } from "./core/promote.js";
|
|
@@ -38,8 +39,9 @@ import { askInit, askClone, confirmInit, againLine, NOT_CLONED_REFUSED } from ".
|
|
|
38
39
|
import { findGit, cloneRepos, listRepos, planClone } from "./core/clone.js";
|
|
39
40
|
import { byHand, profileRepos } from "./core/repos.js";
|
|
40
41
|
import { readlineIo } from "./prompt.js";
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
42
|
+
import { recipeDiffLine } from "./core/recipe-edit.js";
|
|
43
|
+
import { syncWorkspace, editRecipes, confirmWrites } from "./core/write-actions.js";
|
|
44
|
+
import { readLocalFile } from "./core/workspace-yaml.js";
|
|
43
45
|
import { HUNK_CLASSES, INGREDIENT_TYPES, UnifyPlanSchema } from "./schema/index.js";
|
|
44
46
|
import { EXAMPLE_SETTINGS } from "./emitters/shared.js";
|
|
45
47
|
import { YamlSyntaxError, parseYamlText } from "./core/yaml-read.js";
|
|
@@ -57,7 +59,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
|
57
59
|
*/
|
|
58
60
|
const DIFF_EXIT_CODE_FETCH_MODE = "sync";
|
|
59
61
|
const program = new Command();
|
|
60
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
62
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.35.0");
|
|
61
63
|
const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
|
|
62
64
|
const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
|
|
63
65
|
program
|
|
@@ -197,35 +199,23 @@ function budgetRefusalBlock(p) {
|
|
|
197
199
|
* `CRAFTAR_NO_REGISTRY`), and the lines `sync` prints. `sync` and `init` both call it (spec 23 §5.2).
|
|
198
200
|
*/
|
|
199
201
|
async function applyAndReport(ws, p, st, lock, opts = {}) {
|
|
200
|
-
const
|
|
201
|
-
// A writing sync records the workspace (spec 21 §4.1); the registry is an index, so a failure is a warning.
|
|
202
|
-
let registryWarning = null;
|
|
203
|
-
if (!opts.dryRun && !registryOff()) {
|
|
204
|
-
const home = craftarHome();
|
|
205
|
-
try {
|
|
206
|
-
await register(home, ws, p);
|
|
207
|
-
}
|
|
208
|
-
catch (e) {
|
|
209
|
-
registryWarning = `registry not updated (${registryFile(home)}): ${e instanceof Error ? e.message : String(e)}`;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
202
|
+
const o = await syncWorkspace(ws, p, st, lock, { ...opts, home: craftarHome(), registryOff: registryOff() });
|
|
212
203
|
const verb = opts.dryRun ? "would write" : "wrote";
|
|
213
204
|
console.log(pc.bold(`craftar sync — profile ${ws.config.profile} · recipes ${p.resolution.recipes.join(" → ")} · targets ${p.resolution.targets.join(", ")}`));
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
for (const f of r.written)
|
|
205
|
+
if (o.forgeLine)
|
|
206
|
+
console.log(o.forgeLine);
|
|
207
|
+
console.log(` ${verb} ${pc.green(String(o.result.written.length))}, removed ${pc.magenta(String(o.result.removed.length))} orphan(s), skipped ${pc.yellow(String(o.result.skipped.length))}`);
|
|
208
|
+
for (const f of o.result.written)
|
|
219
209
|
console.log(` ${pc.green("+")} ${f}`);
|
|
220
|
-
for (const f of
|
|
210
|
+
for (const f of o.result.removed)
|
|
221
211
|
console.log(` ${pc.magenta("-")} ${f} (${opts.overwritePaths?.has(f) ? "hand-edited orphan: discarded" : "orphan: no longer produced by the Forge"})`);
|
|
222
|
-
for (const s of
|
|
212
|
+
for (const s of o.result.skipped)
|
|
223
213
|
console.log(` ${pc.yellow("!")} ${s.path} ${explainSkip(s)}`);
|
|
224
214
|
for (const w of p.warnings)
|
|
225
215
|
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
226
|
-
if (
|
|
227
|
-
console.log(` ${pc.yellow("warn")} ${
|
|
228
|
-
return
|
|
216
|
+
if (o.registry)
|
|
217
|
+
console.log(` ${pc.yellow("warn")} registry not updated (${o.registry.file}): ${o.registry.message}`);
|
|
218
|
+
return o.result;
|
|
229
219
|
}
|
|
230
220
|
/**
|
|
231
221
|
* Shared clone report for `craftar init --clone` and `craftar repos clone`.
|
|
@@ -1131,43 +1121,20 @@ drift
|
|
|
1131
1121
|
}
|
|
1132
1122
|
});
|
|
1133
1123
|
/* ---------------------------------------------------------------- add / remove recipe */
|
|
1134
|
-
/**
|
|
1135
|
-
* Spec 22's line: what the next sync would do (also `init --no-sync`, spec 23 §4.4).
|
|
1136
|
-
* Uses `countStates` from `src/core/impact.ts`; a new `FileState` does not compile until it is
|
|
1137
|
-
* placed in `FILE_STATE_ORDER_MAP` there. A plan with unset declared parameters has no next sync
|
|
1138
|
-
* to count: the line says it is refused (spec 29 §4.1).
|
|
1139
|
-
*/
|
|
1140
|
-
function nextSyncLine(st, unset) {
|
|
1141
|
-
if (unset.length)
|
|
1142
|
-
return `next sync: ${unsetRefused(unset)}`;
|
|
1143
|
-
const counts = countStates(st).map(([k, n]) => `${n} ${k}`);
|
|
1144
|
-
return `next sync: ${counts.length ? `${counts.join(", ")} — run \`craftar sync\`` : "nothing to sync"}`;
|
|
1145
|
-
}
|
|
1146
1124
|
function recipeCommand(op) {
|
|
1147
1125
|
return async (names, o) => {
|
|
1148
1126
|
const ws = await loadWorkspace(o.workspace, load(o, "read"));
|
|
1149
1127
|
warnStderr(ws.warnings);
|
|
1150
|
-
|
|
1151
|
-
if (
|
|
1152
|
-
|
|
1153
|
-
const edit = planRecipeEdit(ws.forge, ws.config, op, names, { replace: o.replace === true });
|
|
1154
|
-
if (!edit.changed) {
|
|
1155
|
-
console.log(`nothing to change${edit.reasons.length ? `: ${edit.reasons.join(", ")}` : ""}`);
|
|
1128
|
+
const outcome = await editRecipes(ws, op, names, { replace: o.replace });
|
|
1129
|
+
if (!outcome.changed) {
|
|
1130
|
+
console.log(`nothing to change${outcome.reasons.length ? `: ${outcome.reasons.join(", ")}` : ""}`);
|
|
1156
1131
|
return;
|
|
1157
1132
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
const next = { ...ws, config: { ...ws.config, recipes: edit.recipes } };
|
|
1162
|
-
const p = await plan(next);
|
|
1163
|
-
const st = await status(next, p, await readLock(ws.root));
|
|
1164
|
-
const nextLine = nextSyncLine(st, unsetDeclared(p));
|
|
1165
|
-
await fs.writeFile(file, content);
|
|
1166
|
-
console.log(`${WORKSPACE_FILE}: ${recipeDiffLine(ws.config.recipes, edit.recipes)}`);
|
|
1167
|
-
console.log(`recipes: ${p.resolution.recipes.join(" → ")}`);
|
|
1168
|
-
console.log(nextLine);
|
|
1133
|
+
console.log(`${WORKSPACE_FILE}: ${recipeDiffLine(outcome.before, outcome.after)}`);
|
|
1134
|
+
console.log(`recipes: ${outcome.next.recipes.join(" → ")}`);
|
|
1135
|
+
console.log(nextSyncLine(outcome.next.statuses, outcome.next.unset));
|
|
1169
1136
|
// plan() already carries ws.warnings first, and they were printed on load.
|
|
1170
|
-
warnStderr(
|
|
1137
|
+
warnStderr(outcome.next.warnings);
|
|
1171
1138
|
};
|
|
1172
1139
|
}
|
|
1173
1140
|
program
|
|
@@ -1456,11 +1423,12 @@ templates
|
|
|
1456
1423
|
process.exitCode = 1;
|
|
1457
1424
|
});
|
|
1458
1425
|
/* ---------------------------------------------------------------- mcp */
|
|
1459
|
-
program
|
|
1426
|
+
const mcpCmd = program
|
|
1460
1427
|
.command("mcp")
|
|
1461
|
-
.description("Serve this workspace to an AI coder over the Model Context Protocol, on stdin and stdout: seven reading tools — status, diff, explain, recipes, ingredients, targets, doctor — with the data those commands print. Started by the AI coder, not typed. Reads only; reads without the network unless --fetch")
|
|
1428
|
+
.description("Serve this workspace to an AI coder over the Model Context Protocol, on stdin and stdout: seven reading tools — status, diff, explain, recipes, ingredients, targets, doctor — with the data those commands print. Started by the AI coder, not typed. Reads only unless --allow-writes; reads without the network unless --fetch")
|
|
1462
1429
|
.option("--workspace <dir>", "the workspace to serve", ".")
|
|
1463
1430
|
.option("--fetch", "let a tool call fetch a remote Forge instead of reading the cached copy", false)
|
|
1431
|
+
.option("--allow-writes", "enable the writing tools (sync, add_recipe, remove_recipe) for a workspace with consent", false)
|
|
1464
1432
|
.action(async (o) => {
|
|
1465
1433
|
// The bound workspace, resolved once: no tool takes a directory (spec 35 §4.1).
|
|
1466
1434
|
const root = path.resolve(o.workspace);
|
|
@@ -1484,8 +1452,99 @@ program
|
|
|
1484
1452
|
registryOff: registryOff(),
|
|
1485
1453
|
env: process.env,
|
|
1486
1454
|
pin: pinOf(merged),
|
|
1455
|
+
writers: o.allowWrites ? mcpWriters(craftarHome(), registryOff()) : undefined,
|
|
1487
1456
|
});
|
|
1488
1457
|
});
|
|
1458
|
+
mcpCmd
|
|
1459
|
+
.command("allow-writes")
|
|
1460
|
+
.description("Let craftar mcp --allow-writes write in this workspace — sync, add_recipe, remove_recipe — for the Forge, ref and profile it names now; asks a person in a terminal and records the consent in this machine's registry")
|
|
1461
|
+
// No default here: `mcp` holds `--workspace` too, and commander gives the value to the parent when both declare it.
|
|
1462
|
+
.option("--workspace <dir>", 'workspace root (default: ".")')
|
|
1463
|
+
.action(async (o) => {
|
|
1464
|
+
const workspace = o.workspace ?? mcpCmd.opts().workspace ?? ".";
|
|
1465
|
+
if (mcpCmd.opts().fetch)
|
|
1466
|
+
fail("craftar mcp allow-writes takes only --workspace");
|
|
1467
|
+
if (mcpCmd.opts().allowWrites)
|
|
1468
|
+
fail("craftar mcp allow-writes takes only --workspace");
|
|
1469
|
+
const root = path.resolve(workspace);
|
|
1470
|
+
if (!(await exists(path.join(root, WORKSPACE_FILE)))) {
|
|
1471
|
+
fail(`no craftar.yaml in ${root} — the consent is given for one workspace; type it in one, or pass --workspace`);
|
|
1472
|
+
}
|
|
1473
|
+
if (registryOff())
|
|
1474
|
+
fail("the consent is recorded in the registry, and CRAFTAR_NO_REGISTRY is set — unset it to allow writes");
|
|
1475
|
+
const home = craftarHome();
|
|
1476
|
+
try {
|
|
1477
|
+
await readRegistry(home);
|
|
1478
|
+
}
|
|
1479
|
+
catch (e) {
|
|
1480
|
+
fail(`cannot read the registry: ${errorText(e)}`);
|
|
1481
|
+
}
|
|
1482
|
+
// a forge: holding credentials is refused here, before anything prints it
|
|
1483
|
+
let merged;
|
|
1484
|
+
try {
|
|
1485
|
+
merged = await readWorkspaceConfig(root);
|
|
1486
|
+
}
|
|
1487
|
+
catch (e) {
|
|
1488
|
+
fail(errorText(e));
|
|
1489
|
+
}
|
|
1490
|
+
// before anything prints or records it (and before the terminal check)
|
|
1491
|
+
if (merged.config.ref && refHoldsCredentials(merged.config.ref)) {
|
|
1492
|
+
fail(`ref holds ${CREDENTIAL_HIDDEN} — remove the credentials before allowing writes`);
|
|
1493
|
+
}
|
|
1494
|
+
if (!(process.stdin.isTTY === true && process.stdout.isTTY === true)) {
|
|
1495
|
+
fail("craftar mcp allow-writes asks a person in a terminal — type it in one; it cannot be run from a script or an AI coder's shell tool");
|
|
1496
|
+
}
|
|
1497
|
+
const realRoot = await fs.realpath(root);
|
|
1498
|
+
const refLine = merged.config.ref ?? "(the Forge's default)";
|
|
1499
|
+
console.log(`craftar mcp allow-writes — ${realRoot}`);
|
|
1500
|
+
console.log(` forge ${merged.config.forge}`);
|
|
1501
|
+
console.log(` ref ${refLine}`);
|
|
1502
|
+
console.log(` profile ${merged.config.profile}`);
|
|
1503
|
+
console.log(` tools sync, add_recipe, remove_recipe — sync never discards a hand edit`);
|
|
1504
|
+
if (!(await confirmWrites(readlineIo(process.stdin, process.stdout, { terminal: true }))))
|
|
1505
|
+
fail("allow-writes cancelled — nothing written");
|
|
1506
|
+
try {
|
|
1507
|
+
const consent = await allowWrites(home, root, {
|
|
1508
|
+
forge: { source: merged.config.forge, ref: merged.config.ref ?? null },
|
|
1509
|
+
profile: merged.config.profile,
|
|
1510
|
+
});
|
|
1511
|
+
console.log(`write consent recorded for ${consent.path} — craftar mcp --allow-writes may sync and edit recipes here; craftar mcp revoke-writes takes it back`);
|
|
1512
|
+
}
|
|
1513
|
+
catch (e) {
|
|
1514
|
+
fail(`consent not recorded: ${errorText(e)}`);
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
mcpCmd
|
|
1518
|
+
.command("revoke-writes")
|
|
1519
|
+
.description("Take back the consent of craftar mcp allow-writes for this workspace; needs no terminal")
|
|
1520
|
+
// No default here: `mcp` holds `--workspace` too, and commander gives the value to the parent when both declare it.
|
|
1521
|
+
.option("--workspace <dir>", 'workspace root (default: ".")')
|
|
1522
|
+
.action(async (o) => {
|
|
1523
|
+
const workspace = o.workspace ?? mcpCmd.opts().workspace ?? ".";
|
|
1524
|
+
if (mcpCmd.opts().fetch)
|
|
1525
|
+
fail("craftar mcp revoke-writes takes only --workspace");
|
|
1526
|
+
if (mcpCmd.opts().allowWrites)
|
|
1527
|
+
fail("craftar mcp revoke-writes takes only --workspace");
|
|
1528
|
+
const root = path.resolve(workspace);
|
|
1529
|
+
if (!(await exists(path.join(root, WORKSPACE_FILE)))) {
|
|
1530
|
+
fail(`no craftar.yaml in ${root} — the consent is given for one workspace; type it in one, or pass --workspace`);
|
|
1531
|
+
}
|
|
1532
|
+
if (registryOff())
|
|
1533
|
+
fail("the consent is recorded in the registry, and CRAFTAR_NO_REGISTRY is set — nothing to revoke here");
|
|
1534
|
+
const home = craftarHome();
|
|
1535
|
+
try {
|
|
1536
|
+
const result = await revokeWrites(home, root);
|
|
1537
|
+
if (result.removed) {
|
|
1538
|
+
console.log(`write consent removed for ${result.path}`);
|
|
1539
|
+
}
|
|
1540
|
+
else {
|
|
1541
|
+
console.log(`no consent recorded for ${result.path}`);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
catch (e) {
|
|
1545
|
+
fail(`consent not removed: ${errorText(e)}`);
|
|
1546
|
+
}
|
|
1547
|
+
});
|
|
1489
1548
|
/* ---------------------------------------------------------------- recipes */
|
|
1490
1549
|
program
|
|
1491
1550
|
.command("recipes")
|
|
@@ -3007,16 +3066,6 @@ function matchSuffix(ws) {
|
|
|
3007
3066
|
suffix += `, pins ${ws.ref}`;
|
|
3008
3067
|
return suffix;
|
|
3009
3068
|
}
|
|
3010
|
-
/** The header line naming a remote Forge (spec 13 §4.1); null for a path Forge, whose headers keep their shape. */
|
|
3011
|
-
function forgeLine(ws, lock) {
|
|
3012
|
-
const o = ws.origin;
|
|
3013
|
-
if (o.kind !== "remote")
|
|
3014
|
-
return null;
|
|
3015
|
-
const commit = ws.forge.commit;
|
|
3016
|
-
const at = o.ref ?? `${o.defaultBranch} (default branch)`;
|
|
3017
|
-
const moved = lock?.forge.commit && commit && lock.forge.commit !== commit ? ` · lock ${lock.forge.commit.slice(0, 8)}` : "";
|
|
3018
|
-
return ` forge ${o.source} @ ${at} ${commit?.slice(0, 8) ?? "no git"}${moved}`;
|
|
3019
|
-
}
|
|
3020
3069
|
/** One line on stderr with the `error: ` prefix, without exiting — `fail()` is this, then exit 1. */
|
|
3021
3070
|
function errorLine(msg) {
|
|
3022
3071
|
console.error(pc.red("error: ") + msg);
|