craftar 0.13.0 → 0.15.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 +27 -3
- package/dist/cli.js +157 -39
- package/dist/cli.js.map +1 -1
- package/dist/core/doctor.js +362 -0
- package/dist/core/doctor.js.map +1 -0
- package/dist/core/init.js +86 -0
- package/dist/core/init.js.map +1 -0
- package/dist/core/registry.js +27 -1
- package/dist/core/registry.js.map +1 -1
- package/dist/core/remote.js +68 -15
- package/dist/core/remote.js.map +1 -1
- package/dist/core/sync.js +41 -7
- package/dist/core/sync.js.map +1 -1
- package/dist/core/workspace-yaml.js +27 -0
- package/dist/core/workspace-yaml.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Craft, sync and convert AI-coding workspace harnesses — rules, agents, commands, skills, MCP servers — across every client workspace you maintain and every AI coder each team uses.
|
|
4
4
|
|
|
5
|
-
> Status: **phase 0 prototype**. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`<!-- craftar:section <name> -->` … `<!-- /craftar:section -->`), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). Everything else in the spec (profiles with IDP/PM-tool integrations, local services, `dotnet new` project templates, `craftar ui`, `craftar mcp`) is not built yet.
|
|
5
|
+
> Status: **phase 0 prototype**. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`<!-- craftar:section <name> -->` … `<!-- /craftar:section -->`), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. Everything else in the spec (profiles with IDP/PM-tool integrations, local services, `dotnet new` project templates, `craftar ui`, `craftar mcp`) is not built yet.
|
|
6
6
|
|
|
7
7
|
## The idea in one paragraph
|
|
8
8
|
|
|
@@ -27,6 +27,17 @@ node bin/craftar.js --help
|
|
|
27
27
|
npx tsx src/cli.ts --help
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
## Quick start: start an empty workspace from a Forge
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Writes craftar.yaml (forge, profile) once it resolves and plans, then runs the first sync.
|
|
34
|
+
craftar init --workspace C:/Projects/acme-portal-workspace \
|
|
35
|
+
--forge C:/Projects/forge \
|
|
36
|
+
--profile acme-portal
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The workspace follows the profile's recipes and targets; `--add-recipe` / `--remove-recipe` adjust the recipes, and `--targets` writes the targets into `craftar.yaml`. When the directory already holds a harness, files equal to the Forge's are adopted and different ones are left as they are and reported — `craftar import` (below) brings them into the Forge.
|
|
40
|
+
|
|
30
41
|
## Quick start: bring an existing workspace into a Forge
|
|
31
42
|
|
|
32
43
|
```bash
|
|
@@ -52,6 +63,7 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
52
63
|
|
|
53
64
|
| Command | What it does |
|
|
54
65
|
|---|---|
|
|
66
|
+
| `craftar init --forge <dir\|url> --profile <name> [--ref <ref>] [--targets <a,b>] [--add-recipe <name>]... [--remove-recipe <name>]... [--replace] [--no-sync] [--offline] [--workspace .]` | Starts a workspace: writes a `craftar.yaml` holding `forge` (a directory as the path from the workspace, POSIX; a URL as given), `ref` (a remote Forge only), `profile`, `recipes` (only the lists the recipe flags leave non-empty) and `targets` (only with `--targets`; otherwise the workspace follows the profile's), then runs the first `sync` — which also registers the workspace. The file is written only once the configuration loads (a remote Forge is fetched as `sync` fetches it; `--offline` reads the cache), resolves and plans, and the sync applies that same plan. `--remove-recipe` and `--add-recipe` go through `remove recipe` / `add recipe`'s core (removes first, then adds; `--replace` swaps a slot holder); a call whose every name changes nothing prints its reasons as a `note`. `--workspace` is created when missing. Files already on disk and not in a lock are adopted when equal to the plan and skipped as a `collision` otherwise, never overwritten, with one warning pointing at `craftar import`. `--no-sync` writes `craftar.yaml` only and prints what the first `sync` would do. Refused, with nothing written and no directory created: an existing `craftar.yaml`; a `--workspace` that is a file; credentials in `--forge` (the value is never printed); `--ref` with a directory Forge; an unknown target; a recipe both added and removed, or any refusal of `add recipe` / `remove recipe`; a `craftar.local.yaml` that sets `forge` or `profile`, or a key a given flag would write (`ref`, `recipes`, `targets`) — its value would replace the flag's; a Forge, profile or plan that does not load. |
|
|
55
67
|
| `craftar import --from claude-code --forge <dir> --profile <name> [--workspace .] [--write-config]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering). Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `<profile>-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml`. A reused base takes the place its `<name>--<profile>` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `<name>--<profile>` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `<recipe>--<profile>`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections <names>` when a section value filled the render), `inferred`, `sectioned`, `param <key>: <old> → <new>`, `section <key> <name>: <old> → <new>`, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. It refuses, with the Forge untouched, a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, and a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand). Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. |
|
|
56
68
|
| `craftar status` | With a remote Forge, the header's second line names it — `forge <url> @ <ref \| <branch> (default branch)> <commit>`, plus `· lock <commit>` when the Forge moved since the last sync — and `--json` carries `forge: {kind, source, ref, defaultBranch, commit, lockCommit, fetched}` for every Forge. Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in an emitted file not every target renders as text (copied as they are). |
|
|
57
69
|
| `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock` (schema 2: the Forge's source, requested `ref` and commit, the resolved recipes and targets — rewritten only when that content changes). With a remote Forge it fetches first and exits 1 when the fetch fails, `--dry-run` and `--check` included. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default). A writing `sync` (not `--dry-run`, not `--check`) also records the workspace in `$CRAFTAR_HOME/registry.json` (see *Workspace registry*); a registry it cannot write is a warning, never a failed `sync`. |
|
|
@@ -59,6 +71,7 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
59
71
|
| `craftar explain <path>` | Which ingredient, recipe chain, target and origin produced a file and, for an ingredient with sections, which layer filled each one: `sections flavors (profile globex), extra (default)` (`default`, `profile <p>` or `workspace`). `AGENTS.md` gets no sections line: it is not one ingredient. |
|
|
60
72
|
| `craftar ls` | Recipes and ingredients resolved for this workspace. |
|
|
61
73
|
| `craftar workspaces [--fetch] [--json]` | Every workspace a writing `sync` registered on this machine, sorted by path: name, path, profile, stack (`slot=recipe`), targets, the Forge, the last sync, and a status computed now — `up to date`, `outdated` (`new`, `update` or `orphan` pending), `drift` (a `drift`, `orphan-drift` or `collision`), `no lock`, `missing` (directory or `craftar.yaml` gone) or `error` (it no longer loads, plans or reads its lock), plus `forge moved` when the Forge's commit differs from the lock's. Read without the network: a remote Forge comes from the cache; `--fetch` fetches first. Each row's warnings print after the table, prefixed with its path. Times are UTC. Writes nothing. Exits 0 whenever the table prints. Takes no `--workspace`. `--json` is a contract: `{registry, fetch, workspaces, warnings}`. |
|
|
74
|
+
| `craftar doctor [--workspace <dir>] [--fetch] [--strict] [--json]` | Checks this machine — Node ≥ 22, `git`, `$CRAFTAR_HOME`, the registry (entries whose workspace is gone), the Forge cache (its size, entries nothing names, a fetch that never completed) — and, when run inside one (the current directory, or `--workspace`), this workspace: the configuration and its Forge (cached, fetched or pinned), whether the workspace sits inside its Forge, the plan's warnings, declared parameters with no value, the `${NAME}` variables a written MCP server expects and this environment lacks (names only, never values), the lock, `status`, and registration. One line per finding — `ok`, `warn` or `error` — each problem with the command that fixes it; each cause is reported once. It only reports: nothing is written to a workspace, a lock or the registry, and the Forge cache is used as every reader uses it (`.used` stamps, a rebuilt tree; under `--fetch`, a fetch and the cleanup of trees unused for 14 days). Read without the network; `--fetch` fetches a remote Forge first. Exits 1 on an `error`, or on a `warn` under `--strict`; an explicit `--workspace` without `craftar.yaml` exits 1, while the current directory without one runs the machine checks only. `--json` is a contract: `{version, workspace, fetch, strict, checks: [{id, scope, level, message, fix}], summary}`. |
|
|
62
75
|
| `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. |
|
|
63
76
|
| `craftar recipes [--forge <dir> [--profile <name>] \| --workspace <dir>] [--json]` | Every recipe of the Forge, sorted by name: slot, the parents it extends, the ingredients it lists itself and the profiles that reach it. In a workspace (or with `--forge <dir> --profile <name>`) each recipe says whether it is in use and why — `profile`, `workspace` (`recipes.add`), `extends <child>` — and a recipe `recipes.remove` names reads `removed by this workspace`. A context that does not resolve (two recipes on one slot, an unknown recipe, a cycle) still lists everything, marks nothing and warns. Read-only: it resolves, never plans. `--json` is a contract: `{forge, context, recipes, warnings}`. |
|
|
64
77
|
| `craftar ingredients [--recipe <name>] [--type <type>] [--forge <dir> [--profile <name>] \| --workspace <dir>] [--json]` | Every ingredient of the Forge, grouped by type: output name, targets, the recipes that list it, and whether the context uses it or the workspace disables it; a reference a recipe makes to an ingredient the Forge does not hold is listed under `missing`, never dropped. `--recipe <name>` lists what choosing that recipe brings, with the parents it extends, parents first; two of them on one slot is a warning. `--type` keeps one type. Read-only. `--json` is a contract: `{forge, context, recipe, ingredients, missing, warnings}`. |
|
|
@@ -275,11 +288,22 @@ A writing `sync` records the workspace in `$CRAFTAR_HOME/registry.json` (`~/.cra
|
|
|
275
288
|
|
|
276
289
|
## Roadmap (from the functional spec)
|
|
277
290
|
|
|
278
|
-
Phase 0 (this): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock); the workspace registry and `craftar workspaces`.
|
|
279
|
-
Next: `craftar init`
|
|
291
|
+
Phase 0 (this): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock); the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`.
|
|
292
|
+
Next: an interactive `craftar init` (choose the Forge and the profile); cloning a profile's repositories at init; profile-driven integrations (PM tool → MCP, IDP → MCP); `service` ingredients with compose fragments (`craftar services up`); `dotnet new` template registration; rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; `craftar ui`; `craftar mcp` + Claude Code / Agent Plugin packaging.
|
|
280
293
|
|
|
281
294
|
## Upgrading
|
|
282
295
|
|
|
296
|
+
### to 0.15.0
|
|
297
|
+
|
|
298
|
+
- **New command `craftar doctor`**: reports, one line per finding, what is off on this machine and in this workspace. Its `--json` output is a tooling contract, like `status --json`.
|
|
299
|
+
- **No byte change**: no emitter, `plan` output, `status` or lock changed. No workspace sees `update`.
|
|
300
|
+
|
|
301
|
+
### to 0.14.0
|
|
302
|
+
|
|
303
|
+
- **New command `craftar init`**: writes `craftar.yaml` in a workspace that has none, then runs the first `sync`. It never edits an existing `craftar.yaml`.
|
|
304
|
+
- **A missing `craftar.yaml`** is now reported with `craftar init --workspace "<dir>" --forge <dir> --profile <name>` first and `craftar import … --write-config` second, by every command that reads the workspace. Only the message changes; the exit code does not.
|
|
305
|
+
- **No byte change**: no emitter, `plan`, `status`, `sync` or lock changed. No workspace sees `update`.
|
|
306
|
+
|
|
283
307
|
### to 0.13.0
|
|
284
308
|
|
|
285
309
|
- **New commands `craftar add recipe` and `craftar remove recipe`**: they edit `recipes.add` / `recipes.remove` in `craftar.yaml` and nothing else; they never sync.
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import YAML from "yaml";
|
|
|
6
6
|
import { importClaudeCode } from "./importers/claude-code.js";
|
|
7
7
|
import { classifyForge } from "./core/remote.js";
|
|
8
8
|
import { forget, listWorkspaces, prune, register, registryFile } from "./core/registry.js";
|
|
9
|
+
import { runDoctor } from "./core/doctor.js";
|
|
9
10
|
import { resolveHome } from "./core/home-lock.js";
|
|
10
11
|
import { loadWorkspace, plan, readLock, status, apply, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
|
|
11
12
|
import { resolve, sectionKey } from "./core/resolve.js";
|
|
@@ -19,8 +20,9 @@ import { exists, gitDirty, gitIsRepo, gitUnheld } from "./core/forge.js";
|
|
|
19
20
|
import { fingerprintDir } from "./core/fingerprint.js";
|
|
20
21
|
import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, } from "./core/unify.js";
|
|
21
22
|
import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
|
|
23
|
+
import { planInit } from "./core/init.js";
|
|
22
24
|
import { editRecipesText, planRecipeEdit, recipeDiffLine } from "./core/recipe-edit.js";
|
|
23
|
-
import {
|
|
25
|
+
import { localKeys } from "./core/workspace-yaml.js";
|
|
24
26
|
import { HUNK_CLASSES, INGREDIENT_TYPES, UnifyPlanSchema } from "./schema/index.js";
|
|
25
27
|
process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
26
28
|
process.exit(0); });
|
|
@@ -31,7 +33,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
|
31
33
|
*/
|
|
32
34
|
const DIFF_EXIT_CODE_FETCH_MODE = "sync";
|
|
33
35
|
const program = new Command();
|
|
34
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
36
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.15.0");
|
|
35
37
|
/* ---------------------------------------------------------------- import */
|
|
36
38
|
program
|
|
37
39
|
.command("import")
|
|
@@ -100,34 +102,15 @@ program
|
|
|
100
102
|
return console.log(JSON.stringify({ forge: forgeJson(ws, lock), statuses: st.map(({ planned, ...s }) => s), warnings: p.warnings }, null, 2));
|
|
101
103
|
printStatus(st, p.warnings, ws.config.profile, p.resolution.recipes, false, forgeLine(ws, lock));
|
|
102
104
|
});
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
.option("--dry-run", "show the plan, write nothing", false)
|
|
110
|
-
.option("--overwrite-drift", "regenerate files that were hand-edited (their edits are lost)", false)
|
|
111
|
-
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
|
|
112
|
-
.action(async (o) => {
|
|
113
|
-
const ws = await loadWorkspace(o.workspace, load(o, "sync"));
|
|
114
|
-
const p = await plan(ws);
|
|
115
|
-
const lock = await readLock(ws.root);
|
|
116
|
-
const st = await status(ws, p, lock);
|
|
117
|
-
if (o.check) {
|
|
118
|
-
const bad = st.filter((s) => !["unchanged", "adopt"].includes(s.state));
|
|
119
|
-
printStatus(st, p.warnings, ws.config.profile, p.resolution.recipes, true, forgeLine(ws, lock));
|
|
120
|
-
if (bad.length) {
|
|
121
|
-
console.log(pc.red(`\n${bad.length} file(s) out of sync`));
|
|
122
|
-
process.exit(1);
|
|
123
|
-
}
|
|
124
|
-
console.log(pc.green("\nworkspace in sync"));
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
const r = await apply(ws, p, st, { dryRun: o.dryRun, overwriteDrift: o.overwriteDrift });
|
|
105
|
+
/**
|
|
106
|
+
* A writing sync on a planned workspace, then its report: `apply`, the registration of spec 21 (unless
|
|
107
|
+
* `CRAFTAR_NO_REGISTRY`), and the lines `sync` prints. `sync` and `init` both call it (spec 23 §5.2).
|
|
108
|
+
*/
|
|
109
|
+
async function applyAndReport(ws, p, st, lock, opts = {}) {
|
|
110
|
+
const r = await apply(ws, p, st, opts);
|
|
128
111
|
// A writing sync records the workspace (spec 21 §4.1); the registry is an index, so a failure is a warning.
|
|
129
112
|
let registryWarning = null;
|
|
130
|
-
if (!
|
|
113
|
+
if (!opts.dryRun && !registryOff()) {
|
|
131
114
|
const home = craftarHome();
|
|
132
115
|
try {
|
|
133
116
|
await register(home, ws, p);
|
|
@@ -136,7 +119,7 @@ program
|
|
|
136
119
|
registryWarning = `registry not updated (${registryFile(home)}): ${e instanceof Error ? e.message : String(e)}`;
|
|
137
120
|
}
|
|
138
121
|
}
|
|
139
|
-
const verb =
|
|
122
|
+
const verb = opts.dryRun ? "would write" : "wrote";
|
|
140
123
|
console.log(pc.bold(`craftar sync — profile ${ws.config.profile} · recipes ${p.resolution.recipes.join(" → ")} · targets ${p.resolution.targets.join(", ")}`));
|
|
141
124
|
const fl = forgeLine(ws, lock);
|
|
142
125
|
if (fl)
|
|
@@ -152,6 +135,92 @@ program
|
|
|
152
135
|
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
153
136
|
if (registryWarning)
|
|
154
137
|
console.log(` ${pc.yellow("warn")} ${registryWarning}`);
|
|
138
|
+
return r;
|
|
139
|
+
}
|
|
140
|
+
/* ---------------------------------------------------------------- init */
|
|
141
|
+
program
|
|
142
|
+
.command("init")
|
|
143
|
+
.description("Start a workspace from a Forge and a profile: write a craftar.yaml proved to resolve and plan, then run the first sync; refused when craftar.yaml already exists")
|
|
144
|
+
.requiredOption("--forge <dir|url>", "the Forge: a directory (written relative to the workspace) or a git URL")
|
|
145
|
+
.requiredOption("--profile <name>", "the client profile in the Forge")
|
|
146
|
+
.option("--ref <ref>", "branch, tag or full SHA of a remote Forge")
|
|
147
|
+
.option("--targets <a,b>", "targets to write in craftar.yaml (claude-code, kiro, agents-md); omitted, the workspace follows the profile's")
|
|
148
|
+
.option("--add-recipe <name>", "add a recipe to the profile's (repeatable)", collect, [])
|
|
149
|
+
.option("--remove-recipe <name>", "remove a recipe of the profile's (repeatable)", collect, [])
|
|
150
|
+
.option("--replace", "let --add-recipe take a slot another recipe holds", false)
|
|
151
|
+
.option("--no-sync", "write craftar.yaml only, and say what the first sync would do")
|
|
152
|
+
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
|
|
153
|
+
.option("--workspace <dir>", "workspace root, created when missing", ".")
|
|
154
|
+
.action(async (o) => {
|
|
155
|
+
const root = path.resolve(o.workspace);
|
|
156
|
+
const n1 = () => fail(`${WORKSPACE_FILE} already exists in ${root} — change recipes with craftar add recipe / remove recipe, or edit it`);
|
|
157
|
+
const file = path.join(root, WORKSPACE_FILE);
|
|
158
|
+
if (await exists(file))
|
|
159
|
+
n1();
|
|
160
|
+
const st = await fs.stat(root).catch(() => null);
|
|
161
|
+
if (st && !st.isDirectory())
|
|
162
|
+
fail(`cannot use ${root} as a workspace: it is not a directory`);
|
|
163
|
+
const init = await planInit(root, {
|
|
164
|
+
forge: o.forge,
|
|
165
|
+
profile: o.profile,
|
|
166
|
+
ref: o.ref,
|
|
167
|
+
targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()),
|
|
168
|
+
addRecipes: o.addRecipe,
|
|
169
|
+
removeRecipes: o.removeRecipe,
|
|
170
|
+
replace: o.replace,
|
|
171
|
+
}, load(o, "sync"));
|
|
172
|
+
const { ws, plan: p } = init;
|
|
173
|
+
// Every refusal is above: only now does the directory, and craftar.yaml, come to exist (§13 items 1, 9).
|
|
174
|
+
await fs.mkdir(root, { recursive: true });
|
|
175
|
+
try {
|
|
176
|
+
await fs.writeFile(file, init.text, { flag: "wx" });
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
if (e.code === "EEXIST")
|
|
180
|
+
n1();
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
183
|
+
const from = { flag: "", local: ` (from ${LOCAL_FILE})`, profile: " (from the profile)" }[init.targetsFrom];
|
|
184
|
+
console.log(pc.bold(`craftar init — wrote ${WORKSPACE_FILE} in ${root}`));
|
|
185
|
+
console.log(` forge ${ws.config.forge} · profile ${ws.config.profile} · recipes ${p.resolution.recipes.join(" → ")} · targets ${p.resolution.targets.join(", ")}${from}`);
|
|
186
|
+
for (const n of init.notes)
|
|
187
|
+
console.log(` note ${n}`);
|
|
188
|
+
if (!o.sync) {
|
|
189
|
+
console.log(nextSyncLine(init.statuses));
|
|
190
|
+
for (const w of p.warnings)
|
|
191
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const r = await applyAndReport(ws, p, init.statuses, init.lock);
|
|
195
|
+
const collisions = r.skipped.filter((s) => s.state === "collision").length;
|
|
196
|
+
if (collisions)
|
|
197
|
+
console.log(` ${pc.yellow("warn")} ${collisions} file(s) already in the workspace differ from the Forge and were left as they are — to bring them into the Forge, run craftar import`);
|
|
198
|
+
});
|
|
199
|
+
/* ---------------------------------------------------------------- sync */
|
|
200
|
+
program
|
|
201
|
+
.command("sync")
|
|
202
|
+
.description("Generate the harness for every target from the Forge and update craftar.lock; a writing sync also records the workspace in $CRAFTAR_HOME/registry.json (see craftar workspaces)")
|
|
203
|
+
.option("--workspace <dir>", "workspace root", ".")
|
|
204
|
+
.option("--check", "exit 1 when the workspace is out of date or has drift (CI mode)", false)
|
|
205
|
+
.option("--dry-run", "show the plan, write nothing", false)
|
|
206
|
+
.option("--overwrite-drift", "regenerate files that were hand-edited (their edits are lost)", false)
|
|
207
|
+
.option("--offline", "use the cached copy of a remote Forge, without fetching", false)
|
|
208
|
+
.action(async (o) => {
|
|
209
|
+
const ws = await loadWorkspace(o.workspace, load(o, "sync"));
|
|
210
|
+
const p = await plan(ws);
|
|
211
|
+
const lock = await readLock(ws.root);
|
|
212
|
+
const st = await status(ws, p, lock);
|
|
213
|
+
if (o.check) {
|
|
214
|
+
const bad = st.filter((s) => !["unchanged", "adopt"].includes(s.state));
|
|
215
|
+
printStatus(st, p.warnings, ws.config.profile, p.resolution.recipes, true, forgeLine(ws, lock));
|
|
216
|
+
if (bad.length) {
|
|
217
|
+
console.log(pc.red(`\n${bad.length} file(s) out of sync`));
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
console.log(pc.green("\nworkspace in sync"));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await applyAndReport(ws, p, st, lock, { dryRun: o.dryRun, overwriteDrift: o.overwriteDrift });
|
|
155
224
|
});
|
|
156
225
|
/* ---------------------------------------------------------------- diff */
|
|
157
226
|
program
|
|
@@ -208,17 +277,20 @@ program
|
|
|
208
277
|
/** The states `next sync:` counts, in `FileState` declaration order (spec 22 §14 item 6); a new state does not compile until it is placed here. */
|
|
209
278
|
const NEXT_SYNC = { new: true, update: true, drift: true, adopt: true, collision: true, orphan: true, "orphan-drift": true };
|
|
210
279
|
const NEXT_SYNC_STATES = Object.keys(NEXT_SYNC);
|
|
280
|
+
/** Spec 22's line: what the next sync would do, over `NEXT_SYNC_STATES` (also `init --no-sync`, spec 23 §4.4). */
|
|
281
|
+
function nextSyncLine(st) {
|
|
282
|
+
const counts = NEXT_SYNC_STATES.map((k) => [k, st.filter((s) => s.state === k).length])
|
|
283
|
+
.filter(([, n]) => n > 0)
|
|
284
|
+
.map(([k, n]) => `${n} ${k}`);
|
|
285
|
+
return `next sync: ${counts.length ? `${counts.join(", ")} — run \`craftar sync\`` : "nothing to sync"}`;
|
|
286
|
+
}
|
|
211
287
|
function recipeCommand(op) {
|
|
212
288
|
return async (names, o) => {
|
|
213
289
|
const ws = await loadWorkspace(o.workspace, load(o, "read"));
|
|
214
290
|
warnStderr(ws.warnings);
|
|
215
291
|
// R1: arrays replace across layers, so an edit of craftar.yaml would not take effect (spec 22 Ruling 1).
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const local = parseWorkspaceYaml(LOCAL_FILE, await fs.readFile(localFile, "utf8"));
|
|
219
|
-
if (local !== null && typeof local === "object" && "recipes" in local)
|
|
220
|
-
fail(`${LOCAL_FILE} sets recipes, which replaces ${WORKSPACE_FILE}'s lists — edit it by hand, or remove its recipes key and re-run`);
|
|
221
|
-
}
|
|
292
|
+
if ((await localKeys(ws.root)).includes("recipes"))
|
|
293
|
+
fail(`${LOCAL_FILE} sets recipes, which replaces ${WORKSPACE_FILE}'s lists — edit it by hand, or remove its recipes key and re-run`);
|
|
222
294
|
const edit = planRecipeEdit(ws.forge, ws.config, op, names, { replace: o.replace === true });
|
|
223
295
|
if (!edit.changed) {
|
|
224
296
|
console.log(`nothing to change${edit.reasons.length ? `: ${edit.reasons.join(", ")}` : ""}`);
|
|
@@ -230,13 +302,11 @@ function recipeCommand(op) {
|
|
|
230
302
|
const next = { ...ws, config: { ...ws.config, recipes: edit.recipes } };
|
|
231
303
|
const p = await plan(next);
|
|
232
304
|
const st = await status(next, p, await readLock(ws.root));
|
|
233
|
-
const
|
|
234
|
-
.filter(([, n]) => n > 0)
|
|
235
|
-
.map(([k, n]) => `${n} ${k}`);
|
|
305
|
+
const nextLine = nextSyncLine(st);
|
|
236
306
|
await fs.writeFile(file, content);
|
|
237
307
|
console.log(`${WORKSPACE_FILE}: ${recipeDiffLine(ws.config.recipes, edit.recipes)}`);
|
|
238
308
|
console.log(`recipes: ${p.resolution.recipes.join(" → ")}`);
|
|
239
|
-
console.log(
|
|
309
|
+
console.log(nextLine);
|
|
240
310
|
// plan() already carries ws.warnings first, and they were printed on load.
|
|
241
311
|
warnStderr(p.warnings.slice(ws.warnings.length));
|
|
242
312
|
};
|
|
@@ -345,6 +415,40 @@ workspaces
|
|
|
345
415
|
for (const p of gone)
|
|
346
416
|
console.log(`pruned ${p}`);
|
|
347
417
|
});
|
|
418
|
+
/* ---------------------------------------------------------------- doctor */
|
|
419
|
+
program
|
|
420
|
+
.command("doctor")
|
|
421
|
+
.description("Check this machine (Node, git, $CRAFTAR_HOME, the registry, the Forge cache) and, inside one, this workspace (configuration and Forge, plan, declared parameters, MCP environment variables, lock, status, registration): one line per finding — ok, warn or error — each with its fix. Reports only; reads without the network unless --fetch")
|
|
422
|
+
// No commander default: an explicit --workspace without craftar.yaml exits 1, the current directory without one runs the machine checks (spec 24 §4.1).
|
|
423
|
+
.option("--workspace <dir>", "the workspace to check (default: the current directory, when it holds craftar.yaml)")
|
|
424
|
+
.option("--fetch", "fetch a remote Forge first instead of reading the cached copy", false)
|
|
425
|
+
.option("--strict", "exit 1 on a warn too, not only on an error", false)
|
|
426
|
+
.option("--json", "machine-readable output", false)
|
|
427
|
+
.action(async (o) => {
|
|
428
|
+
let workspace = null;
|
|
429
|
+
if (o.workspace !== undefined) {
|
|
430
|
+
if (!(await exists(path.join(o.workspace, WORKSPACE_FILE))))
|
|
431
|
+
fail(`no ${WORKSPACE_FILE} in ${path.resolve(o.workspace)} — run doctor inside a workspace, or without --workspace for the machine checks`);
|
|
432
|
+
workspace = o.workspace;
|
|
433
|
+
}
|
|
434
|
+
else if (await exists(path.join(process.cwd(), WORKSPACE_FILE)))
|
|
435
|
+
workspace = process.cwd();
|
|
436
|
+
const report = await runDoctor({
|
|
437
|
+
version: program.version() ?? "unknown",
|
|
438
|
+
home: craftarHome(),
|
|
439
|
+
workspace,
|
|
440
|
+
fetch: o.fetch,
|
|
441
|
+
strict: o.strict,
|
|
442
|
+
registryOff: registryOff(),
|
|
443
|
+
});
|
|
444
|
+
if (o.json)
|
|
445
|
+
console.log(JSON.stringify(report, null, 2));
|
|
446
|
+
else
|
|
447
|
+
printDoctor(report);
|
|
448
|
+
// exitCode, not process.exit: a piped --json is never cut (spec 24 §5.1).
|
|
449
|
+
if (report.summary.error > 0 || (o.strict && report.summary.warn > 0))
|
|
450
|
+
process.exitCode = 1;
|
|
451
|
+
});
|
|
348
452
|
/* ---------------------------------------------------------------- recipes */
|
|
349
453
|
program
|
|
350
454
|
.command("recipes")
|
|
@@ -1102,10 +1206,24 @@ async function forgeFor(o) {
|
|
|
1102
1206
|
warnStderr(workspace?.warnings ?? []);
|
|
1103
1207
|
return forge;
|
|
1104
1208
|
}
|
|
1209
|
+
/** A repeatable option's values, in the order given. */
|
|
1210
|
+
function collect(value, previous) {
|
|
1211
|
+
return [...previous, value];
|
|
1212
|
+
}
|
|
1105
1213
|
/** `$CRAFTAR_HOME`, as the cache reads it (spec 13 §6.3). */
|
|
1106
1214
|
function craftarHome() {
|
|
1107
1215
|
return resolveHome(process.env.CRAFTAR_HOME || undefined);
|
|
1108
1216
|
}
|
|
1217
|
+
/** The text report of `craftar doctor` (spec 24 §4.4) — presentation, not contract. */
|
|
1218
|
+
function printDoctor(r) {
|
|
1219
|
+
console.log(pc.bold(`craftar doctor — craftar ${r.version} · ${r.workspace ?? "no workspace (machine checks only)"}`));
|
|
1220
|
+
const paint = (l) => (l === "error" ? pc.red : l === "warn" ? pc.yellow : pc.dim)(l.padEnd(6));
|
|
1221
|
+
// A message that already ends in its fix (a refused schema says "— upgrade craftar") is not suffixed twice.
|
|
1222
|
+
const suffix = (c) => (c.fix && c.level !== "ok" && !c.message.endsWith(c.fix) ? ` — ${c.fix}` : "");
|
|
1223
|
+
for (const c of r.checks)
|
|
1224
|
+
console.log(` ${paint(c.level)} ${c.id.padEnd(13)} ${c.message}${suffix(c)}`);
|
|
1225
|
+
console.log(`summary: ${r.summary.ok} ok, ${r.summary.warn} warn, ${r.summary.error} error`);
|
|
1226
|
+
}
|
|
1109
1227
|
/** `CRAFTAR_NO_REGISTRY` — "no registry", read like NO_COLOR: any non-empty value turns it off (spec 21 §4.1). */
|
|
1110
1228
|
function registryOff() {
|
|
1111
1229
|
return Boolean(process.env.CRAFTAR_NO_REGISTRY);
|