craftar 0.28.1 → 0.29.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 +50 -8
- package/dist/cli.js +85 -103
- package/dist/cli.js.map +1 -1
- package/dist/core/catalogue.js +5 -0
- package/dist/core/catalogue.js.map +1 -1
- package/dist/core/doctor.js +11 -2
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/drift.js +3 -1
- package/dist/core/drift.js.map +1 -1
- package/dist/core/mcp-protocol.js +228 -0
- package/dist/core/mcp-protocol.js.map +1 -0
- package/dist/core/proc.js +31 -8
- package/dist/core/proc.js.map +1 -1
- package/dist/core/report.js +160 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/services.js +18 -11
- package/dist/core/services.js.map +1 -1
- package/dist/mcp.js +87 -0
- package/dist/mcp.js.map +1 -0
- 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`. Everything else in the spec (`craftar ui
|
|
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 reads only. Everything else in the spec (`craftar ui`) is not built yet.
|
|
6
6
|
|
|
7
7
|
## The idea in one paragraph
|
|
8
8
|
|
|
@@ -82,6 +82,7 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
82
82
|
| `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` — 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. |
|
|
83
83
|
| `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. |
|
|
84
84
|
| `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*. |
|
|
85
|
+
| `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. Nothing is written to the workspace, the lock, `craftar.yaml`, the registry or the Forge; there is no `sync` tool. 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*. |
|
|
85
86
|
| `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}`. |
|
|
86
87
|
| `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. |
|
|
87
88
|
| `craftar recipes [--forge <dir> [--profile <name>] \| --workspace <dir>] [--json]` | Every recipe of the Forge, sorted by name: slot, the parents it extends, the ingredients it lists itself and the profiles that reach it. In a workspace (or with `--forge <dir> --profile <name>`) each recipe says whether it is in use and why — `profile` (listed in `recipes` or selected by the profile's `integrations` block), `workspace` (`recipes.add`), `extends <child>` — and a recipe `recipes.remove` names reads `removed by this workspace`. A context that does not resolve (two recipes on one slot, an unknown recipe, a cycle) still lists everything, marks nothing and warns. Read-only: it resolves, never plans. `--json` is a contract: `{forge, context, recipes, warnings}`. |
|
|
@@ -372,7 +373,7 @@ They are generated files like any other: `status`, `diff`, `sync --check` and `-
|
|
|
372
373
|
|
|
373
374
|
**The plan fails** when a fragment is not a YAML mapping, declares no service, declares a top-level `name` (the project name is craftar's) or `include` (every file of the stack is in the lock) — also when they arrive through a YAML merge key (`<<`): a fragment is checked as Compose reads it; when a service's `extends` names a `file` (it may extend a service of the same fragment), or when two fragments declare the same key under `services`, `volumes`, `networks`, `configs` or `secrets` — Compose would keep one silently. A file not listed in `binary` must be UTF-8 text. **It warns** when two fragments publish the same literal host port, and when a rendered file holds a secret-like value (the kind and the line, never the value): keep values out of the Forge and use `${NAME}`.
|
|
374
375
|
|
|
375
|
-
**Running it.** `craftar services up` runs `docker compose up -d` on the stack, `services down` stops it and `services status` shows it (see *Commands*). They need Docker with its Compose plugin, found on `PATH
|
|
376
|
+
**Running it.** `craftar services up` runs `docker compose up -d` on the stack, `services down` stops it and `services status` shows it (see *Commands*). They need Docker with its Compose plugin, found on `PATH` by the lookup that finds `dotnet` and `git` (see *Project templates*); `up` needs Compose **2.20.0 or later** (the first with `include`), while `down` and `status` work on an older one. `sync` needs no Docker. `up` reads the lock, not the Forge: it comes up on what the last `sync` wrote, and refuses a file that is missing or edited rather than run something the lock does not record. Nothing runs Docker implicitly — not `sync`, not `init`, and `doctor` — the command, or the `doctor` tool of `craftar mcp` — only asks for the version. craftar adds no policy over what a fragment declares: a service runs with your Docker rights, as the hooks and scripts of a Forge run with yours.
|
|
376
377
|
|
|
377
378
|
- **The project** is `craftar-<slug>-<hash>`: the workspace directory's name, lowercased, every character outside `a-z 0-9 _ -` replaced by `-`, cut at 40, then the first 8 hex digits of the SHA-256 of the workspace's real path. It is computed, never written, so the files under `.craftar/` are the same on every machine and two checkouts are two projects. `services status` prints it. Moving a workspace changes it: stop the old one with `docker compose -p <old name> down`. A name that leaves nothing but dashes gives `craftar--<hash>`.
|
|
378
379
|
- **`${CRAFTAR_WORKSPACE}`** is set by `craftar services` to the workspace's real path, so a fragment can mount a repository of the workspace.
|
|
@@ -451,23 +452,64 @@ templates:
|
|
|
451
452
|
A `kind` is a lowercase name (`dotnet-new`); a package is a NuGet ID, optionally with a version (`<id>::<version>`). A `source` is an `https://` URL with a host, in printable ASCII, and no user or password. There is no workspace layer: `craftar.yaml` and `craftar.local.yaml` cannot add or remove a template.
|
|
452
453
|
|
|
453
454
|
- **The store is the user's** — one per machine user, shared by every workspace; the last install of a package ID wins. Remove one with `dotnet new uninstall <package>`.
|
|
454
|
-
- **Feeds are additive** — a declared `source` is added to the feeds `dotnet` already uses, so an ID nobody reserved on a public feed can be served from there. Pin it with the team's own `NuGet.Config` at the workspace root (craftar looks there only, and generates none). `install` and `doctor` warn when an entry declares a `source` and the root has no `NuGet.Config`.
|
|
455
|
+
- **Feeds are additive** — a declared `source` is added to the feeds `dotnet` already uses, so an ID nobody reserved on a public feed can be served from there. Pin it with the team's own `NuGet.Config` at the workspace root (craftar looks there only, and generates none). `install` and `doctor` — the command, or the `doctor` tool of `craftar mcp` — warn when an entry declares a `source` and the root has no `NuGet.Config`.
|
|
455
456
|
- **Private feeds** — authentication is NuGet's (`NuGet.Config`, a credential provider, `--interactive`); craftar holds no token, and a `source` carrying a user or a password is refused and never printed. A `source` is printed as written by `list`, `--dry-run` and `install`: craftar refuses a user or a password in it, not a token in its path or query — keep those out of the profile.
|
|
456
|
-
- **`dotnet` comes from `PATH`** — craftar looks for it itself, in the absolute entries of `PATH` only (on Windows, a `dotnet.exe` or `dotnet.com` in an entry with a drive or a UNC host), and never in the workspace root unless `PATH` names it: a `dotnet` committed in a repository is not run by `install` or by `doctor`'s probe, which
|
|
457
|
+
- **`dotnet` comes from `PATH`** — craftar looks for it itself, in the absolute entries of `PATH` only (on Windows, a `dotnet.exe` or `dotnet.com` in an entry with a drive or a UNC host), and never in the workspace root unless `PATH` names it: a `dotnet` committed in a repository is not run by `install` or by `doctor`'s probe (the command's, or that of the `doctor` tool of `craftar mcp`), all of which run there. An empty or relative entry, an unset `PATH` and a `.cmd` / `.bat` shim are not used, and neither is a `node_modules/.bin` of the directory craftar is started in or of a directory above it — the entries `npx` and `npm` put first. The same lookup finds `git` and `docker`.
|
|
457
458
|
- **Unknown kind** — an entry of a kind this craftar cannot install (`npm-create`, `nx`) is listed, skipped and warned; `install` then exits 1 (`--dry-run` still exits 0).
|
|
458
459
|
|
|
460
|
+
## Craftar inside the AI coder
|
|
461
|
+
|
|
462
|
+
An AI coder working in a generated workspace does not know the files are generated. `craftar mcp` tells it: registered as an MCP server, it lets the agent ask what is pending, why a file exists and what the Forge offers — and say "run `craftar sync`" instead of editing a generated file.
|
|
463
|
+
|
|
464
|
+
Register it by hand, in the AI coder's own MCP configuration — a file `sync` does not write (the user's own, not a generated `.mcp.json` or `.kiro/settings/mcp.json`):
|
|
465
|
+
|
|
466
|
+
```json
|
|
467
|
+
{ "mcpServers": { "craftar": { "command": "craftar", "args": ["mcp"] } } }
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
or for a whole team, as an ordinary `mcp` ingredient in the Forge, which `sync` writes to `.mcp.json` and `.kiro/settings/mcp.json` like any other server:
|
|
471
|
+
|
|
472
|
+
```yaml
|
|
473
|
+
# ingredients/mcp/craftar/ingredient.yaml
|
|
474
|
+
type: mcp
|
|
475
|
+
name: craftar
|
|
476
|
+
server:
|
|
477
|
+
command: craftar
|
|
478
|
+
args: [mcp]
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
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`.
|
|
482
|
+
|
|
483
|
+
**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.
|
|
484
|
+
|
|
459
485
|
## Roadmap (from the functional spec)
|
|
460
486
|
|
|
461
487
|
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.
|
|
462
488
|
|
|
463
|
-
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. Next: cloning a profile's repositories at init.
|
|
489
|
+
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). Next: cloning a profile's repositories at init.
|
|
464
490
|
|
|
465
491
|
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).
|
|
466
492
|
|
|
467
|
-
Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; `craftar ui`; `craftar mcp`
|
|
493
|
+
Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; `craftar ui`; writing tools for `craftar mcp` (`sync`, `add recipe`) and its Claude Code / Agent Plugin packaging.
|
|
468
494
|
|
|
469
495
|
## Upgrading
|
|
470
496
|
|
|
497
|
+
### to 0.29.0
|
|
498
|
+
|
|
499
|
+
- **New command: `craftar mcp`**, a read-only MCP server over stdin and stdout, started by an AI coder (see *Commands* and *Craftar inside the AI coder*). It writes nothing to a workspace, its lock or `craftar.yaml`, the registry or the Forge — the Forge cache is used as every reader uses it — and there is no `sync` tool.
|
|
500
|
+
- **Nothing that exists changes**: no emitted byte, no lock, no Forge schema; `status`, `diff`, `explain`, `recipes`, `ingredients`, `targets` and `drift show` print what they printed before, with the same `--json` keys and exit codes — their builders moved into the core, and their output is pinned by tests.
|
|
501
|
+
- **New tooling contracts**: the seven tool names and their arguments; the `diff` tool's result (`{files: [{path, state, added, removed, diff, note}], warnings, unsetParams}`); the `explain` tool's (`{path, target, ingredient, origin, via, sections, params, profile, recipes, hash, warnings}`); and the `doctor` tool's, which is `doctor --json` narrowed to the workspace's checks plus `node` and `git`, its summary recounted. The `status`, `recipes`, `ingredients` and `targets` tools answer with the shapes of those commands' `--json`, which now bind the tools too.
|
|
502
|
+
- **Upgrade every machine before adding the server to a shared Forge.** A Forge can register `craftar mcp` as an ordinary `mcp` ingredient, and `sync` on any version that reads the Forge writes it to `.mcp.json` / `.kiro/settings/mcp.json` for the targets a workspace uses (its workspaces see `new` or `update` on those files); an AI coder on a machine with a craftar before `0.29.0` then starts `craftar mcp`, an unknown command, and the server fails to start.
|
|
503
|
+
- **The server runs in its client's environment**: the programs it runs (`git`, and for the `doctor` tool `docker` and `dotnet`) are looked for in the `PATH` the client gives it, by the lookup of *to 0.28.2*, and the `forge`, `ref` and `profile` it was started with stay fixed: after one of them changes, every tool but `targets` answers with an error until it is restarted.
|
|
504
|
+
- **A result can carry what the reading commands print** — the names of the Forge's profiles and of the workspace an ingredient was imported from, absolute paths, and the parameter values a generated file holds (at Forge `schema: 4`, also those rendered into an MCP server; a value that looks like a credential still refuses the plan, and the tool then answers with the plan's line, never the value). Register the server only where that is acceptable.
|
|
505
|
+
|
|
506
|
+
### to 0.28.2
|
|
507
|
+
|
|
508
|
+
- **A program is no longer looked for in a `node_modules/.bin` of the current directory or of a directory above it.** `npx craftar` — and an `npm` script — puts those entries first on `PATH`, as absolute ones, so a `git`, a `dotnet` or a `docker` committed there was the program craftar ran. They are now skipped, in the lookup and in the `PATH` the program gets, so what it starts in turn (`ssh`, a credential helper) is not found there either. The current directory is the one craftar is started in, whatever `--workspace` names. The entries are compared as written — `.`, `..` and repeated separators resolved, no link followed, without case on Windows. A `node_modules/.bin` anywhere else on `PATH` (npx's own cache, a global prefix) is looked in as before.
|
|
509
|
+
- **`docker` is now found as `git` and `dotnet` are** (see *to 0.27.2*): in the absolute entries of `PATH` only, never in the directory craftar runs in; on Windows only `docker.exe` / `docker.com`, in an entry with a drive or a UNC host — a `docker.cmd` shim is not a program. `docker` gets the same reduced `PATH` (what `docker` then does with it when it starts a credential helper was not measured). This holds for `craftar services up | down | status` and for the `docker` check of `craftar doctor`.
|
|
510
|
+
- **A program reachable only that way is no longer found**, and craftar then behaves as on a machine without it: for `git`, what *to 0.27.2* describes — a path Forge whose `git` is no longer found is read without its commit, and its next writing `sync` rewrites `craftar.lock` once with `forge.commit: null`; for `dotnet`, `craftar templates install` and `craftar doctor` say the .NET SDK is not available; for `docker`, `craftar services up` and `down` stop with `docker not found — install Docker with the Compose plugin 2.20.0 or later`, `services status` warns the same, shows every declared service as `not created` and exits 1 (`docker.available` is `false` under `--json`), and `doctor` warns `docker not found`. A toolchain that lives only in such a `node_modules/.bin` has to be installed in another directory that `PATH` names: an entry written by hand for that same `node_modules/.bin` is skipped like the one `npx` adds.
|
|
511
|
+
- **No generated file changes**, and no lock changes for a workspace whose programs are found as before.
|
|
512
|
+
|
|
471
513
|
### to 0.28.1
|
|
472
514
|
|
|
473
515
|
- **A Forge URL with credentials that an earlier release recorded is no longer printed from the registry.** A release before `0.27.1` could record such a URL (see *to 0.27.1*). For a `missing` or an `error` row whose recorded URL holds credentials, `craftar workspaces --json` now gives `(a URL with credentials — not shown)` as `forge.source` and `null` as `forge.key`. `craftar cache prune` — its lines and, under `--json`, `kept[].path`, `removed[].path` and `warnings` — and the `cache` check of `craftar doctor` put the same label where the name of the cache entry such a row names would be: `path` then reads `forges/(a URL with credentials — not shown)`, `forges/(a URL with credentials — not shown)/trees/<commit>` or `forges/~removing-(a URL with credentials — not shown)-<pid>`, which is not a path, and two such entries read the same; a failure either command reports with a path inside such an entry names it the same way. No `--json` key is added, removed or renamed, and no type changes: `forge.key` could already be `null` (a path Forge that cannot be resolved); a `remote` row can now carry it too. A row that loads gives what `craftar.yaml` holds, as before.
|
|
@@ -487,10 +529,10 @@ Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`;
|
|
|
487
529
|
|
|
488
530
|
### to 0.27.2
|
|
489
531
|
|
|
490
|
-
- **`git` is looked for in the absolute entries of `PATH` only** — never in the directory craftar runs in, which is a workspace or a Forge, unless `PATH` names that directory by an absolute entry: a `git` committed in a repository is not run through an empty or a relative entry. `npx craftar` is the case to know: npm puts `<cwd>/node_modules/.bin`, and every ancestor's, first on `PATH` as absolute entries, so a `git` committed there is still run, as before. An empty entry (a leading or a trailing `:`, `::`), a relative one (`.`, `bin`, an unexpanded `~/bin`) and an unset `PATH` are not looked in. On Windows only `git.exe` / `git.com` in an entry with a drive or a UNC host count.
|
|
532
|
+
- **`git` is looked for in the absolute entries of `PATH` only** — never in the directory craftar runs in, which is a workspace or a Forge, unless `PATH` names that directory by an absolute entry: a `git` committed in a repository is not run through an empty or a relative entry. `npx craftar` is the case to know: npm puts `<cwd>/node_modules/.bin`, and every ancestor's, first on `PATH` as absolute entries, so a `git` committed there is still run, as before. (Since `0.28.2` those entries are skipped: see *to 0.28.2*.) An empty entry (a leading or a trailing `:`, `::`), a relative one (`.`, `bin`, an unexpanded `~/bin`) and an unset `PATH` are not looked in. On Windows only `git.exe` / `git.com` in an entry with a drive or a UNC host count.
|
|
491
533
|
- **A `git` reachable only that way is no longer found**, and craftar then behaves as on a machine without git: `craftar doctor` says `git not found` (an `error` when the workspace names a remote Forge, a `warn` otherwise); a remote Forge fails; `forge unify` (a `--save-plan` run aside), `forge promote` and `drift promote` refuse, since git is their undo; `forge impact` no longer matches a workspace through a clone's remotes; and a path Forge is read without its commit — headers read `no git`, and `craftar workspaces` cannot say whether that Forge moved.
|
|
492
534
|
- **No generated file changes; `craftar.lock` does, once, for a path Forge whose `git` is no longer found**: the next writing `sync` rewrites it with `forge.commit: null`, and the machine's registry records the same; `status` and `sync --check` stay as they were, and the commit comes back with the first writing `sync` that finds `git` again. No lock changes for a workspace whose `git` is found as before.
|
|
493
|
-
- **`git` and `dotnet` get a `PATH` of absolute entries only** — every `git` craftar runs, and `dotnet` for `craftar templates install` and for `doctor`'s probe; `docker` is started as before — so what they start in turn (`ssh`, a credential helper) is not looked for in the working directory either (measured on Linux for the `ssh` that `git` starts; on Windows what `git` itself searches was not measured, and what `dotnet` starts was not measured on either). By the same filter, a helper, a `git-remote-*` or `git-lfs` that lives only on a relative `PATH` entry — an unexpanded `~/bin` is one — is no longer on the `PATH` that `git` searches (not measured).
|
|
535
|
+
- **`git` and `dotnet` get a `PATH` of absolute entries only** — every `git` craftar runs, and `dotnet` for `craftar templates install` and for `doctor`'s probe; `docker` is started as before (until `0.28.2`) — so what they start in turn (`ssh`, a credential helper) is not looked for in the working directory either (measured on Linux for the `ssh` that `git` starts; on Windows what `git` itself searches was not measured, and what `dotnet` starts was not measured on either). By the same filter, a helper, a `git-remote-*` or `git-lfs` that lives only on a relative `PATH` entry — an unexpanded `~/bin` is one — is no longer on the `PATH` that `git` searches (not measured).
|
|
494
536
|
|
|
495
537
|
### to 0.27.1
|
|
496
538
|
|
package/dist/cli.js
CHANGED
|
@@ -12,18 +12,19 @@ import { classifyForge } from "./core/remote.js";
|
|
|
12
12
|
import { forget, listWorkspaces, prune, register, registryFile } from "./core/registry.js";
|
|
13
13
|
import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState, countStates, countStatesObject, NEXT_SYNC_STATES } from "./core/impact.js";
|
|
14
14
|
import { runDoctor } from "./core/doctor.js";
|
|
15
|
+
import { launchRefusal, pinOf } from "./core/mcp-protocol.js";
|
|
16
|
+
import { serve } from "./mcp.js";
|
|
15
17
|
import { pruneCache } from "./core/cache.js";
|
|
16
18
|
import { resolveHome } from "./core/home-lock.js";
|
|
17
|
-
import { loadWorkspace, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
|
|
18
|
-
import {
|
|
19
|
-
import { catalogueContext, listRecipes, listIngredients, checkType } from "./core/catalogue.js";
|
|
20
|
-
import { listTargets } from "./core/capabilities.js";
|
|
19
|
+
import { loadWorkspace, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
|
|
20
|
+
import { sectionKey } from "./core/resolve.js";
|
|
21
|
+
import { catalogueContext, listRecipes, listIngredients, checkType, withLoadWarnings } from "./core/catalogue.js";
|
|
21
22
|
import { canonicalValue } from "./core/sections.js";
|
|
22
23
|
import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
|
|
23
24
|
import { preferredLine, renderImportReport } from "./core/import-report.js";
|
|
24
25
|
import { driftList, unmanaged } from "./core/drift.js";
|
|
26
|
+
import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, explainSkip, notProduced, readText, statusPath, statusReport, targetsReport } from "./core/report.js";
|
|
25
27
|
import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
|
|
26
|
-
import { hashNormalized, toLf, stripBom } from "./core/text.js";
|
|
27
28
|
import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
|
|
28
29
|
import { applyBasePromotion, planBasePromotion, proveBasePromotion } from "./core/promote.js";
|
|
29
30
|
import { fingerprintDir } from "./core/fingerprint.js";
|
|
@@ -37,8 +38,7 @@ import { localKeys, readLocalFile } from "./core/workspace-yaml.js";
|
|
|
37
38
|
import { HUNK_CLASSES, INGREDIENT_TYPES, UnifyPlanSchema } from "./schema/index.js";
|
|
38
39
|
import { EXAMPLE_SETTINGS } from "./emitters/shared.js";
|
|
39
40
|
import { YamlSyntaxError, parseYamlText } from "./core/yaml-read.js";
|
|
40
|
-
import {
|
|
41
|
-
import { recordedServices, pendingLine, upRefusal, composeArgv, downArgv, readPs, dockerVersion, dockerFault, dockerRunner, projectName, statusReport } from "./core/services.js";
|
|
41
|
+
import { recordedServices, pendingLine, upRefusal, composeArgv, downArgv, readPs, dockerVersion, dockerFault, dockerRunner, projectName, statusReport as servicesStatusReport } from "./core/services.js";
|
|
42
42
|
import { errorText } from "./core/schema-fault.js";
|
|
43
43
|
import { declaredTemplates, DOTNET_MISSING, DOTNET_MISSING_FIX, dotnetVersion, feedMessage, installReport, installTemplates, profileLabel, templateRows, unpinnedSources } from "./core/templates.js";
|
|
44
44
|
import { capture, spawnInherited } from "./core/proc.js";
|
|
@@ -51,7 +51,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
|
51
51
|
*/
|
|
52
52
|
const DIFF_EXIT_CODE_FETCH_MODE = "sync";
|
|
53
53
|
const program = new Command();
|
|
54
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
54
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.29.0");
|
|
55
55
|
const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
|
|
56
56
|
const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
|
|
57
57
|
program
|
|
@@ -149,9 +149,10 @@ program
|
|
|
149
149
|
const p = await plan(ws);
|
|
150
150
|
const lock = await readLock(ws.root);
|
|
151
151
|
const st = await status(ws, p, lock);
|
|
152
|
-
const
|
|
152
|
+
const report = statusReport(ws, p, lock, st);
|
|
153
|
+
const unset = report.unsetParams;
|
|
153
154
|
if (o.json) {
|
|
154
|
-
console.log(JSON.stringify(
|
|
155
|
+
console.log(JSON.stringify(report, null, 2));
|
|
155
156
|
// exitCode, not process.exit: a piped object is not cut (spec 29 §4.1)
|
|
156
157
|
if (unset.length)
|
|
157
158
|
process.exitCode = 1;
|
|
@@ -407,11 +408,9 @@ program
|
|
|
407
408
|
for (const s of st) {
|
|
408
409
|
if (only && s.path !== only)
|
|
409
410
|
continue;
|
|
410
|
-
// Spec 19 §3.2:
|
|
411
|
-
if (
|
|
412
|
-
|
|
413
|
-
shown++;
|
|
414
|
-
await printFileDiff(ws.root, s);
|
|
411
|
+
// Spec 19 §3.2: the six states sync --check refuses are shown; which those are is diffReport's to say
|
|
412
|
+
if (await printFileDiff(ws.root, s))
|
|
413
|
+
shown++;
|
|
415
414
|
}
|
|
416
415
|
if (!shown)
|
|
417
416
|
console.log(pc.green("no differences"));
|
|
@@ -425,36 +424,31 @@ program
|
|
|
425
424
|
const DIFF_PAINT = { paint: { same: pc.dim, del: pc.red, add: pc.green } };
|
|
426
425
|
/** What `craftar diff` prints for one file `sync --check` refuses; `drift show <path>` prints the same for a drifted one. */
|
|
427
426
|
async function printFileDiff(root, s) {
|
|
427
|
+
// What to show is decided in the core (`diffReport`); the headers and the paint are this command's.
|
|
428
|
+
const [row] = await diffReport({ root }, [s], { withholdMcp: false });
|
|
429
|
+
// Nothing to show for `unchanged` and `adopt`: says so, for the caller's count
|
|
430
|
+
if (!row)
|
|
431
|
+
return false;
|
|
428
432
|
if (s.state === "orphan-drift") {
|
|
429
433
|
// Header and one line, no body: sync keeps this file
|
|
430
434
|
console.log(pc.bold(`--- ${s.path} (disk, orphan-drift)`));
|
|
431
|
-
console.log(` ${explainSkip(s)}`);
|
|
432
|
-
return;
|
|
433
435
|
}
|
|
434
|
-
|
|
435
|
-
if (s.state === "orphan") {
|
|
436
|
+
else if (s.state === "orphan") {
|
|
436
437
|
// A removal: the file exists on disk but the Forge no longer produces it
|
|
437
438
|
console.log(pc.bold(`--- ${s.path} (disk, orphan)`));
|
|
438
439
|
console.log(pc.bold(`+++ ${s.path} (forge: no longer produced — sync removes it)`));
|
|
439
|
-
console.log(renderDiff(disk ?? "", "", DIFF_PAINT));
|
|
440
440
|
}
|
|
441
441
|
else {
|
|
442
|
-
const next = s.planned ? toLf(stripBom(s.planned.content.toString("utf8"))) : "";
|
|
443
442
|
console.log(pc.bold(`--- ${s.path} (disk, ${s.state})`));
|
|
444
443
|
console.log(pc.bold(`+++ ${s.path} (forge)`));
|
|
445
|
-
// Spec 27 §4.2, Ruling 4: do not print on-disk content of the example file for drift/collision
|
|
446
|
-
if (s.path === EXAMPLE_SETTINGS && (s.state === "drift" || s.state === "collision")) {
|
|
447
|
-
console.log(EXAMPLE_CONTENT_HIDDEN);
|
|
448
|
-
}
|
|
449
|
-
else {
|
|
450
|
-
console.log(renderDiff(disk ?? "", next, DIFF_PAINT));
|
|
451
|
-
}
|
|
452
444
|
}
|
|
445
|
+
// Spec 27 §4.2, Ruling 4: a note stands in for the on-disk content of the example file in drift / collision
|
|
446
|
+
if (row.note !== null)
|
|
447
|
+
console.log(` ${row.note}`);
|
|
448
|
+
else
|
|
449
|
+
console.log(renderDiff(row.disk ?? "", row.forge ?? "", DIFF_PAINT));
|
|
450
|
+
return true;
|
|
453
451
|
}
|
|
454
|
-
/** Spec 27 Ruling 4: the on-disk content of the example file is never printed. */
|
|
455
|
-
const EXAMPLE_CONTENT_HIDDEN = " content not shown: an example file may hold a value typed by hand";
|
|
456
|
-
/** A path as the user may type it, to the spelling `status` prints: forward slashes, no leading `./`. */
|
|
457
|
-
const statusPath = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
458
452
|
/* ---------------------------------------------------------------- drift */
|
|
459
453
|
const drift = program.command("drift").description("Hand-edited generated files: show them, discard the edit, or promote it to the Forge");
|
|
460
454
|
drift
|
|
@@ -522,7 +516,7 @@ drift
|
|
|
522
516
|
console.log(pc.bold(`+++ ${named.path} (forge: no longer produced — \`craftar drift discard\` removes it)`));
|
|
523
517
|
// Spec 27 Ruling 4: the on-disk content of the example file is never printed.
|
|
524
518
|
if (named.path === EXAMPLE_SETTINGS) {
|
|
525
|
-
console.log(EXAMPLE_CONTENT_HIDDEN);
|
|
519
|
+
console.log(` ${EXAMPLE_CONTENT_HIDDEN}`);
|
|
526
520
|
}
|
|
527
521
|
else {
|
|
528
522
|
console.log(renderDiff((await readText(path.join(ws.root, named.path))) ?? "", "", DIFF_PAINT));
|
|
@@ -820,29 +814,26 @@ program
|
|
|
820
814
|
const ws = await loadWorkspace(o.workspace, load(o, "read"));
|
|
821
815
|
warnStderr(ws.warnings);
|
|
822
816
|
const p = await plan(ws);
|
|
823
|
-
const f =
|
|
817
|
+
const f = explainFile(ws, p, file.replace(/\\/g, "/"));
|
|
824
818
|
if (!f)
|
|
825
|
-
fail(
|
|
826
|
-
const ing = p.resolution.ingredients.find((i) => i.ref === f.ingredient);
|
|
819
|
+
fail(notProduced(file, ws.config.profile));
|
|
827
820
|
console.log(pc.bold(f.path));
|
|
828
821
|
console.log(` target ${f.target}`);
|
|
829
|
-
console.log(` ingredient ${f.ingredient}${
|
|
830
|
-
if (
|
|
831
|
-
console.log(` via recipes ${
|
|
822
|
+
console.log(` ingredient ${f.ingredient}${f.origin ? pc.dim(` (imported from ${f.origin.workspace}:${f.origin.path})`) : ""}`);
|
|
823
|
+
if (f.via)
|
|
824
|
+
console.log(` via recipes ${f.via.join(" → ")}`);
|
|
832
825
|
// Spec 11 §4.2 (Ruling 15): which layer filled each section. AGENTS.md (rule/*) is not one ingredient.
|
|
833
|
-
|
|
834
|
-
if (sections?.length) {
|
|
826
|
+
if (f.sections.length) {
|
|
835
827
|
const layer = (l) => (l === "profile" ? `profile ${p.resolution.profile.name}` : l);
|
|
836
|
-
console.log(` sections ${sections.map((x) => `${x.name} (${layer(x.layer)})`).join(", ")}`);
|
|
828
|
+
console.log(` sections ${f.sections.map((x) => `${x.name} (${layer(x.layer)})`).join(", ")}`);
|
|
837
829
|
}
|
|
838
830
|
// Spec 29 §4.1: which layer filled each parameter the file's ingredient cites — or `unset`.
|
|
839
|
-
|
|
840
|
-
if (params?.length) {
|
|
831
|
+
if (f.params.length) {
|
|
841
832
|
const layer = (l) => (typeof l === "string" ? l : "recipe" in l ? `recipe ${l.recipe}` : `profile ${l.profile}`);
|
|
842
|
-
console.log(` params ${params.map((x) => `${x.key} (${layer(x.layer)})`).join(", ")}`);
|
|
833
|
+
console.log(` params ${f.params.map((x) => `${x.key} (${layer(x.layer)})`).join(", ")}`);
|
|
843
834
|
}
|
|
844
|
-
console.log(` profile ${
|
|
845
|
-
console.log(` hash ${
|
|
835
|
+
console.log(` profile ${f.profile} (recipes: ${f.recipes.join(", ")})`);
|
|
836
|
+
console.log(` hash ${f.hash}`);
|
|
846
837
|
});
|
|
847
838
|
/* ---------------------------------------------------------------- forge ls */
|
|
848
839
|
program
|
|
@@ -1030,6 +1021,37 @@ templates
|
|
|
1030
1021
|
if (report.exitCode)
|
|
1031
1022
|
process.exitCode = 1;
|
|
1032
1023
|
});
|
|
1024
|
+
/* ---------------------------------------------------------------- mcp */
|
|
1025
|
+
program
|
|
1026
|
+
.command("mcp")
|
|
1027
|
+
.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")
|
|
1028
|
+
.option("--workspace <dir>", "the workspace to serve", ".")
|
|
1029
|
+
.option("--fetch", "let a tool call fetch a remote Forge instead of reading the cached copy", false)
|
|
1030
|
+
.action(async (o) => {
|
|
1031
|
+
// The bound workspace, resolved once: no tool takes a directory (spec 35 §4.1).
|
|
1032
|
+
const root = path.resolve(o.workspace);
|
|
1033
|
+
const refusal = launchRefusal(process.stdin.isTTY === true, await exists(path.join(root, WORKSPACE_FILE)), root);
|
|
1034
|
+
if (refusal !== null)
|
|
1035
|
+
fail(refusal);
|
|
1036
|
+
// The configuration must read at launch — its forge, ref and profile are the pin. The Forge need not load: each call says so.
|
|
1037
|
+
let merged;
|
|
1038
|
+
try {
|
|
1039
|
+
merged = await readWorkspaceConfig(root);
|
|
1040
|
+
}
|
|
1041
|
+
catch (e) {
|
|
1042
|
+
fail(errorText(e));
|
|
1043
|
+
}
|
|
1044
|
+
// From here stdout carries protocol lines only, and stderr nothing (spec 35 §4.1, §7). Stdin ending ends the process, exit 0.
|
|
1045
|
+
await serve(process.stdin, process.stdout, {
|
|
1046
|
+
root,
|
|
1047
|
+
fetch: o.fetch,
|
|
1048
|
+
home: craftarHome(),
|
|
1049
|
+
version: program.version() ?? "unknown",
|
|
1050
|
+
registryOff: registryOff(),
|
|
1051
|
+
env: process.env,
|
|
1052
|
+
pin: pinOf(merged),
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1033
1055
|
/* ---------------------------------------------------------------- recipes */
|
|
1034
1056
|
program
|
|
1035
1057
|
.command("recipes")
|
|
@@ -1060,7 +1082,7 @@ program
|
|
|
1060
1082
|
const r = listRecipes(forg, context);
|
|
1061
1083
|
// Loading the workspace may warn (spec 13 §4.2); those come first, as in `plan()`.
|
|
1062
1084
|
if (workspace)
|
|
1063
|
-
r
|
|
1085
|
+
withLoadWarnings(r, workspace.warnings);
|
|
1064
1086
|
if (o.json) {
|
|
1065
1087
|
return console.log(JSON.stringify(r, null, 2));
|
|
1066
1088
|
}
|
|
@@ -1185,7 +1207,7 @@ program
|
|
|
1185
1207
|
fail(e instanceof Error ? e.message : String(e));
|
|
1186
1208
|
}
|
|
1187
1209
|
if (workspace)
|
|
1188
|
-
result
|
|
1210
|
+
withLoadWarnings(result, workspace.warnings);
|
|
1189
1211
|
if (o.json) {
|
|
1190
1212
|
return console.log(JSON.stringify(result, null, 2));
|
|
1191
1213
|
}
|
|
@@ -1345,36 +1367,32 @@ program
|
|
|
1345
1367
|
.option("--workspace <dir>", "workspace whose targets to mark (default: . when it holds a craftar.yaml)")
|
|
1346
1368
|
.option("--json", "machine-readable output", false)
|
|
1347
1369
|
.action(async (o) => {
|
|
1348
|
-
let inUse = null;
|
|
1349
|
-
const warnings = [];
|
|
1350
1370
|
const root = o.workspace ?? ".";
|
|
1371
|
+
let input = null;
|
|
1351
1372
|
if (!(await exists(path.join(root, WORKSPACE_FILE)))) {
|
|
1352
|
-
// craftar.yaml
|
|
1373
|
+
// Explicit --workspace without craftar.yaml: exit 1 with the load's own message. No --workspace: unmarked, no warning.
|
|
1353
1374
|
if (o.workspace !== undefined) {
|
|
1354
|
-
// Explicit --workspace: exit 1 with loadWorkspace's message
|
|
1355
1375
|
try {
|
|
1356
|
-
await
|
|
1376
|
+
await readWorkspaceConfig(root);
|
|
1357
1377
|
}
|
|
1358
1378
|
catch (e) {
|
|
1359
1379
|
fail(e instanceof Error ? e.message : String(e));
|
|
1360
1380
|
}
|
|
1361
1381
|
}
|
|
1362
|
-
// No --workspace: just show unmarked, no warning
|
|
1363
1382
|
}
|
|
1364
1383
|
else {
|
|
1365
|
-
// craftar.yaml exists,
|
|
1384
|
+
// craftar.yaml exists: a configuration that does not read is a warning, as a Forge that does not load is
|
|
1366
1385
|
try {
|
|
1367
|
-
|
|
1368
|
-
inUse = resolve(ws.forge, ws.config).targets;
|
|
1386
|
+
input = { merged: await readWorkspaceConfig(root) };
|
|
1369
1387
|
}
|
|
1370
1388
|
catch (e) {
|
|
1371
|
-
|
|
1372
|
-
warnings.push(`targets in use not shown: ${e instanceof Error ? e.message : String(e)}`);
|
|
1389
|
+
input = { reason: e instanceof Error ? e.message : String(e) };
|
|
1373
1390
|
}
|
|
1374
1391
|
}
|
|
1375
|
-
const result =
|
|
1392
|
+
const result = await targetsReport(root, { home: load({}, "no-fetch").home }, input);
|
|
1393
|
+
const warnings = result.warnings;
|
|
1376
1394
|
if (o.json) {
|
|
1377
|
-
return console.log(JSON.stringify(
|
|
1395
|
+
return console.log(JSON.stringify(result, null, 2));
|
|
1378
1396
|
}
|
|
1379
1397
|
// Text mode
|
|
1380
1398
|
const targetLine = result.targets
|
|
@@ -2256,13 +2274,10 @@ async function envFileOrNull(root) {
|
|
|
2256
2274
|
const env = path.join(root, ".env");
|
|
2257
2275
|
return (await exists(env)) ? env : null;
|
|
2258
2276
|
}
|
|
2259
|
-
/** Runs `docker compose` with the terminal's own streams and resolves with its exit code. No shell. */
|
|
2260
|
-
function runCompose(argv, real) {
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
child.on("error", reject);
|
|
2264
|
-
child.on("close", (code) => resolve(code ?? 1));
|
|
2265
|
-
});
|
|
2277
|
+
/** Runs `docker compose` with the terminal's own streams and resolves with its exit code. No shell; `docker` is found as git and dotnet are. */
|
|
2278
|
+
async function runCompose(argv, real) {
|
|
2279
|
+
const { code } = await spawnInherited({ file: "docker", args: argv, cwd: process.cwd(), env: { ...process.env, CRAFTAR_WORKSPACE: real }, stdin: "inherit" });
|
|
2280
|
+
return code ?? 1;
|
|
2266
2281
|
}
|
|
2267
2282
|
services
|
|
2268
2283
|
.command("up")
|
|
@@ -2332,7 +2347,7 @@ services
|
|
|
2332
2347
|
else
|
|
2333
2348
|
psFault = `docker compose ps failed — ${r.stderr.trim().split("\n")[0] || `exit ${r.code}`}`;
|
|
2334
2349
|
}
|
|
2335
|
-
const report =
|
|
2350
|
+
const report = servicesStatusReport(real, recorded, docker, ps, psFault);
|
|
2336
2351
|
if (o.json)
|
|
2337
2352
|
console.log(JSON.stringify(report, null, 2));
|
|
2338
2353
|
else {
|
|
@@ -2558,19 +2573,6 @@ function forgeLine(ws, lock) {
|
|
|
2558
2573
|
const moved = lock?.forge.commit && commit && lock.forge.commit !== commit ? ` · lock ${lock.forge.commit.slice(0, 8)}` : "";
|
|
2559
2574
|
return ` forge ${o.source} @ ${at} ${commit?.slice(0, 8) ?? "no git"}${moved}`;
|
|
2560
2575
|
}
|
|
2561
|
-
/** `status --json`'s `forge` key, for every Forge (spec 13 §4.1). */
|
|
2562
|
-
function forgeJson(ws, lock) {
|
|
2563
|
-
const o = ws.origin;
|
|
2564
|
-
return {
|
|
2565
|
-
kind: o.kind,
|
|
2566
|
-
source: o.source,
|
|
2567
|
-
ref: o.ref,
|
|
2568
|
-
defaultBranch: o.defaultBranch,
|
|
2569
|
-
commit: ws.forge.commit,
|
|
2570
|
-
lockCommit: lock?.forge.commit ?? null,
|
|
2571
|
-
fetched: o.fetched,
|
|
2572
|
-
};
|
|
2573
|
-
}
|
|
2574
2576
|
/** One line on stderr with the `error: ` prefix, without exiting — `fail()` is this, then exit 1. */
|
|
2575
2577
|
function errorLine(msg) {
|
|
2576
2578
|
console.error(pc.red("error: ") + msg);
|
|
@@ -2616,26 +2618,6 @@ function color(state) {
|
|
|
2616
2618
|
return pc.dim;
|
|
2617
2619
|
}
|
|
2618
2620
|
}
|
|
2619
|
-
function explainSkip(s) {
|
|
2620
|
-
switch (s.state) {
|
|
2621
|
-
case "drift":
|
|
2622
|
-
return "hand-edited since last sync — `craftar drift show <path>`, then `craftar drift discard` or `craftar drift promote`";
|
|
2623
|
-
case "collision":
|
|
2624
|
-
return "exists but was never generated by craftar and differs from the Forge — rename it or import it";
|
|
2625
|
-
case "orphan-drift":
|
|
2626
|
-
return "no longer produced by the Forge but hand-edited — kept; `craftar drift discard <path>` removes it, or delete it yourself";
|
|
2627
|
-
default:
|
|
2628
|
-
return "";
|
|
2629
|
-
}
|
|
2630
|
-
}
|
|
2631
|
-
async function readText(p) {
|
|
2632
|
-
try {
|
|
2633
|
-
return toLf(stripBom(await fs.readFile(p, "utf8")));
|
|
2634
|
-
}
|
|
2635
|
-
catch {
|
|
2636
|
-
return null;
|
|
2637
|
-
}
|
|
2638
|
-
}
|
|
2639
2621
|
/**
|
|
2640
2622
|
* The real path of `abs`, resolved through its nearest existing ancestor: `fs.realpath` needs the
|
|
2641
2623
|
* path to exist, and a plan target usually does not yet — so the part that exists is resolved
|