Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "craftar",
"version": "0.34.1",
"version": "0.34.2",
"description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.",
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE
const DIFF_EXIT_CODE_FETCH_MODE: FetchMode = "sync";

const program = new Command();
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.34.1");
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.34.2");

/* ---------------------------------------------------------------- import */
/** The words of one output-path gate (`gateOutsideForge`), declared before the commands that run at load: what is written, by which command, and its two refusals' endings. */
Expand Down
17 changes: 15 additions & 2 deletions src/core/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AGENTS_MD_BUDGET_KEY, agentsMd } from "../emitters/agents-md.js";
import type { EmitBase, Emitter, PlannedFile } from "../emitters/types.js";
import { written } from "./capabilities.js";
import { planServices } from "./services.js";
import { INTEGRATIONS_PREFIX, readsIntegrations } from "./integrations.js";

export const WORKSPACE_FILE = "craftar.yaml";
export const LOCAL_FILE = "craftar.local.yaml";
Expand Down Expand Up @@ -624,10 +625,22 @@ export async function plan(ws: Workspace): Promise<Plan> {
assertInsideWorkspace(files);
const missing: Plan["missingParams"] = [];
const declared = declaredWithoutValue({ resolution });
// Keys declared by ANY resolved ingredient (with or without default) — for the warning condition
const declaredByAny = new Set<string>();
for (const ing of resolution.ingredients) {
for (const key of Object.keys(ing.meta.params ?? {})) {
declaredByAny.add(key);
}
}
for (const [key, refs] of [...missingParams].sort(([a], [b]) => a.localeCompare(b))) {
const warning = `param "${key}" has no value in any layer — left verbatim (${[...refs].sort().join(", ")})`;
warnings.push(warning);
missing.push({ key, refs: [...refs].sort(), warning, declaredBy: [...(declared.get(key) ?? [])].sort() });
const declaredBy = [...(declared.get(key) ?? [])].sort();
// Warn when the key is declared by ANY resolved ingredient (with or without default), or when it
// is under `integrations.` in a Forge at schema 3+. An undeclared key is left silently.
if (declaredByAny.has(key) || (readsIntegrations(ws.forge.manifest) && key.startsWith(INTEGRATIONS_PREFIX))) {
warnings.push(warning);
}
missing.push({ key, refs: [...refs].sort(), warning, declaredBy });
}
const params: Plan["params"] = new Map();
for (const [ref, { ing, keys }] of cited) {
Expand Down
107 changes: 102 additions & 5 deletions src/importers/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,11 +263,53 @@ async function planImport(opts: ImportOptions, stage: ForgeStage): Promise<{ rep

/* ---- scripts & hooks ---- */
for (const kind of ["scripts", "hooks"] as const) {
const type = kind === "scripts" ? "script" : "hook";
// Read each file and group by the base name (lowercased) that readEmitted gives
const emittedList: EmittedSource[] = [];
for (const f of await safeList(path.join(claudeDir, kind))) {
if (f === ".gitkeep" || f.startsWith("__pycache__") || f.endsWith(".pyc")) continue;
const emitted = await readEmitted(claudeDir, kind === "scripts" ? "script" : "hook", f, origin);
const emitted = await readEmitted(claudeDir, type, f, origin);
if (!emitted) continue;
read(emitted.meta, emitted.files, emitted.scan, (ref) => addRef(refs.base, ref));
emittedList.push(emitted);
}
// Group by ingredient name (lowercased base name)
const groups = new Map<string, EmittedSource[]>();
for (const e of emittedList) {
const name = e.meta.name; // already lowercased by readEmitted
const list = groups.get(name) ?? [];
list.push(e);
groups.set(name, list);
}
// Queue one ingredient per group
for (const [name, parts] of groups) {
// Case collision check: do any two files in the group have names equal ignoring case?
const allFiles = parts.flatMap((p) => (p.meta as { files?: string[] }).files ?? []);
const seenLower = new Map<string, string>(); // lowercase → original
let collision: { a: string; b: string } | null = null;
for (const f of allFiles) {
const lower = f.toLowerCase();
const prev = seenLower.get(lower);
if (prev !== undefined && prev !== f) {
// Collision: two different file names map to same lowercase
const [a, b] = [prev, f].sort();
collision = { a, b };
break;
}
seenLower.set(lower, f);
}
if (collision) {
report.rejected.push({
name: `${type}/${name}`,
reason: `files ${collision.a} and ${collision.b} differ only in case — they would collide on a case-insensitive checkout`,
});
continue;
}
const merged = mergeEmitted(parts);
// Copy any warnings from mergeEmitted into the report
if (merged.warnings) {
for (const w of merged.warnings) report.warnings.push(w);
}
read(merged.meta, merged.files, merged.scan, (ref) => addRef(refs.base, ref));
}
}

Expand Down Expand Up @@ -637,6 +679,32 @@ export interface EmittedSource {
warnings?: string[];
}

/**
* Merge multiple EmittedSource parts that share a base name into one ingredient.
* The meta of `parts[0]` is kept with `files` listing every file from all parts in order;
* `files` and `scan` are the unions of all parts; `warnings` are concatenated (key omitted when empty).
*/
export function mergeEmitted(parts: EmittedSource[]): EmittedSource {
if (parts.length === 0) throw new Error("mergeEmitted: parts must not be empty");
if (parts.length === 1) return parts[0];
const first = parts[0];
const mergedMeta = { ...first.meta } as Ingredient & { files?: string[] };
// Build the files list from each part's meta.files (not from their files map keys, to preserve original order)
const filesList: string[] = [];
for (const p of parts) {
const partFiles = (p.meta as { files?: string[] }).files ?? [];
for (const f of partFiles) filesList.push(f);
}
mergedMeta.files = filesList;
const mergedFiles: Record<string, string | Buffer> = {};
for (const p of parts) Object.assign(mergedFiles, p.files);
const mergedScan: Record<string, string> = {};
for (const p of parts) Object.assign(mergedScan, p.scan);
const mergedWarnings: string[] = [];
for (const p of parts) if (p.warnings) mergedWarnings.push(...p.warnings);
return { meta: mergedMeta, files: mergedFiles, scan: mergedScan, ...(mergedWarnings.length > 0 ? { warnings: mergedWarnings } : {}) };
}

/**
* A Claude rule's `paths:`, read from its raw frontmatter. `present` says the key is there; `value` is set only
* when it is usable — a non-empty string, or a non-empty list of non-empty strings. YAML is tried first; when it
Expand Down Expand Up @@ -929,6 +997,20 @@ function bodyFromSteering(meta: Ingredient): boolean {
return meta.type === "rule" && (meta.origin?.path ?? "").startsWith(".kiro/steering/");
}

/**
* The file path to report in a rejection message (secret, marker).
* - For skill dir layout: base ends with `/`, so returns `base + rel`.
* - For grouped scripts/hooks (files.length > 1): returns `dirname(base) + "/" + rel`.
* - Otherwise: returns `base` unchanged.
*/
function fileWhere(meta: Ingredient, base: string, rel: string): string {
if (meta.type === "skill" && meta.layout === "dir") return `${base}${rel}`;
if ((meta.type === "script" || meta.type === "hook") && meta.files && meta.files.length > 1) {
return `${path.posix.dirname(base)}/${rel}`;
}
return base;
}

/** First secret-like value in an ingredient about to be imported, described by location only. */
export function secretIn(meta: Ingredient, files: Record<string, string | Buffer>, scan: Record<string, string> = {}, lineOffset = 0): string | null {
const origin = meta.origin?.path ?? `${meta.type}/${meta.name}`;
Expand All @@ -941,7 +1023,7 @@ export function secretIn(meta: Ingredient, files: Record<string, string | Buffer
const texts: { where: string; text: string; offset: number }[] = [];
if (raw) texts.push({ where: rawWhere, text: raw, offset: 1 });
for (const [rel, content] of Object.entries(files)) {
const where = meta.type === "skill" && meta.layout === "dir" ? `${origin}${rel}` : origin;
const where = fileWhere(meta, origin, rel);
// A Buffer (non-allowlisted extensions such as .pem or .bat) goes through decodeForScan:
// a UTF-16 BOM is decoded as UTF-16, otherwise a NUL byte marks it binary and unscanned.
if (typeof content === "string") texts.push({ where, text: content, offset: bodyOffset });
Expand All @@ -953,7 +1035,7 @@ export function secretIn(meta: Ingredient, files: Record<string, string | Buffer
// UTF-16 sources read on the text path: their stored text is mojibake no pattern matches,
// so the whole source file, decoded correctly, is scanned too (lines count from its top).
for (const [rel, text] of Object.entries(scan)) {
const where = meta.type === "skill" && meta.layout === "dir" ? `${origin}${rel}` : origin;
const where = fileWhere(meta, origin, rel);
texts.push({ where, text, offset: 0 });
}
for (const t of texts) {
Expand Down Expand Up @@ -1101,6 +1183,19 @@ async function writeIngredient(
}
stage.write(path.join(dir, "ingredient.yaml"), YAML.stringify(yamlMeta, { lineWidth: 0 }));
for (const [rel, content] of Object.entries(files)) stage.write(path.join(dir, rel), content);

// For script and hook, warn about leftover files in the directory
if (meta.type === "script" || meta.type === "hook") {
const diskFiles = await stage.reader().list(dir);
const plannedFiles = new Set(["ingredient.yaml", ...Object.keys(files)]);
for (const diskRel of diskFiles) {
if (!plannedFiles.has(diskRel)) {
const relDir = path.relative(forge, dir).split(path.sep).join("/");
report.warnings.push(`${relDir}/${diskRel} is not in the files of ${meta.type}/${name} — sync does not write it; remove it by hand`);
}
}
}

return `${meta.type}/${name}`;
}

Expand Down Expand Up @@ -1334,7 +1429,9 @@ export function markerIn(meta: Ingredient, files: Record<string, string | Buffer
// the importer builds meta and file keys itself, with canonical names
if (!bodyFile(meta, rel, null)) continue;
const line = firstMarkerLine(toLf(stripBom(typeof content === "string" ? content : content.toString("utf8"))));
if (line !== null) return { where: meta.type === "skill" && meta.layout === "dir" ? `${source}${rel}` : source, line: line + offset };
if (line !== null) {
return { where: fileWhere(meta, source, rel), line: line + offset };
}
}
return null;
}
Expand Down
17 changes: 8 additions & 9 deletions src/importers/drift-promote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
resolvedRecipes,
recipeFile,
ForgeStage,
mergeEmitted,
type EmittedSource,
} from "./claude-code.js";
import { decide, workspaceParams, workspaceSections, type RunContext } from "./decide.js";
Expand Down Expand Up @@ -689,23 +690,21 @@ export async function planPromote(input: PromoteInput): Promise<PromotePlan> {
throw new Error(`cannot promote ${ingredientRef} — unsupported type`);
}

// For scripts/hooks with multiple files, read each file and merge
// For scripts/hooks with multiple files, read each file and merge using mergeEmitted
const scriptHookFiles = (meta.type === "script" || meta.type === "hook") ? (meta.files ?? [rel]) : null;
let emitted: EmittedSource | null;
let mergedFiles: Record<string, string | Buffer> = {};
let mergedScan: Record<string, string> = {};

if (scriptHookFiles && scriptHookFiles.length > 1) {
// Read each file and merge
// Read each file with readEmitted, in the ingredient's files order
const parts: EmittedSource[] = [];
for (const fileRel of scriptHookFiles) {
const fileEmitted = await readEmitted(claudeDir, readType, fileRel, (r) => ({ workspace: path.basename(root), path: r }));
if (!fileEmitted) throw new Error(`cannot read ${readType} file ${fileRel} from ${claudeDir}`);
Object.assign(mergedFiles, fileEmitted.files);
Object.assign(mergedScan, fileEmitted.scan);
parts.push(fileEmitted);
}
// Use the first file's metadata as base but with files from the original ingredient
const firstEmitted = await readEmitted(claudeDir, readType, scriptHookFiles[0], (r) => ({ workspace: path.basename(root), path: r }));
emitted = firstEmitted ? { meta: { ...firstEmitted.meta, files: scriptHookFiles } as Ingredient, files: mergedFiles, scan: mergedScan } : null;
const merged = mergeEmitted(parts);
// Use the original ingredient's files list (meta.files) to preserve order
emitted = { ...merged, meta: { ...merged.meta, files: scriptHookFiles } as Ingredient };
} else {
// Single file case: call readEmitted normally
emitted = await readEmitted(claudeDir, readType, rel, (r) => ({ workspace: path.basename(root), path: r }));
Expand Down
14 changes: 8 additions & 6 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe("cli", () => {
expect(keys.filter((k) => k === ".git" || k.startsWith(".git/"))).toEqual([]);
});

it("status prints the unresolved-param warning and exits 0; sync --check still passes", async () => {
it("status with an undeclared {{key}}: no param warning, exits 0; sync --check still passes", async () => {
const s = await scenario(
{ ingredients: [rule("a", "Org: {{missing}}\n")], recipes: [recipe("base", ["rule/a"])], profiles: [profile("acme", ["base"])] },
{ config: { profile: "acme" } },
Expand All @@ -65,7 +65,8 @@ describe("cli", () => {

const st = runCli(["status", "--workspace", s.wsRoot]);
expect(st.code).toBe(0);
expect(st.stdout).toContain('param "missing" has no value in any layer');
// An undeclared key is left verbatim silently — no warning
expect(st.stdout).not.toContain('param "missing"');

expect(runCli(["sync", "--workspace", s.wsRoot]).code).toBe(0);
const check = runCli(["sync", "--check", "--workspace", s.wsRoot]);
Expand Down Expand Up @@ -4075,7 +4076,7 @@ describe("cli — add recipe / remove recipe (spec 22)", () => {
await unmoved();
});

it("test 13: nothing to sync when no file moves; a plan warning goes to stderr", async () => {
it("test 13: nothing to sync when no file moves; an undeclared param is left silently", async () => {
const s = await recipeScenario();
const unmovedS = await guard(s);
await setYaml(s, BASE_YAML + "recipes:\n remove: [base]\n");
Expand All @@ -4095,7 +4096,8 @@ describe("cli — add recipe / remove recipe (spec 22)", () => {
"recipes: base → stack-api → front-c\n" +
"next sync: 1 new, 1 orphan — run `craftar sync`\n",
);
expect(w.stderr).toBe('warn param "owner" has no value in any layer — left verbatim (rule/front-c)\n');
// An undeclared key is left verbatim silently — no warning
expect(w.stderr).toBe("");
await unmovedT();
});

Expand Down Expand Up @@ -4324,17 +4326,17 @@ describe("cli — craftar init (spec 23 §9.2)", () => {
]);
});

it("--no-sync: craftar.yaml only, the next sync line and the plan's warnings, nothing registered", async () => {
it("--no-sync: craftar.yaml only, the next sync line, nothing registered; an undeclared param is left silently", async () => {
const s = await setup();
const ws = path.join(s.root, "ws");
const r = s.init(ws, ["--no-sync", "--add-recipe", "ph"]);
expect(r.code, r.stderr).toBe(0);
// An undeclared key is left verbatim silently — no warning
expect(r.stdout).toBe(
[
`craftar init — wrote craftar.yaml in ${ws}`,
" forge ../forge · profile acme · recipes base → ph · targets claude-code (from the profile)",
"next sync: 2 new — run `craftar sync`",
" warn param \"nope\" has no value in any layer — left verbatim (rule/p)",
"",
].join("\n"),
);
Expand Down
7 changes: 4 additions & 3 deletions test/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ describe("workspace checks", () => {
expect(one((await run({ home: f.home, workspace: c })).checks, "lock")).toMatchObject({ level: "error", fix: "upgrade craftar", message: expect.stringMatching(/declares schema 9/) });
});

it("params: a declared, cited key with no value is one error, naming its citers, and plan does not repeat it", async () => {
it("params: a declared, cited key with no value is one error, naming its citers; undeclared keys are silent", async () => {
const f = await fixture({
ingredients: [rule("a", "# {{who}}\n", { params: { who: { description: "w" } } }), rule("b", "# {{who}} too\n"), rule("c", "# {{other}}\n")],
recipes: [recipe("base", ["rule/a", "rule/b", "rule/c"])],
Expand All @@ -239,8 +239,9 @@ describe("workspace checks", () => {
expect(one(r.checks, "params")).toMatchObject({ level: "error", message: '"who" declared by rule/a has no value — cited by rule/a, rule/b', fix: "set who in the profile's params or overrides.params" });
const planLines = of(r.checks, "plan").map((c) => c.message);
expect(planLines.some((x) => x.includes('param "who"'))).toBe(false);
expect(planLines.some((x) => x.includes('param "other"'))).toBe(true);
expect(of(r.checks, "plan").map((c) => c.level)).toEqual(["warn"]);
// An undeclared key is left verbatim silently — no warning in plan either
expect(planLines.some((x) => x.includes('param "other"'))).toBe(false);
expect(of(r.checks, "plan").map((c) => c.level)).toEqual(["ok"]);
});

it("params: cited is an error, cited nowhere stays a warning — one line each, and the summary counts them", async () => {
Expand Down
Loading
Loading