craftar 0.21.0 → 0.22.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.
@@ -21,6 +21,8 @@ import { bodyFile } from "../core/extract.js";
21
21
  import { GENERATED_BANNER, kiroReport } from "./kiro-report.js";
22
22
  import { deepMerge } from "../core/merge.js";
23
23
  import { outName } from "../emitters/shared.js";
24
+ import { parseYamlText } from "../core/yaml-read.js";
25
+ import { note } from "../core/unify.js";
24
26
  /** Rules that every workspace shares by intent — they seed the `base` recipe. */
25
27
  const BASE_RULES = new Set([
26
28
  "README",
@@ -118,22 +120,14 @@ async function planImport(opts, stage) {
118
120
  if (!f.endsWith(".md"))
119
121
  continue;
120
122
  const name = f.replace(/\.md$/, "");
121
- const src = await readSource(path.join(claudeDir, "rules", f));
122
- const text = toLf(stripBom(src.text));
123
123
  const sm = steeringMeta.get(name);
124
- const meta = {
125
- type: "rule",
126
- name,
127
- inclusion: sm?.inclusion ?? "always",
128
- fileMatchPattern: sm?.fileMatchPattern,
129
- file: "rule.md",
130
- targets: "*",
131
- tags: [],
132
- origin: origin(`.claude/rules/${f}`),
133
- };
134
- read(meta, { "rule.md": text }, scanOf("rule.md", src), (ref) => {
124
+ const emitted = await readEmitted(claudeDir, "rule", f, origin, sm ? { inclusion: sm.inclusion, fileMatchPattern: sm.fileMatchPattern } : undefined);
125
+ if (!emitted)
126
+ continue;
127
+ const ruleMeta = emitted.meta;
128
+ read(emitted.meta, emitted.files, emitted.scan, (ref) => {
135
129
  ruleNames.push(ref.split("/")[1]);
136
- if (meta.inclusion === "fileMatch")
130
+ if (ruleMeta.inclusion === "fileMatch")
137
131
  scopedRules.set(ref, sm?.fileMatchPattern ?? "");
138
132
  else
139
133
  refs.base.push(ref);
@@ -144,24 +138,12 @@ async function planImport(opts, stage) {
144
138
  for (const f of await safeList(path.join(claudeDir, "agents"))) {
145
139
  if (!f.endsWith(".md"))
146
140
  continue;
147
- const src = await readSource(path.join(claudeDir, "agents", f));
148
- const text = src.text;
149
- const { data, body, raw } = parseFrontmatter(text, { loose: true });
150
- const name = (data.name || f.replace(/\.md$/, "")).trim();
151
- const meta = {
152
- type: "agent",
153
- name,
154
- description: data.description?.trim(),
155
- tools: splitList(data.tools),
156
- model: data.model?.trim() || undefined,
157
- file: "agent.md",
158
- frontmatterRaw: raw ?? undefined,
159
- targets: "*",
160
- tags: [],
161
- origin: origin(`.claude/agents/${f}`),
162
- };
163
- read(meta, { "agent.md": body }, scanOf("agent.md", src), (ref) => {
164
- agentBodies.set(ref, (data.description ?? "") + "\n" + body);
141
+ const emitted = await readEmitted(claudeDir, "agent", f, origin);
142
+ if (!emitted)
143
+ continue;
144
+ const agentMeta = emitted.meta;
145
+ read(emitted.meta, emitted.files, emitted.scan, (ref) => {
146
+ agentBodies.set(ref, (agentMeta.description ?? "") + "\n" + emitted.files["agent.md"]);
165
147
  refs.base.push(ref);
166
148
  });
167
149
  }
@@ -169,22 +151,10 @@ async function planImport(opts, stage) {
169
151
  for (const f of await safeList(path.join(claudeDir, "commands"))) {
170
152
  if (!f.endsWith(".md"))
171
153
  continue;
172
- const src = await readSource(path.join(claudeDir, "commands", f));
173
- const text = src.text;
174
- const { data, body, raw } = parseFrontmatter(text, { loose: true });
175
- const meta = {
176
- type: "command",
177
- name: f.replace(/\.md$/, ""),
178
- description: data.description?.trim(),
179
- argumentHint: data["argument-hint"]?.trim() || undefined,
180
- allowedTools: data["allowed-tools"]?.trim() || undefined,
181
- file: "command.md",
182
- frontmatterRaw: raw ?? undefined,
183
- targets: "*",
184
- tags: [],
185
- origin: origin(`.claude/commands/${f}`),
186
- };
187
- read(meta, { "command.md": body }, scanOf("command.md", src), (ref) => addRef(refs.base, ref));
154
+ const emitted = await readEmitted(claudeDir, "command", f, origin);
155
+ if (!emitted)
156
+ continue;
157
+ read(emitted.meta, emitted.files, emitted.scan, (ref) => addRef(refs.base, ref));
188
158
  }
189
159
  /* ---- skills ---- */
190
160
  const skillsDir = path.join(claudeDir, "skills");
@@ -193,32 +163,13 @@ async function planImport(opts, stage) {
193
163
  for (const e of (await fs.readdir(skillsDir, { withFileTypes: true })).sort((x, y) => x.name.localeCompare(y.name))) {
194
164
  if (e.name === ".gitkeep")
195
165
  continue;
196
- if (e.isDirectory()) {
197
- const files = {};
198
- const scan = {};
199
- for (const rel of await listFiles(path.join(skillsDir, e.name))) {
200
- const abs = path.join(skillsDir, e.name, rel);
201
- if (/\.(md|txt|json|ya?ml|ps1|py|sh|js|ts)$/i.test(rel)) {
202
- const src = await readSource(abs);
203
- files[rel] = toLf(stripBom(src.text));
204
- Object.assign(scan, scanOf(rel, src));
205
- }
206
- else
207
- files[rel] = await fs.readFile(abs);
208
- }
209
- if (!files["SKILL.md"]) {
166
+ const emitted = await readEmitted(claudeDir, "skill", e.name, origin);
167
+ if (!emitted) {
168
+ if (e.isDirectory())
210
169
  report.warnings.push(`skill dir ${e.name} has no SKILL.md; skipped`);
211
- continue;
212
- }
213
- const meta = { type: "skill", name: e.name, layout: "dir", targets: "*", tags: [], origin: origin(`.claude/skills/${e.name}/`) };
214
- read(meta, files, scan, (ref) => addRef(refs.base, ref));
215
- }
216
- else if (e.name.endsWith(".md")) {
217
- const src = await readSource(path.join(skillsDir, e.name));
218
- const text = toLf(stripBom(src.text));
219
- const meta = { type: "skill", name: e.name.replace(/\.md$/, ""), layout: "file", targets: "*", tags: [], origin: origin(`.claude/skills/${e.name}`) };
220
- read(meta, { "SKILL.md": text }, scanOf("SKILL.md", src), (ref) => addRef(refs.base, ref));
170
+ continue;
221
171
  }
172
+ read(emitted.meta, emitted.files, emitted.scan, (ref) => addRef(refs.base, ref));
222
173
  }
223
174
  }
224
175
  /* ---- scripts & hooks ---- */
@@ -226,20 +177,10 @@ async function planImport(opts, stage) {
226
177
  for (const f of await safeList(path.join(claudeDir, kind))) {
227
178
  if (f === ".gitkeep" || f.startsWith("__pycache__") || f.endsWith(".pyc"))
228
179
  continue;
229
- const abs = path.join(claudeDir, kind, f);
230
- if (!(await fs.stat(abs)).isFile())
180
+ const emitted = await readEmitted(claudeDir, kind === "scripts" ? "script" : "hook", f, origin);
181
+ if (!emitted)
231
182
  continue;
232
- const src = /\.(ps1|py|sh|js|ts|cjs|mjs|json|md|txt|ya?ml)$/i.test(f) ? await readSource(abs) : null;
233
- const content = src ? toLf(stripBom(src.text)) : await fs.readFile(abs);
234
- const meta = {
235
- type: kind === "scripts" ? "script" : "hook",
236
- name: f.replace(/\.[^.]+$/, "").toLowerCase(),
237
- files: [f],
238
- targets: ["claude-code"],
239
- tags: [],
240
- origin: origin(`.claude/${kind}/${f}`),
241
- };
242
- read(meta, { [f]: content }, src ? scanOf(f, src) : {}, (ref) => addRef(refs.base, ref));
183
+ read(emitted.meta, emitted.files, emitted.scan, (ref) => addRef(refs.base, ref));
243
184
  }
244
185
  }
245
186
  /* ---- MCP servers ---- */
@@ -470,6 +411,162 @@ export class ForgeStage {
470
411
  throw new Error(`${e instanceof Error ? e.message : String(e)}\nThe import failed while writing into the Forge (${already}); restore or remove those paths before re-running.`, { cause: e });
471
412
  }
472
413
  }
414
+ /** Staged paths in staging order, Forge-relative with `/`; `created` is false when the path exists on disk under `root`. */
415
+ async entries() {
416
+ const result = [];
417
+ for (const [abs] of this.files) {
418
+ const rel = path.relative(this.root, abs).split(path.sep).join("/");
419
+ const created = !(await exists(abs));
420
+ result.push({ rel, abs, created });
421
+ }
422
+ return result;
423
+ }
424
+ /** Write every staged file under another root (same relative paths), in the order given by `order` (a comparator over `rel`), noting each in `journal` before it is touched. */
425
+ async flushTo(root, opts) {
426
+ // Build entries in staging order
427
+ const entries = [];
428
+ for (const [abs, content] of this.files) {
429
+ const rel = path.relative(this.root, abs).split(path.sep).join("/");
430
+ entries.push({ rel, content });
431
+ }
432
+ // Sort if order given
433
+ if (opts?.order) {
434
+ entries.sort((a, b) => opts.order(a.rel, b.rel));
435
+ }
436
+ // Write each entry to the target root
437
+ for (const { rel, content } of entries) {
438
+ const targetAbs = path.join(root, rel);
439
+ await note(opts?.journal, targetAbs);
440
+ await fs.mkdir(path.dirname(targetAbs), { recursive: true });
441
+ await fs.writeFile(targetAbs, content);
442
+ }
443
+ }
444
+ }
445
+ /**
446
+ * One ingredient as import reads it from a workspace's `.claude/` tree. `rel` is the path under
447
+ * `.claude/<folder>/` (a file, or a skill's directory name). Returns `null` where `planImport`
448
+ * skips: a skill directory without `SKILL.md`, a non-file under scripts/hooks.
449
+ */
450
+ export async function readEmitted(claudeDir, type, rel, origin, steering) {
451
+ if (type === "rule") {
452
+ const name = rel.replace(/\.md$/, "");
453
+ const src = await readSource(path.join(claudeDir, "rules", rel));
454
+ const text = toLf(stripBom(src.text));
455
+ const meta = {
456
+ type: "rule",
457
+ name,
458
+ inclusion: steering?.inclusion ?? "always",
459
+ fileMatchPattern: steering?.fileMatchPattern,
460
+ file: "rule.md",
461
+ targets: "*",
462
+ tags: [],
463
+ origin: origin(`.claude/rules/${rel}`),
464
+ };
465
+ return { meta, files: { "rule.md": text }, scan: scanOf("rule.md", src) };
466
+ }
467
+ if (type === "agent") {
468
+ const src = await readSource(path.join(claudeDir, "agents", rel));
469
+ const text = src.text;
470
+ const { data, body, raw } = parseFrontmatter(text, { loose: true });
471
+ const name = (data.name || rel.replace(/\.md$/, "")).trim();
472
+ const meta = {
473
+ type: "agent",
474
+ name,
475
+ description: data.description?.trim(),
476
+ tools: splitList(data.tools),
477
+ model: data.model?.trim() || undefined,
478
+ file: "agent.md",
479
+ frontmatterRaw: raw ?? undefined,
480
+ targets: "*",
481
+ tags: [],
482
+ origin: origin(`.claude/agents/${rel}`),
483
+ };
484
+ return { meta, files: { "agent.md": body }, scan: scanOf("agent.md", src) };
485
+ }
486
+ if (type === "command") {
487
+ const src = await readSource(path.join(claudeDir, "commands", rel));
488
+ const text = src.text;
489
+ const { data, body, raw } = parseFrontmatter(text, { loose: true });
490
+ const meta = {
491
+ type: "command",
492
+ name: rel.replace(/\.md$/, ""),
493
+ description: data.description?.trim(),
494
+ argumentHint: data["argument-hint"]?.trim() || undefined,
495
+ allowedTools: data["allowed-tools"]?.trim() || undefined,
496
+ file: "command.md",
497
+ frontmatterRaw: raw ?? undefined,
498
+ targets: "*",
499
+ tags: [],
500
+ origin: origin(`.claude/commands/${rel}`),
501
+ };
502
+ return { meta, files: { "command.md": body }, scan: scanOf("command.md", src) };
503
+ }
504
+ if (type === "skill") {
505
+ const skillDir = path.join(claudeDir, "skills", rel);
506
+ const stat = await fs.lstat(skillDir).catch(() => null);
507
+ if (!stat)
508
+ return null;
509
+ if (stat.isDirectory()) {
510
+ const files = {};
511
+ const scan = {};
512
+ for (const fileRel of await listFiles(skillDir)) {
513
+ const abs = path.join(skillDir, fileRel);
514
+ if (/\.(md|txt|json|ya?ml|ps1|py|sh|js|ts)$/i.test(fileRel)) {
515
+ const src = await readSource(abs);
516
+ files[fileRel] = toLf(stripBom(src.text));
517
+ Object.assign(scan, scanOf(fileRel, src));
518
+ }
519
+ else {
520
+ files[fileRel] = await fs.readFile(abs);
521
+ }
522
+ }
523
+ if (!files["SKILL.md"])
524
+ return null;
525
+ const meta = {
526
+ type: "skill",
527
+ name: rel,
528
+ layout: "dir",
529
+ targets: "*",
530
+ tags: [],
531
+ origin: origin(`.claude/skills/${rel}/`),
532
+ };
533
+ return { meta, files, scan };
534
+ }
535
+ else if (rel.endsWith(".md")) {
536
+ const src = await readSource(skillDir);
537
+ const text = toLf(stripBom(src.text));
538
+ const meta = {
539
+ type: "skill",
540
+ name: rel.replace(/\.md$/, ""),
541
+ layout: "file",
542
+ targets: "*",
543
+ tags: [],
544
+ origin: origin(`.claude/skills/${rel}`),
545
+ };
546
+ return { meta, files: { "SKILL.md": text }, scan: scanOf("SKILL.md", src) };
547
+ }
548
+ return null;
549
+ }
550
+ if (type === "script" || type === "hook") {
551
+ const kind = type === "script" ? "scripts" : "hooks";
552
+ const abs = path.join(claudeDir, kind, rel);
553
+ // fs.stat throws on a dangling link (ENOENT) — spec 30r1 §3.4 says we must let it throw
554
+ const stat = await fs.stat(abs);
555
+ if (!stat.isFile())
556
+ return null;
557
+ const src = /\.(ps1|py|sh|js|ts|cjs|mjs|json|md|txt|ya?ml)$/i.test(rel) ? await readSource(abs) : null;
558
+ const content = src ? toLf(stripBom(src.text)) : await fs.readFile(abs);
559
+ const meta = {
560
+ type,
561
+ name: rel.replace(/\.[^.]+$/, "").toLowerCase(),
562
+ files: [rel],
563
+ targets: ["claude-code"],
564
+ tags: [],
565
+ origin: origin(`.claude/${kind}/${rel}`),
566
+ };
567
+ return { meta, files: { [rel]: content }, scan: src ? scanOf(rel, src) : {} };
568
+ }
569
+ return null;
473
570
  }
474
571
  /**
475
572
  * A reviewer agent belongs to the stack of a scoped rule when its name is derived from the rule name
@@ -489,7 +586,7 @@ function addRef(list, ref) {
489
586
  list.push(ref);
490
587
  }
491
588
  /** First secret-like value in an ingredient about to be imported, described by location only. */
492
- function secretIn(meta, files, scan = {}) {
589
+ export function secretIn(meta, files, scan = {}) {
493
590
  const origin = meta.origin?.path ?? `${meta.type}/${meta.name}`;
494
591
  const raw = "frontmatterRaw" in meta ? meta.frontmatterRaw : undefined;
495
592
  // Bodies of agents and commands start after `---`, the raw block and the closing `---`.
@@ -697,7 +794,7 @@ async function writeIngredient(stage, meta, files, profile, report, scan = {}, r
697
794
  * `env` value, a name that is not slug-like) fails the whole import, naming its source, before the
698
795
  * first write — 0.2.4 wrote a Forge no command could load (spec 07, Ruling 6).
699
796
  */
700
- function validateImported(meta) {
797
+ export function validateImported(meta) {
701
798
  const r = IngredientSchema.safeParse(meta);
702
799
  if (r.success)
703
800
  return r.data;
@@ -820,7 +917,7 @@ async function writeOwnedRecipe(o, name, list, description) {
820
917
  for (const x of next.slice(kept.length))
821
918
  seq.items.push(doc.createNode(x));
822
919
  });
823
- const after = RecipeSchema.safeParse(YAML.parse(stripBom(content)) ?? {});
920
+ const after = RecipeSchema.safeParse(parseYamlText(label, content) ?? {});
824
921
  if (!after.success || !isDeepStrictEqual(after.data, { ...before, ingredients: next })) {
825
922
  throw new Error(`import: cannot edit ${label} in place (the edit does not read back as exactly the new ingredients) — reformat it by hand, commit, and re-run`);
826
923
  }
@@ -833,7 +930,7 @@ async function writeOwnedRecipe(o, name, list, description) {
833
930
  * declaring another name, would leave two recipes under one name and loadForge would keep one.
834
931
  * Refused before the first write, as I4 refuses the same split for a profile.
835
932
  */
836
- async function recipeFile(o, name) {
933
+ export async function recipeFile(o, name, command = "import") {
837
934
  const file = path.join(o.dir, `${name}.yaml`);
838
935
  for (const f of await o.stage.reader().list(o.dir)) {
839
936
  if (f.includes("/") || !/\.ya?ml$/.test(f))
@@ -841,14 +938,14 @@ async function recipeFile(o, name) {
841
938
  const abs = path.join(o.dir, f);
842
939
  const declared = parseYaml(abs, stripBom(await o.stage.readText(abs)), RecipeSchema).name;
843
940
  if (abs === file && declared !== name)
844
- throw new Error(`import: recipes/${f} is recipe ${declared} — import writes recipe ${name} there`);
941
+ throw new Error(`${command}: recipes/${f} is recipe ${declared} — ${command} writes recipe ${name} there`);
845
942
  if (abs !== file && declared === name)
846
- throw new Error(`import: recipe ${name} is recipes/${f} — import writes recipes/${name}.yaml`);
943
+ throw new Error(`${command}: recipe ${name} is recipes/${f} — ${command} writes recipes/${name}.yaml`);
847
944
  }
848
945
  return file;
849
946
  }
850
947
  /** The recipes a profile resolves inside the Forge; fails closed — a profile that does not resolve counts as using everything. */
851
- function recipesOf(forge, profile) {
948
+ export function recipesOf(forge, profile) {
852
949
  try {
853
950
  return new Set(resolve(forge, WorkspaceConfigSchema.parse({ forge: ".", profile })).recipes);
854
951
  }
@@ -884,7 +981,7 @@ async function existingProfile(forge, profile, loaded) {
884
981
  * created or variant source, which sync would read as structure. The line counts from the top of
885
982
  * the workspace file, as the secret scan's does: an agent or command body starts after its frontmatter.
886
983
  */
887
- function markerIn(meta, files, source) {
984
+ export function markerIn(meta, files, source) {
888
985
  const raw = "frontmatterRaw" in meta ? meta.frontmatterRaw : undefined;
889
986
  const offset = raw ? raw.split("\n").length + 2 : 0;
890
987
  for (const [rel, content] of Object.entries(files)) {
@@ -964,7 +1061,7 @@ async function writeProfile(stage, forge, name, computed, ctx, report) {
964
1061
  doc.set("targets", targets);
965
1062
  }
966
1063
  });
967
- const after = ProfileSchema.safeParse(YAML.parse(stripBom(content)) ?? {});
1064
+ const after = ProfileSchema.safeParse(parseYamlText(label, content) ?? {});
968
1065
  const expected = { ...before, recipes, targets, params: { ...before.params, ...values }, sections: deepMerge(before.sections, sectionValues) };
969
1066
  if (!after.success || !isDeepStrictEqual(after.data, expected)) {
970
1067
  throw new Error(`import: cannot edit ${label} in place (the edit does not read back as exactly the intended change) — reformat it by hand, commit, and re-run`);