craftar 0.21.1 → 0.22.1

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