craftar 0.8.6 → 0.9.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 +25 -3
- package/dist/cli.js +390 -5
- package/dist/cli.js.map +1 -1
- package/dist/core/capabilities.js +152 -0
- package/dist/core/capabilities.js.map +1 -0
- package/dist/core/catalogue.js +263 -0
- package/dist/core/catalogue.js.map +1 -0
- package/dist/core/resolve.js +21 -10
- package/dist/core/resolve.js.map +1 -1
- package/dist/core/sync.js +14 -5
- package/dist/core/sync.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*). 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. 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
|
|
|
@@ -58,11 +58,14 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
58
58
|
| `craftar diff [path]` | Line diff between disk and what the Forge would generate. |
|
|
59
59
|
| `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
60
|
| `craftar ls` | Recipes and ingredients resolved for this workspace. |
|
|
61
|
+
| `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}`. |
|
|
62
|
+
| `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}`. |
|
|
63
|
+
| `craftar targets [--workspace <dir>] [--json]` | The capability matrix (see *Targets*): what each target does with each ingredient type, the paths it writes and what changes. Needs no Forge; inside a workspace it marks the targets in use, and when that workspace does not load or resolve it prints the matrix unmarked with a warning. An explicit `--workspace <dir>` without a `craftar.yaml` exits 1; the current directory without one just prints the matrix unmarked. `--json` is a contract: `{ingredientTypes, targets, warnings}`. |
|
|
61
64
|
| `craftar forge variants [--forge <dir> \| --workspace <dir>] [--json]` | Lists ingredients that have variants, nearest first, with the profile each came from, its distance to the base and its hunks counted by suggested class (`[1 evolution · 2 block]`), then any variant whose base is missing from the Forge. Read-only; exits 0 either way. `--json` prints `{groups, orphans}`; each variant carries `classes: {evolution, value, block}`, which add up to `distance.hunks`. |
|
|
62
65
|
| `craftar forge diff <type/name> [--against <profile>] [--forge <dir> \| --workspace <dir>] [--json]` | Shows the differences between a base ingredient and each of its variants: a header carrying the same distance `forge variants` reports, then hunk by hunk, each with a suggested class and reason — `evolution` (one side is newer text), `value` (an identifier-like token swapped in shared prose, with a suggested `param.<slug>`) or `block` (lines only one side has) — then the files that exist on only one side. A suggestion never decides anything. It compares the raw Forge text, section marker lines included, so a base with markers shows them as hunks against a variant that has none. Read-only. `--json` prints an array of `{ref, profile, distance, diff}`; each hunk carries `suggestion: {class, reason, tokens?}` next to its `kind`. |
|
|
63
66
|
| `craftar forge unify <type/name> --profile <p> (--take base\|variant \| --plan <file> \| --save-plan <file>) [--forge <dir> \| --workspace <dir>] [--json]` | Resolves one variant back into its base, hunk by hunk. `--save-plan` writes a reviewable plan with every decision set to `keep`, each hunk annotated with its suggested class (which `--plan` ignores) and, for a `value` hunk, a pre-filled `params` list (`token` → suggested `key`); on a `block` hunk, a pre-filled `section: { name }` from the Markdown heading above (or `section-<n>`); and on a hunk touching a section the base already has, that section's name. It refuses a path that resolves inside the Forge (after `..` segments and symlinks) and a path that already exists, so it never overwrites a Forge file or a plan you already edited; it writes nothing else. A hunk set to `take: param` turns each listed token into `{{key}}` in the base, declares the base's text as the key's default in the base's `ingredient.yaml` and writes the variant's text into the variant profile's `profile.yaml` `params` — only after proving that both the base's and the variant's text render back exactly, and only when the same plan resolves the variant; it refuses (with the Forge untouched) a key another layer, profile or ingredient already uses, a variant another profile also uses, a whitespace-only difference, and a YAML file that would not round-trip unchanged. A hunk set to `take: section` (with `section: { name, lines? }`) wraps the base's lines in section markers as the default and writes the variant's lines into the variant profile's `sections` — consecutive hunks with one name form one section, `lines: "<from>-<to>"` widens it over equal base lines, and hunks inside a section the base already has only write the profile value; it is proved (both sides render back exactly) and needs the same plan to resolve the variant; `take: param` and `take: section` may share a plan, never a hunk. It refuses (Forge untouched) a `take: param` or `take: section` on a file no target emits, a variant that holds markers, a profile that already sets that section (another profile for a new section, or the variant's profile with other content), a span that cuts a hunk or crosses another section, a span that covers a hunk outside the run (including `take: base` on a hunk inside a section the plan fills), a hunk mixing lines inside and outside an existing section, and a span reaching a missing final newline; when it adds a section to a Forge still at `schema: 1` it sets `schema: 2` in `craftar.forge.yaml` in place (refused if the file does not round-trip). `--plan` applies an edited plan, refusing when it is not for this exact ingredient/profile or either side's fingerprint moved since it was saved. `--take base\|variant` resolves every decision to that side. Writes the merged base and, once every difference is resolved, removes the variant and rewrites every recipe reference to it (`ingredients`) to name the base. It never deletes a recipe and never edits an `extends`; it edits a profile only to write the values of a `take: param` or `take: section` extraction into the variant's own profile, and otherwise never: a `<recipe>--<profile>` left identical to `<recipe>` is reported as a warning, to be removed by hand after repointing the lists that name it — unify does not, because a workspace or an `extends` chain may also name `<recipe>` and the recipe order or param precedence would change. Unify cannot reach workspaces, so a removed variant is reported as a warning for any `craftar.yaml` that disables it in `overrides.ingredients.disable`. Every recipe rewrite is checked before the first write, so one that cannot land (a reference behind a YAML alias) is refused with the Forge untouched; a failure after writing began (an I/O error, a locked file) names the paths already touched and the `git checkout` / `git clean` commands that undo them. A merge never adds, removes or changes a section marker other than the sections the plan declares; a plan or `--take variant` that would is refused before the first write — take `base` for the marker lines, or `take: section` to fill the section; the citation checks of `take: param` also read the profiles' section values, which can cite `{{key}}`. `ingredient.yaml` is never merged: when the two sides' metadata differs (beyond `name`, `as` and `origin`), the variant stays unresolved and the differing fields are named — edit it by hand, or `--take base` to discard the variant, metadata included. Requires a clean git checkout in the Forge with at least one commit (`--save-plan` excepted) — the Forge has no lock, so git is the undo. It also refuses when any path it would overwrite or delete (the base, the variant, the recipe files it rewrites, the profile a parameter or section extraction edits, `craftar.forge.yaml` when it is bumped) is ignored, untracked, modified, or flagged skip-worktree or assume-unchanged in the index, since git could not restore it. `--json` prints `{base, profile, resolved, written, removed, unresolved, variantRemoved, recipes: {rewritten, identicalToSibling}, metaDiffers, params, profileEdited, sections, manifestEdited, warnings}` for `--take`/`--plan` (every key always present, `[]` when empty), or `{base, profile, plan, unresolved}` for `--save-plan`. |
|
|
64
67
|
|
|
65
|
-
All commands take `--workspace <dir>` (default: current directory); the `forge` commands also take `--forge <dir>` as an alternative to it.
|
|
68
|
+
All commands take `--workspace <dir>` (default: current directory); the `forge` commands, `recipes` and `ingredients` also take `--forge <dir>` as an alternative to it, and `targets` needs no Forge.
|
|
66
69
|
|
|
67
70
|
## Forge layout
|
|
68
71
|
|
|
@@ -184,6 +187,19 @@ Sections layer the same way, weakest → strongest: the body's default → the p
|
|
|
184
187
|
|
|
185
188
|
## Targets
|
|
186
189
|
|
|
190
|
+
`craftar targets` prints this matrix from `src/core/capabilities.ts`; a test plans one ingredient of every type for each target and fails when a cell and its emitter disagree. **native** — written in the tool's own place for that type, as the Forge holds it; **converted** — written in another form (frontmatter or a banner added, references rewritten, another container format, or folded into a shared file); **unsupported** — not written: `sync` warns and names an ingredient aimed at that target.
|
|
191
|
+
|
|
192
|
+
| type | `claude-code` | `kiro` | `agents-md` |
|
|
193
|
+
|---|---|---|---|
|
|
194
|
+
| `rule` | native | converted | converted |
|
|
195
|
+
| `agent` | native | converted | unsupported |
|
|
196
|
+
| `command` | native | converted | unsupported |
|
|
197
|
+
| `skill` | native | converted | unsupported |
|
|
198
|
+
| `mcp` | native | native | unsupported |
|
|
199
|
+
| `script` | native | unsupported | unsupported |
|
|
200
|
+
| `steering` | unsupported | native | unsupported |
|
|
201
|
+
| `hook` | native | unsupported | unsupported |
|
|
202
|
+
|
|
187
203
|
**claude-code** — emits `.claude/rules|agents|commands|skills|scripts|hooks` and `.mcp.json` verbatim from the Forge (frontmatter kept byte-for-byte). Each MCP server is written exactly as the Forge holds it, undeclared keys included, in its own key order. Existing files keep their line endings and BOM; new files are LF without a BOM. Steering is Kiro-only (its `targets` default to `[kiro]`); one aimed at `claude-code` explicitly (`targets: "*"` or a list naming it) is skipped with a warning.
|
|
188
204
|
|
|
189
205
|
**kiro** — reproduces, then extends, the hand-written `sync-steering.ps1` script it replaces:
|
|
@@ -236,11 +252,17 @@ Copy a workspace with `.claude/` and `.kiro/` into `fixtures/` (keep `local-stac
|
|
|
236
252
|
|
|
237
253
|
## Roadmap (from the functional spec)
|
|
238
254
|
|
|
239
|
-
Phase 0 (this): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans.
|
|
255
|
+
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).
|
|
240
256
|
Next: `craftar init` from a profile; profile-driven integrations (PM tool → MCP, IDP → MCP); `service` ingredients with compose fragments (`craftar services up`); `dotnet new` template registration; rulesync bridge for Codex/Kimi/Cursor specifics; remote Forge (git URL + ref); `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; `craftar ui`; `craftar mcp` + Claude Code / Agent Plugin packaging.
|
|
241
257
|
|
|
242
258
|
## Upgrading
|
|
243
259
|
|
|
260
|
+
### to 0.9.0
|
|
261
|
+
|
|
262
|
+
- **Three new read-only commands**: `craftar recipes`, `craftar ingredients` and `craftar targets`. They write nothing — no generated file, no lock, no Forge file.
|
|
263
|
+
- **No byte change**: no emitter changed, and `craftar ls` prints what it printed before. No workspace sees `update`.
|
|
264
|
+
- Their `--json` output is a tooling contract, like `status --json`: removing or renaming a key, changing a value's type, or adding a capability state will be announced here.
|
|
265
|
+
|
|
244
266
|
### to 0.8.6
|
|
245
267
|
|
|
246
268
|
- **A rule reference inside the text of a Markdown link is now resolved like any other**, in `AGENTS.md` and in the kiro files. Before, the text of a link that `AGENTS.md` or kiro rewrote was copied as the Forge held it, or blanket-rewritten to `.kiro/steering/`.
|
package/dist/cli.js
CHANGED
|
@@ -4,21 +4,23 @@ import path from "node:path";
|
|
|
4
4
|
import { promises as fs } from "node:fs";
|
|
5
5
|
import YAML from "yaml";
|
|
6
6
|
import { importClaudeCode } from "./importers/claude-code.js";
|
|
7
|
-
import { loadWorkspace, plan, readLock, status, apply, resolveForge } from "./core/sync.js";
|
|
8
|
-
import { sectionKey } from "./core/resolve.js";
|
|
7
|
+
import { loadWorkspace, plan, readLock, status, apply, resolveForge, resolveForgeSource, WORKSPACE_FILE } from "./core/sync.js";
|
|
8
|
+
import { resolve, sectionKey } from "./core/resolve.js";
|
|
9
|
+
import { catalogueContext, listRecipes, listIngredients, checkType } from "./core/catalogue.js";
|
|
10
|
+
import { listTargets } from "./core/capabilities.js";
|
|
9
11
|
import { canonicalValue } from "./core/sections.js";
|
|
10
12
|
import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
|
|
11
13
|
import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
|
|
12
14
|
import { hashNormalized, toLf, stripBom } from "./core/text.js";
|
|
13
|
-
import { gitDirty, gitIsRepo, gitUnheld } from "./core/forge.js";
|
|
15
|
+
import { exists, gitDirty, gitIsRepo, gitUnheld } from "./core/forge.js";
|
|
14
16
|
import { fingerprintDir } from "./core/fingerprint.js";
|
|
15
17
|
import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, } from "./core/unify.js";
|
|
16
18
|
import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
|
|
17
|
-
import { HUNK_CLASSES, UnifyPlanSchema } from "./schema/index.js";
|
|
19
|
+
import { HUNK_CLASSES, INGREDIENT_TYPES, UnifyPlanSchema } from "./schema/index.js";
|
|
18
20
|
process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
19
21
|
process.exit(0); });
|
|
20
22
|
const program = new Command();
|
|
21
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
23
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.9.0");
|
|
22
24
|
/* ---------------------------------------------------------------- import */
|
|
23
25
|
program
|
|
24
26
|
.command("import")
|
|
@@ -187,6 +189,389 @@ program
|
|
|
187
189
|
}
|
|
188
190
|
console.log(`\n${p.files.length} files across targets ${p.resolution.targets.join(", ")}`);
|
|
189
191
|
});
|
|
192
|
+
/* ---------------------------------------------------------------- recipes */
|
|
193
|
+
program
|
|
194
|
+
.command("recipes")
|
|
195
|
+
.description("List every recipe of the Forge — slot, parents, the profiles that use it — marked against this workspace or a profile. Read-only")
|
|
196
|
+
.option("--forge <dir>", "Forge directory (instead of --workspace)")
|
|
197
|
+
.option("--profile <name>", "with --forge: mark what this profile resolves")
|
|
198
|
+
.option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
|
|
199
|
+
.option("--json", "machine-readable output", false)
|
|
200
|
+
.action(async (o) => {
|
|
201
|
+
if (o.profile && !o.forge)
|
|
202
|
+
fail("--profile goes with --forge — inside a workspace the profile comes from craftar.yaml");
|
|
203
|
+
const { forge: forg, workspace } = await resolveForgeSource({ forge: o.forge, workspace: o.workspace });
|
|
204
|
+
const source = workspace
|
|
205
|
+
? { kind: "workspace", config: workspace.config }
|
|
206
|
+
: o.profile
|
|
207
|
+
? { kind: "profile", profile: o.profile }
|
|
208
|
+
: null;
|
|
209
|
+
let context = null;
|
|
210
|
+
if (source) {
|
|
211
|
+
try {
|
|
212
|
+
context = catalogueContext(forg, source);
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const r = listRecipes(forg, context);
|
|
219
|
+
if (o.json) {
|
|
220
|
+
return console.log(JSON.stringify(r, null, 2));
|
|
221
|
+
}
|
|
222
|
+
// Text mode
|
|
223
|
+
const ctxDesc = source
|
|
224
|
+
? source.kind === "workspace"
|
|
225
|
+
? `workspace profile ${source.config.profile}`
|
|
226
|
+
: `profile ${source.profile}`
|
|
227
|
+
: null;
|
|
228
|
+
console.log(pc.bold(`craftar recipes — forge ${forg.manifest.name} @ ${forg.commit?.slice(0, 8) ?? "no git"}${ctxDesc ? ` · ${ctxDesc}` : ""}`));
|
|
229
|
+
const resolves = context !== null && context.resolution !== null;
|
|
230
|
+
// Compute column widths for alignment
|
|
231
|
+
// Column 1: name[ [slot s]]
|
|
232
|
+
// Column 2: <N ingredient(s)>
|
|
233
|
+
// Column 3: ← parents (empty when none)
|
|
234
|
+
// Column 4: reason (empty when none)
|
|
235
|
+
const col1 = r.recipes.map((rec) => `${rec.name}${rec.slot ? ` [slot ${rec.slot}]` : ""}`);
|
|
236
|
+
const col2 = r.recipes.map((rec) => `${rec.ingredients.length} ingredient${rec.ingredients.length === 1 ? "" : "s"}`);
|
|
237
|
+
const col3 = r.recipes.map((rec) => rec.extends.length ? `← ${rec.extends.join(", ")}` : "");
|
|
238
|
+
const col4 = r.recipes.map((rec) => {
|
|
239
|
+
let reason = "";
|
|
240
|
+
if (rec.inUse) {
|
|
241
|
+
const byParts = [];
|
|
242
|
+
for (const b of rec.inUse.by) {
|
|
243
|
+
if (b === "extends")
|
|
244
|
+
byParts.push(`extends ${rec.inUse.extendedBy.join(", ")}`);
|
|
245
|
+
else
|
|
246
|
+
byParts.push(b);
|
|
247
|
+
}
|
|
248
|
+
reason = `in use (${byParts.join(", ")})`;
|
|
249
|
+
}
|
|
250
|
+
const removed = rec.removedByWorkspace ? (reason ? " · " : "") + "removed by this workspace" : "";
|
|
251
|
+
return reason + removed;
|
|
252
|
+
});
|
|
253
|
+
const w1 = Math.max(...col1.map((s) => s.length));
|
|
254
|
+
const w2 = Math.max(...col2.map((s) => s.length));
|
|
255
|
+
const w3 = Math.max(...col3.map((s) => s.length));
|
|
256
|
+
const w4 = Math.max(...col4.map((s) => s.length));
|
|
257
|
+
for (let i = 0; i < r.recipes.length; i++) {
|
|
258
|
+
const rec = r.recipes[i];
|
|
259
|
+
const desc = rec.description ? `— ${rec.description}` : "";
|
|
260
|
+
let line;
|
|
261
|
+
if (resolves) {
|
|
262
|
+
const mark = rec.inUse ? pc.green("●") : pc.dim("○");
|
|
263
|
+
line = [
|
|
264
|
+
` ${mark} `,
|
|
265
|
+
col1[i].padEnd(w1),
|
|
266
|
+
" ",
|
|
267
|
+
col2[i].padEnd(w2),
|
|
268
|
+
col3[i] ? " " + col3[i].padEnd(w3) : (w3 > 0 ? " " + "".padEnd(w3) : ""),
|
|
269
|
+
col4[i] ? " " + col4[i].padEnd(w4) : (w4 > 0 ? " " + "".padEnd(w4) : ""),
|
|
270
|
+
desc ? " " + desc : "",
|
|
271
|
+
].join("").trimEnd();
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
// No context: no mark column, rows start with two spaces then the name
|
|
275
|
+
line = [
|
|
276
|
+
" ",
|
|
277
|
+
col1[i].padEnd(w1),
|
|
278
|
+
" ",
|
|
279
|
+
col2[i].padEnd(w2),
|
|
280
|
+
col3[i] ? " " + col3[i].padEnd(w3) : (w3 > 0 ? " " + "".padEnd(w3) : ""),
|
|
281
|
+
desc ? " " + desc : "",
|
|
282
|
+
].join("").trimEnd();
|
|
283
|
+
}
|
|
284
|
+
console.log(line);
|
|
285
|
+
}
|
|
286
|
+
// Count line
|
|
287
|
+
const inUseCount = r.recipes.filter((x) => x.inUse).length;
|
|
288
|
+
const profilesPart = r.recipes.length > 0
|
|
289
|
+
? ` · used by profiles: ${r.recipes.map((x) => `${x.name} (${x.profiles.length ? x.profiles.join(", ") : "—"})`).join(", ")}`
|
|
290
|
+
: "";
|
|
291
|
+
const countLine = resolves
|
|
292
|
+
? `${r.recipes.length} recipe${r.recipes.length === 1 ? "" : "s"}, ${inUseCount} in use${profilesPart}`
|
|
293
|
+
: `${r.recipes.length} recipe${r.recipes.length === 1 ? "" : "s"}${profilesPart}`;
|
|
294
|
+
console.log(`\n ${countLine}`);
|
|
295
|
+
for (const w of r.warnings)
|
|
296
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
297
|
+
});
|
|
298
|
+
/* ---------------------------------------------------------------- ingredients */
|
|
299
|
+
program
|
|
300
|
+
.command("ingredients")
|
|
301
|
+
.description("List every ingredient of the Forge — its recipes and targets, references to missing ones — or what one recipe brings with its parents. Read-only")
|
|
302
|
+
.option("--recipe <name>", "only what this recipe brings, with the parents it extends, parents first")
|
|
303
|
+
.option("--type <type>", "only this ingredient type")
|
|
304
|
+
.option("--forge <dir>", "Forge directory (instead of --workspace)")
|
|
305
|
+
.option("--profile <name>", "with --forge: mark what this profile resolves")
|
|
306
|
+
.option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
|
|
307
|
+
.option("--json", "machine-readable output", false)
|
|
308
|
+
.action(async (o) => {
|
|
309
|
+
if (o.profile && !o.forge)
|
|
310
|
+
fail("--profile goes with --forge — inside a workspace the profile comes from craftar.yaml");
|
|
311
|
+
if (o.type) {
|
|
312
|
+
try {
|
|
313
|
+
checkType(o.type);
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const { forge: forg, workspace } = await resolveForgeSource({ forge: o.forge, workspace: o.workspace });
|
|
320
|
+
const source = workspace
|
|
321
|
+
? { kind: "workspace", config: workspace.config }
|
|
322
|
+
: o.profile
|
|
323
|
+
? { kind: "profile", profile: o.profile }
|
|
324
|
+
: null;
|
|
325
|
+
let context = null;
|
|
326
|
+
if (source) {
|
|
327
|
+
try {
|
|
328
|
+
context = catalogueContext(forg, source);
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
let result;
|
|
335
|
+
try {
|
|
336
|
+
result = listIngredients(forg, context, { recipe: o.recipe, type: o.type });
|
|
337
|
+
}
|
|
338
|
+
catch (e) {
|
|
339
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
340
|
+
}
|
|
341
|
+
if (o.json) {
|
|
342
|
+
return console.log(JSON.stringify(result, null, 2));
|
|
343
|
+
}
|
|
344
|
+
// Text mode
|
|
345
|
+
const ctxDesc = source
|
|
346
|
+
? source.kind === "workspace"
|
|
347
|
+
? `workspace profile ${source.config.profile}`
|
|
348
|
+
: `profile ${source.profile}`
|
|
349
|
+
: null;
|
|
350
|
+
const recipeOpt = o.recipe ? ` --recipe ${o.recipe}` : "";
|
|
351
|
+
const typeOpt = o.type ? ` --type ${o.type}` : "";
|
|
352
|
+
console.log(pc.bold(`craftar ingredients${recipeOpt}${typeOpt} — forge ${forg.manifest.name} @ ${forg.commit?.slice(0, 8) ?? "no git"}${ctxDesc ? ` · ${ctxDesc}` : ""}`));
|
|
353
|
+
const resolves = context !== null && context.resolution !== null;
|
|
354
|
+
if (result.recipe) {
|
|
355
|
+
// --recipe mode: grouped by chain entry
|
|
356
|
+
// Compute column widths for alignment across the whole listing
|
|
357
|
+
// Column 1: ref[ → outputName]
|
|
358
|
+
const col1Recipe = [];
|
|
359
|
+
for (const entry of result.recipe.chain) {
|
|
360
|
+
for (const ref of entry.ingredients) {
|
|
361
|
+
const ing = result.ingredients.find((i) => i.ref === ref);
|
|
362
|
+
if (ing) {
|
|
363
|
+
col1Recipe.push(ing.outputName !== ing.name ? `${ref} → ${ing.outputName}` : ref);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
col1Recipe.push(ref);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const w1Recipe = Math.max(...col1Recipe.map((s) => s.length), 0);
|
|
371
|
+
for (const entry of result.recipe.chain) {
|
|
372
|
+
const rec = forg.recipes.get(entry.recipe);
|
|
373
|
+
const ext = rec?.extends.length ? ` ← ${rec.extends.join(", ")}` : "";
|
|
374
|
+
const slot = rec?.slot ? ` [slot ${rec.slot}]` : "";
|
|
375
|
+
console.log(`${entry.recipe}${ext}${slot}`);
|
|
376
|
+
for (const ref of entry.ingredients) {
|
|
377
|
+
const ing = result.ingredients.find((i) => i.ref === ref);
|
|
378
|
+
const miss = result.missing.find((m) => m.ref === ref);
|
|
379
|
+
if (ing) {
|
|
380
|
+
const col1 = ing.outputName !== ing.name ? `${ref} → ${ing.outputName}` : ref;
|
|
381
|
+
const disabled = ing.disabled ? "(disabled by this workspace)" : "";
|
|
382
|
+
let line;
|
|
383
|
+
if (resolves) {
|
|
384
|
+
const mark = ing.disabled ? pc.dim("◌") : ing.inUse ? pc.green("●") : pc.dim("○");
|
|
385
|
+
line = ` ${mark} ${col1.padEnd(w1Recipe)}${disabled ? " " + disabled : ""}`.trimEnd();
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
line = ` ${col1.padEnd(w1Recipe)}`.trimEnd();
|
|
389
|
+
}
|
|
390
|
+
console.log(line);
|
|
391
|
+
}
|
|
392
|
+
else if (miss) {
|
|
393
|
+
const line = ` ${pc.red("✗")} ${ref.padEnd(w1Recipe)} not in this Forge`.trimEnd();
|
|
394
|
+
console.log(line);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Count line for --recipe
|
|
399
|
+
const countLine = `${result.recipe.chain.length} recipe${result.recipe.chain.length === 1 ? "" : "s"}, ${result.ingredients.length} ingredient${result.ingredients.length === 1 ? "" : "s"}${result.missing.length ? ` · ${result.missing.length} missing reference${result.missing.length === 1 ? "" : "s"}` : ""}`;
|
|
400
|
+
console.log(`\n ${countLine}`);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
// No --recipe: grouped by type
|
|
404
|
+
// Spec 16 §4.3: rows print the bare `name`, not the `ref`, under their type heading
|
|
405
|
+
// Compute column widths across the whole listing (all types + missing)
|
|
406
|
+
// Column 1: name[ → outputName] (or ref for missing)
|
|
407
|
+
// Column 2: targets <...>
|
|
408
|
+
// Column 3: in <...>
|
|
409
|
+
const allCol1 = [];
|
|
410
|
+
const allCol2 = [];
|
|
411
|
+
const allCol3 = [];
|
|
412
|
+
for (const ing of result.ingredients) {
|
|
413
|
+
const col1 = ing.outputName !== ing.name ? `${ing.name} → ${ing.outputName}` : ing.name;
|
|
414
|
+
allCol1.push(col1);
|
|
415
|
+
allCol2.push(`targets ${ing.targets === "*" ? "*" : ing.targets.join(", ")}`);
|
|
416
|
+
allCol3.push(`in ${ing.recipes.length ? ing.recipes.join(", ") : "no recipe"}`);
|
|
417
|
+
}
|
|
418
|
+
for (const m of result.missing) {
|
|
419
|
+
allCol1.push(m.ref);
|
|
420
|
+
}
|
|
421
|
+
const w1 = Math.max(...allCol1.map((s) => s.length), 0);
|
|
422
|
+
const w2 = Math.max(...allCol2.map((s) => s.length), 0);
|
|
423
|
+
const w3 = Math.max(...allCol3.map((s) => s.length), 0);
|
|
424
|
+
const byType = new Map();
|
|
425
|
+
for (const ing of result.ingredients) {
|
|
426
|
+
if (!byType.has(ing.type))
|
|
427
|
+
byType.set(ing.type, []);
|
|
428
|
+
byType.get(ing.type).push(ing);
|
|
429
|
+
}
|
|
430
|
+
for (const type of INGREDIENT_TYPES) {
|
|
431
|
+
const ings = byType.get(type);
|
|
432
|
+
if (!ings?.length)
|
|
433
|
+
continue;
|
|
434
|
+
console.log(type);
|
|
435
|
+
for (const ing of ings) {
|
|
436
|
+
const col1 = ing.outputName !== ing.name ? `${ing.name} → ${ing.outputName}` : ing.name;
|
|
437
|
+
const col2 = `targets ${ing.targets === "*" ? "*" : ing.targets.join(", ")}`;
|
|
438
|
+
const col3 = `in ${ing.recipes.length ? ing.recipes.join(", ") : "no recipe"}`;
|
|
439
|
+
const desc = ing.description ? `— ${ing.description}` : "";
|
|
440
|
+
const disabled = ing.disabled ? "(disabled by this workspace)" : "";
|
|
441
|
+
let line;
|
|
442
|
+
if (resolves) {
|
|
443
|
+
const mark = ing.disabled ? pc.dim("◌") : ing.inUse ? pc.green("●") : pc.dim("○");
|
|
444
|
+
line = [
|
|
445
|
+
` ${mark} `,
|
|
446
|
+
col1.padEnd(w1),
|
|
447
|
+
" ",
|
|
448
|
+
col2.padEnd(w2),
|
|
449
|
+
" ",
|
|
450
|
+
col3.padEnd(w3),
|
|
451
|
+
desc ? " " + desc : "",
|
|
452
|
+
disabled ? " " + disabled : "",
|
|
453
|
+
].join("").trimEnd();
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
// No context: no mark column, rows start with two spaces then the name
|
|
457
|
+
line = [
|
|
458
|
+
" ",
|
|
459
|
+
col1.padEnd(w1),
|
|
460
|
+
" ",
|
|
461
|
+
col2.padEnd(w2),
|
|
462
|
+
" ",
|
|
463
|
+
col3.padEnd(w3),
|
|
464
|
+
desc ? " " + desc : "",
|
|
465
|
+
].join("").trimEnd();
|
|
466
|
+
}
|
|
467
|
+
console.log(line);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (result.missing.length) {
|
|
471
|
+
console.log("missing");
|
|
472
|
+
for (const m of result.missing) {
|
|
473
|
+
const line = ` ${m.ref.padEnd(w1)} cited by ${m.recipes.join(", ")}, not in this Forge`.trimEnd();
|
|
474
|
+
console.log(line);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Count line
|
|
478
|
+
const inUseCount = result.ingredients.filter((x) => x.inUse).length;
|
|
479
|
+
const disabledCount = result.ingredients.filter((x) => x.disabled).length;
|
|
480
|
+
const noRecipeCount = result.ingredients.filter((x) => x.recipes.length === 0).length;
|
|
481
|
+
const countParts = [`${result.ingredients.length} ingredient${result.ingredients.length === 1 ? "" : "s"}`];
|
|
482
|
+
if (resolves) {
|
|
483
|
+
countParts.push(`${inUseCount} in use`);
|
|
484
|
+
countParts.push(`${disabledCount} disabled`);
|
|
485
|
+
}
|
|
486
|
+
countParts.push(`${noRecipeCount} in no recipe`);
|
|
487
|
+
const missingPart = result.missing.length ? ` · ${result.missing.length} missing reference${result.missing.length === 1 ? "" : "s"}` : "";
|
|
488
|
+
console.log(`\n ${countParts.join(", ")}${missingPart}`);
|
|
489
|
+
}
|
|
490
|
+
for (const w of result.warnings)
|
|
491
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
492
|
+
});
|
|
493
|
+
/* ---------------------------------------------------------------- targets */
|
|
494
|
+
program
|
|
495
|
+
.command("targets")
|
|
496
|
+
.description("Print the capability matrix: what each target does with each ingredient type (native, converted, unsupported), marking the targets this workspace uses. Read-only")
|
|
497
|
+
.option("--workspace <dir>", "workspace whose targets to mark (default: . when it holds a craftar.yaml)")
|
|
498
|
+
.option("--json", "machine-readable output", false)
|
|
499
|
+
.action(async (o) => {
|
|
500
|
+
let inUse = null;
|
|
501
|
+
const warnings = [];
|
|
502
|
+
const root = o.workspace ?? ".";
|
|
503
|
+
if (!(await exists(path.join(root, WORKSPACE_FILE)))) {
|
|
504
|
+
// craftar.yaml does not exist
|
|
505
|
+
if (o.workspace !== undefined) {
|
|
506
|
+
// Explicit --workspace: exit 1 with loadWorkspace's message
|
|
507
|
+
try {
|
|
508
|
+
await loadWorkspace(root);
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// No --workspace: just show unmarked, no warning
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
// craftar.yaml exists, try to load
|
|
518
|
+
try {
|
|
519
|
+
const ws = await loadWorkspace(root);
|
|
520
|
+
inUse = resolve(ws.forge, ws.config).targets;
|
|
521
|
+
}
|
|
522
|
+
catch (e) {
|
|
523
|
+
// Load or resolve failed — warning, not error
|
|
524
|
+
warnings.push(`targets in use not shown: ${e instanceof Error ? e.message : String(e)}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const result = listTargets(inUse);
|
|
528
|
+
if (o.json) {
|
|
529
|
+
return console.log(JSON.stringify({ ...result, warnings }, null, 2));
|
|
530
|
+
}
|
|
531
|
+
// Text mode
|
|
532
|
+
const targetLine = result.targets
|
|
533
|
+
.map((t) => (t.inUse === true ? `${t.name} (in use)` : t.name))
|
|
534
|
+
.join(" · ");
|
|
535
|
+
console.log(pc.bold(`craftar targets — ${targetLine}`));
|
|
536
|
+
console.log();
|
|
537
|
+
// Matrix table
|
|
538
|
+
const colWidth = 14;
|
|
539
|
+
const typeColWidth = 10;
|
|
540
|
+
const header = " " + " ".repeat(typeColWidth) + result.targets.map((t) => t.name.padEnd(colWidth)).join("");
|
|
541
|
+
console.log(header.trimEnd());
|
|
542
|
+
for (const type of result.ingredientTypes) {
|
|
543
|
+
const row = " " + type.padEnd(typeColWidth) + result.targets.map((t) => {
|
|
544
|
+
const cap = t.capabilities[type];
|
|
545
|
+
return cap.state.padEnd(colWidth);
|
|
546
|
+
}).join("");
|
|
547
|
+
console.log(row.trimEnd());
|
|
548
|
+
}
|
|
549
|
+
console.log();
|
|
550
|
+
// Legend
|
|
551
|
+
console.log(" native written in the tool's own place for that type, as the Forge holds it");
|
|
552
|
+
console.log(" converted written in another form — see its line below");
|
|
553
|
+
console.log(" unsupported not written; sync warns and names an ingredient aimed at this target");
|
|
554
|
+
console.log();
|
|
555
|
+
// Paths block — type-major (types outer, targets inner); align labels to widest
|
|
556
|
+
console.log("paths");
|
|
557
|
+
const pathLines = [];
|
|
558
|
+
for (const type of INGREDIENT_TYPES) {
|
|
559
|
+
for (const t of result.targets) {
|
|
560
|
+
const cap = t.capabilities[type];
|
|
561
|
+
if (cap.output.length > 0) {
|
|
562
|
+
const pathStr = cap.output.join(", ");
|
|
563
|
+
const note = cap.note ? ` — ${cap.note}` : "";
|
|
564
|
+
pathLines.push({ label: `${t.name} · ${type}`, content: `${pathStr}${note}` });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const maxLabelLen = Math.max(...pathLines.map((l) => l.label.length));
|
|
569
|
+
for (const line of pathLines) {
|
|
570
|
+
console.log(` ${line.label.padEnd(maxLabelLen)} ${line.content}`);
|
|
571
|
+
}
|
|
572
|
+
for (const w of warnings)
|
|
573
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
574
|
+
});
|
|
190
575
|
/* ---------------------------------------------------------------- forge */
|
|
191
576
|
const forge = program.command("forge").description("Operate on the Forge itself rather than on a workspace");
|
|
192
577
|
forge
|