@domandigital/craft 0.14.0 → 0.16.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.
@@ -1,15 +1,23 @@
1
+ import {
2
+ compareDirections,
3
+ compareToDeclared,
4
+ formatDirectionMatches,
5
+ summariseDirection,
6
+ validateDirection
7
+ } from "./chunk-WAV6AKR4.js";
1
8
  import {
2
9
  loadNull
3
- } from "./chunk-XVBR2UPH.js";
10
+ } from "./chunk-LWVGIJ5A.js";
4
11
  import {
5
12
  parseFlags,
6
13
  toJson
7
- } from "./chunk-3OTKMKJQ.js";
14
+ } from "./chunk-LXDNIJFG.js";
8
15
  import {
9
16
  readSnapshot
10
17
  } from "./chunk-WUACK2HX.js";
11
18
  import {
12
19
  COMPONENT_ROLES,
20
+ SHARED_PART,
13
21
  auditSnapshot,
14
22
  closestComponents,
15
23
  estateComponentPairs,
@@ -18,7 +26,7 @@ import {
18
26
  groundName,
19
27
  hueName,
20
28
  shapeName
21
- } from "./chunk-GQVEYTBY.js";
29
+ } from "./chunk-B2MJLGF6.js";
22
30
 
23
31
  // src/estate/cli.ts
24
32
  import { existsSync, readFileSync, writeFileSync } from "fs";
@@ -37,7 +45,7 @@ function addSite(register, site) {
37
45
  }
38
46
  function sharedParts(a, b, d = fingerprintDistance(a, b)) {
39
47
  const out = [];
40
- const close = (k) => d.parts[k] <= 0.2;
48
+ const close = (k) => d.parts[k] <= SHARED_PART;
41
49
  if (close("accent")) out.push(a.accent || b.accent ? `${hueName(a.accent)} accent` : "no accent");
42
50
  if (close("type")) {
43
51
  if (a.display.family === b.display.family) out.push(`${a.display.family} headline`);
@@ -52,14 +60,14 @@ function sharedParts(a, b, d = fingerprintDistance(a, b)) {
52
60
  }
53
61
  function compareToEstate(target, register, options = {}) {
54
62
  const at = options.siblingAt ?? SIBLING_AT;
55
- return register.sites.filter((s) => s.id !== options.exclude).map((s) => {
63
+ return register.sites.filter((s) => s.id !== options.exclude && s.fingerprint).map((s) => {
56
64
  const d = fingerprintDistance(target, s.fingerprint);
57
65
  return { id: s.id, distance: d.total, sibling: d.total < at, shared: sharedParts(target, s.fingerprint, d) };
58
66
  }).sort((a, b) => a.distance - b.distance);
59
67
  }
60
68
  function estatePairs(register, siblingAt = SIBLING_AT) {
61
69
  const out = [];
62
- const s = register.sites;
70
+ const s = register.sites.filter((site) => site.fingerprint);
63
71
  for (let i = 0; i < s.length; i += 1) {
64
72
  for (let j = i + 1; j < s.length; j += 1) {
65
73
  const d = fingerprintDistance(s[i].fingerprint, s[j].fingerprint);
@@ -81,7 +89,7 @@ function siblingLine(briefs) {
81
89
  }
82
90
 
83
91
  // src/estate/cli.ts
84
- var USAGE = "usage: craft estate add <url | snapshot.json> --id <id> [--client <name>] | craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--component <role>] [--json] [--strict]";
92
+ var USAGE = "usage: craft estate add [<url | snapshot.json>] --id <id> [--client <name>] [--direction <art-direction.json>] | craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--component <role>] [--directions] [--json] [--strict]";
85
93
  function loadEstate(path) {
86
94
  if (!existsSync(path)) return emptyEstate();
87
95
  const data = JSON.parse(readFileSync(path, "utf8"));
@@ -95,7 +103,7 @@ async function snapshotOf(target, cwd) {
95
103
  return snap;
96
104
  }
97
105
  if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL, a snapshot file, nor a site in the register`);
98
- const { snapshotUrl } = await import("./audit-6WHR7ZTV.js");
106
+ const { snapshotUrl } = await import("./audit-UJCUWORL.js");
99
107
  return snapshotUrl(target);
100
108
  }
101
109
  var line = (distance, sibling, shared) => `${distance.toFixed(2)}${sibling ? " SIBLING" : ""}${shared.length ? ` shares ${shared.join(", ")}` : ""}`;
@@ -115,7 +123,7 @@ function componentRole(name) {
115
123
  if (!COMPONENT_ROLES.includes(name)) throw new Error(`--component takes one of ${COMPONENT_ROLES.join(", ")}`);
116
124
  return name;
117
125
  }
118
- var withRole = (register, role) => register.sites.filter((s) => (s.fingerprint.sections ?? []).some((x) => x.role === role)).length;
126
+ var withRole = (register, role) => register.sites.filter((s) => (s.fingerprint?.sections ?? []).some((x) => x.role === role)).length;
119
127
  function formatComponentPairs(role, pairs, measured, sites) {
120
128
  const lines = [`craft estate: ${role} components, ${measured} of ${sites} sites have one, closest pairs first (no sibling line yet: ranked, not judged)`];
121
129
  for (const p of pairs) lines.push(` ${`${p.a} + ${p.b}`.padEnd(34)} ${p.distance.toFixed(2)}${p.shared.length ? ` shares ${p.shared.join(", ")}` : ""}`);
@@ -134,6 +142,19 @@ async function runEstate(args, io) {
134
142
  const file = resolve(io.cwd, flags.estate ?? "estate.json");
135
143
  const register = loadEstate(file);
136
144
  const at = flags.null ? siblingLine(flags.null.split(",").map((p) => loadNull(p.trim(), io.cwd).runs.map((r) => r.fingerprint))) : SIBLING_AT;
145
+ const directionFile = flags.direction ? JSON.parse(readFileSync(resolve(io.cwd, flags.direction), "utf8")) : void 0;
146
+ if (directionFile && !validateDirection(directionFile).valid) throw new Error("the direction has errors: run craft direction validate before registering it");
147
+ const declared = () => summariseDirection(directionFile);
148
+ if (sub === "add" && !target && flags.id && flags.direction) {
149
+ const existing = register.sites.find((s) => s.id === flags.id);
150
+ const direction = declared();
151
+ const next = addSite(register, { ...existing ?? { id: flags.id, client: flags.client ?? directionFile.client, url: "", tells: [], addedAt: (/* @__PURE__ */ new Date()).toISOString() }, direction });
152
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}
153
+ `);
154
+ io.out(`craft estate: ${flags.id} direction recorded.`);
155
+ io.out(formatDirectionMatches(flags.id, compareToDeclared(direction, next.sites, flags.id)));
156
+ return 0;
157
+ }
137
158
  if (sub === "add") {
138
159
  if (!target || !flags.id) throw new Error(USAGE);
139
160
  const snap = await snapshotOf(target, io.cwd);
@@ -144,7 +165,8 @@ async function runEstate(args, io) {
144
165
  url: snap.url,
145
166
  fingerprint: fp,
146
167
  tells: [...new Set(auditSnapshot(snap).findings.map((f) => f.tell))].sort(),
147
- addedAt: snap.capturedAt
168
+ addedAt: snap.capturedAt,
169
+ ...flags.direction ? { direction: declared() } : register.sites.find((s) => s.id === flags.id)?.direction ? { direction: register.sites.find((s) => s.id === flags.id).direction } : {}
148
170
  });
149
171
  writeFileSync(file, `${JSON.stringify(next, null, 2)}
150
172
  `);
@@ -152,6 +174,17 @@ async function runEstate(args, io) {
152
174
  io.out(formatMatches(flags.id, compareToEstate(fp, next, { exclude: flags.id, siblingAt: at }).slice(0, 3), at));
153
175
  return 0;
154
176
  }
177
+ if (sub === "compare" && flags.directions) {
178
+ const withDirection = register.sites.filter((s) => s.direction);
179
+ const pairs = withDirection.flatMap((a, i) => withDirection.slice(i + 1).map((b) => ({ a: a.id, b: b.id, ...compareDirections(a.direction, b.direction) })));
180
+ pairs.sort((x, y) => y.shared.length - x.shared.length);
181
+ if (flags.json) io.out(toJson({ sites: withDirection.length, pairs }));
182
+ else {
183
+ io.out(`craft estate: ${withDirection.length} of ${register.sites.length} sites have a declared direction, ${pairs.filter((p) => p.flagged).length} pair(s) worth a look (ranked, not judged)`);
184
+ for (const p of pairs) io.out(` ${`${p.a} + ${p.b}`.padEnd(34)} ${p.flagged ? "LOOK " : " "}${p.shared.length ? `shares ${p.shared.join(", ")}` : "nothing shared"}`);
185
+ }
186
+ return 0;
187
+ }
155
188
  if (sub === "compare" && flags.component) {
156
189
  const role = componentRole(flags.component);
157
190
  if (!target) {
@@ -161,9 +194,10 @@ async function runEstate(args, io) {
161
194
  return 0;
162
195
  }
163
196
  const known = register.sites.find((s) => s.id === target);
197
+ if (known && !known.fingerprint) throw new Error(`${target} has a declared direction only; add a snapshot to compare rendered components`);
164
198
  const fp = known ? known.fingerprint : fingerprint(await snapshotOf(target, io.cwd));
165
199
  if (!(fp.sections ?? []).some((x) => x.role === role)) throw new Error(`${known?.id ?? target} has no ${role} section measured (roles need snapshot version 2)`);
166
- const pairs = register.sites.filter((s) => s.id !== known?.id).flatMap((s) => {
200
+ const pairs = register.sites.filter((s) => s.id !== known?.id && s.fingerprint).flatMap((s) => {
167
201
  const c = closestComponents(fp, s.fingerprint, role);
168
202
  return c ? [{ a: known?.id ?? target, b: s.id, ...c }] : [];
169
203
  }).sort((x, y) => x.distance - y.distance);
@@ -177,6 +211,7 @@ async function runEstate(args, io) {
177
211
  return flags.strict && pairs.some((p) => p.sibling) ? 1 : 0;
178
212
  }
179
213
  const known = register.sites.find((s) => s.id === target);
214
+ if (known && !known.fingerprint) throw new Error(`${target} has a declared direction only; add a snapshot to compare rendered sites`);
180
215
  const fp = known ? known.fingerprint : fingerprint(await snapshotOf(target, io.cwd));
181
216
  const matches = compareToEstate(fp, register, { exclude: known?.id, siblingAt: at });
182
217
  io.out(flags.json ? toJson({ siblingAt: at, matches }) : formatMatches(known?.id ?? target, matches, at));
@@ -1,12 +1,16 @@
1
+ import {
2
+ generate
3
+ } from "./chunk-OQXTQQAU.js";
1
4
  import {
2
5
  parseFlags,
3
6
  toJson
4
- } from "./chunk-3OTKMKJQ.js";
7
+ } from "./chunk-LXDNIJFG.js";
5
8
  import {
6
9
  readSnapshot
7
10
  } from "./chunk-WUACK2HX.js";
8
11
  import {
9
12
  CATALOGUE_VERSION,
13
+ MIN_RUNS,
10
14
  NULL_VERSION,
11
15
  auditSnapshot,
12
16
  checkCopy,
@@ -17,34 +21,20 @@ import {
17
21
  hueSwatch,
18
22
  nullPrompt,
19
23
  scanSource
20
- } from "./chunk-GQVEYTBY.js";
24
+ } from "./chunk-B2MJLGF6.js";
21
25
 
22
26
  // src/null/cli.ts
23
- import { spawn } from "child_process";
24
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
25
- import { tmpdir } from "os";
26
- import { join, relative, resolve } from "path";
27
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
28
+ import { createServer } from "http";
29
+ import { extname, join, relative, resolve, sep } from "path";
27
30
  import { pathToFileURL } from "url";
28
31
  var readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
32
+ var NULL_USAGE = [
33
+ 'usage: craft null build --brief "<text>" --out <dir> [--runs 20] [--parallel 4] [--model <name>]',
34
+ ' craft null import <dir> --builder <name> --brief "<text>" [--out <dir>] [--model <name>]',
35
+ ' craft null prompt --brief "<text>"'
36
+ ].join("\n");
29
37
  var pad = (n) => String(n).padStart(2, "0");
30
- function generate(prompt, model) {
31
- const bin = process.env.CRAFT_CLAUDE ?? "claude";
32
- const args = ["-p", "--tools", "", "--no-session-persistence", "--disable-slash-commands", "--setting-sources", "", "--strict-mcp-config", ...model ? ["--model", model] : [], prompt];
33
- const cwd = mkdtempSync(join(tmpdir(), "craft-null-"));
34
- return new Promise((done, fail) => {
35
- const child = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
36
- let out = "";
37
- let err = "";
38
- child.stdout.on("data", (d) => out += d.toString());
39
- child.stderr.on("data", (d) => err += d.toString());
40
- child.on("error", (e) => fail(new Error(`${bin}: ${e.message}. Install Claude Code, or set CRAFT_CLAUDE.`)));
41
- child.on("close", (code) => {
42
- rmSync(cwd, { recursive: true, force: true });
43
- if (code === 0) done(out);
44
- else fail(new Error(`${bin} exited ${code}: ${(err || out).trim().slice(0, 200)}`));
45
- });
46
- });
47
- }
48
38
  async function pool(items, size, work) {
49
39
  const queue = [...items];
50
40
  await Promise.all(Array.from({ length: Math.max(1, Math.min(size, queue.length)) }, async () => {
@@ -55,6 +45,15 @@ function nullPath(target, cwd) {
55
45
  const abs = resolve(cwd, target);
56
46
  return existsSync(abs) && statSync(abs).isDirectory() ? join(abs, "null.json") : abs;
57
47
  }
48
+ function expandNullTargets(targets, cwd) {
49
+ return targets.flatMap((t) => {
50
+ const abs = resolve(cwd, t);
51
+ if (existsSync(abs) && statSync(abs).isDirectory() && !existsSync(join(abs, "null.json"))) {
52
+ return readdirSync(abs).sort().map((d) => join(abs, d, "null.json")).filter((p) => existsSync(p));
53
+ }
54
+ return [t];
55
+ });
56
+ }
58
57
  function loadNull(target, cwd) {
59
58
  const path = nullPath(target, cwd);
60
59
  if (!existsSync(path)) throw new Error(`no null model at ${target}. Build one with craft null build.`);
@@ -62,10 +61,11 @@ function loadNull(target, cwd) {
62
61
  if (model.version !== NULL_VERSION) throw new Error(`${target} is null model version ${String(model.version)}; this craft reads ${NULL_VERSION}`);
63
62
  return model;
64
63
  }
65
- function measureRun(id, html, snapshot) {
64
+ function measureRun(id, html, snapshot, styles = []) {
66
65
  const file = { path: `${id}.html`, text: html };
67
- const tells = new Set([...auditSnapshot(snapshot).findings, ...scanSource([file]).findings].map((f) => f.tell));
68
- const copy = [...new Set(extractStrings(file).filter((b) => !b.literal).map((b) => b.text.replace(/\s+/g, " ").trim()).filter(Boolean))];
66
+ const tells = new Set([...auditSnapshot(snapshot).findings, ...scanSource([file, ...styles]).findings].map((f) => f.tell));
67
+ let copy = [...new Set(extractStrings(file).filter((b) => !b.literal).map((b) => b.text.replace(/\s+/g, " ").trim()).filter(Boolean))];
68
+ if (copy.length === 0) copy = [...new Set(snapshot.sections.flatMap((s) => s.text ?? []).map((t) => t.replace(/\s+/g, " ").trim()).filter(Boolean))];
69
69
  return { id, fingerprint: fingerprint(snapshot), tells: [...tells].sort(), copy };
70
70
  }
71
71
  async function build(flags, io) {
@@ -115,7 +115,7 @@ async function build(flags, io) {
115
115
  const toShoot = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && !existsSync(join(shots, `${id}.json`)));
116
116
  if (toShoot.length) {
117
117
  io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"}`);
118
- const { snapshotUrls } = await import("./audit-6WHR7ZTV.js");
118
+ const { snapshotUrls } = await import("./audit-UJCUWORL.js");
119
119
  const results = await snapshotUrls(toShoot.map((id) => pathToFileURL(join(pages, `${id}.html`)).href));
120
120
  results.forEach((r, i) => {
121
121
  const id = toShoot[i];
@@ -144,6 +144,142 @@ async function build(flags, io) {
144
144
  }
145
145
  return 0;
146
146
  }
147
+ function findImportPages(dir) {
148
+ const pages = [];
149
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
150
+ if (e.isFile() && /\.html?$/i.test(e.name)) pages.push({ id: e.name.replace(/\.html?$/i, ""), root: dir, entry: e.name });
151
+ else if (e.isDirectory() && existsSync(join(dir, e.name, "index.html"))) pages.push({ id: e.name, root: join(dir, e.name), entry: "index.html" });
152
+ }
153
+ const ids = pages.map((p) => p.id);
154
+ const twice = ids.find((id, i) => ids.indexOf(id) !== i);
155
+ if (twice) throw new Error(`"${twice}" is both a file and a folder; rename one`);
156
+ return pages.sort((a, b) => a.id.localeCompare(b.id));
157
+ }
158
+ var TYPES = {
159
+ ".html": "text/html",
160
+ ".htm": "text/html",
161
+ ".css": "text/css",
162
+ ".js": "text/javascript",
163
+ ".mjs": "text/javascript",
164
+ ".json": "application/json",
165
+ ".svg": "image/svg+xml",
166
+ ".png": "image/png",
167
+ ".jpg": "image/jpeg",
168
+ ".jpeg": "image/jpeg",
169
+ ".webp": "image/webp",
170
+ ".avif": "image/avif",
171
+ ".gif": "image/gif",
172
+ ".ico": "image/x-icon",
173
+ ".woff": "font/woff",
174
+ ".woff2": "font/woff2",
175
+ ".ttf": "font/ttf",
176
+ ".otf": "font/otf"
177
+ };
178
+ function serveDir(root) {
179
+ const server = createServer((req, res) => {
180
+ let file;
181
+ try {
182
+ file = resolve(root, `.${decodeURIComponent(new URL(req.url ?? "/", "http://local").pathname)}`);
183
+ } catch {
184
+ res.statusCode = 400;
185
+ return void res.end();
186
+ }
187
+ if (file !== root && !file.startsWith(root + sep)) {
188
+ res.statusCode = 403;
189
+ return void res.end();
190
+ }
191
+ if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html");
192
+ if (!existsSync(file)) {
193
+ res.statusCode = 404;
194
+ return void res.end();
195
+ }
196
+ res.setHeader("content-type", TYPES[extname(file).toLowerCase()] ?? "application/octet-stream");
197
+ res.end(readFileSync(file));
198
+ });
199
+ return new Promise((done, fail) => {
200
+ server.once("error", fail);
201
+ server.listen(
202
+ 0,
203
+ "127.0.0.1",
204
+ () => done({
205
+ base: `http://127.0.0.1:${server.address().port}`,
206
+ close: () => new Promise((closed) => server.close(() => closed()))
207
+ })
208
+ );
209
+ });
210
+ }
211
+ function stylesheets(root, id) {
212
+ const found = [];
213
+ const walk = (dir) => {
214
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
215
+ const path = join(dir, e.name);
216
+ if (e.isDirectory() && e.name !== "node_modules" && !e.name.startsWith(".")) walk(path);
217
+ else if (e.isFile() && e.name.endsWith(".css") && statSync(path).size <= 1e6) found.push({ path: `${id}/${relative(root, path)}`, text: readFileSync(path, "utf8") });
218
+ }
219
+ };
220
+ walk(root);
221
+ return found;
222
+ }
223
+ async function importPages(flags, io) {
224
+ const usage = 'usage: craft null import <dir> --builder <name> --brief "<who, where, what they do>" [--out <dir>] [--model <name>]';
225
+ const from = flags.positional[1];
226
+ const brief = flags.brief?.trim();
227
+ if (!from || flags.positional.length > 2 || !flags.builder || !brief || brief.split(/\s+/).length < 5) throw new Error(usage);
228
+ const dir = resolve(io.cwd, from);
229
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`no directory at ${from}`);
230
+ const pages = findImportPages(dir);
231
+ if (pages.length < MIN_RUNS) throw new Error(`${from} holds ${pages.length} page${pages.length === 1 ? "" : "s"}; a null model needs at least ${MIN_RUNS}`);
232
+ const out = resolve(io.cwd, flags.out ?? from);
233
+ const shots = join(out, "snapshots");
234
+ mkdirSync(shots, { recursive: true });
235
+ const failed = [];
236
+ const toShoot = pages.filter((p) => !existsSync(join(shots, `${p.id}.json`)));
237
+ if (toShoot.length) {
238
+ io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"} from ${flags.builder}`);
239
+ const { snapshotUrls } = await import("./audit-UJCUWORL.js");
240
+ const servers = /* @__PURE__ */ new Map();
241
+ try {
242
+ for (const p of toShoot) if (!servers.has(p.root)) servers.set(p.root, await serveDir(p.root));
243
+ const urls = toShoot.map((p) => `${servers.get(p.root)?.base}/${p.entry === "index.html" ? "" : encodeURIComponent(p.entry)}`);
244
+ const results = await snapshotUrls(urls);
245
+ results.forEach((r, i) => {
246
+ const p = toShoot[i];
247
+ if (r.snapshot) writeFileSync(join(shots, `${p.id}.json`), `${JSON.stringify({ ...r.snapshot, url: relative(out, join(p.root, p.entry)) }, null, 2)}
248
+ `);
249
+ else failed.push(`${p.id}: the page would not render: ${r.error}`);
250
+ });
251
+ } finally {
252
+ await Promise.all([...servers.values()].map((s) => s.close()));
253
+ }
254
+ }
255
+ const measured = pages.filter((p) => existsSync(join(shots, `${p.id}.json`))).map(
256
+ (p) => measureRun(
257
+ p.id,
258
+ readFileSync(join(p.root, p.entry), "utf8"),
259
+ readSnapshot(readJson(join(shots, `${p.id}.json`)), `${p.id}.json`),
260
+ p.root === dir ? [] : stylesheets(p.root, p.id)
261
+ )
262
+ );
263
+ if (measured.length < MIN_RUNS) {
264
+ for (const f of failed) io.err(` ${f}`);
265
+ throw new Error(`only ${measured.length} of ${pages.length} pages rendered; a null model needs at least ${MIN_RUNS}`);
266
+ }
267
+ const model = {
268
+ version: NULL_VERSION,
269
+ brief,
270
+ prompt: nullPrompt(brief),
271
+ model: flags.model ?? "unknown",
272
+ builder: flags.builder,
273
+ catalogueVersion: CATALOGUE_VERSION,
274
+ builtAt: (/* @__PURE__ */ new Date()).toISOString(),
275
+ runs: measured
276
+ };
277
+ writeFileSync(join(out, "null.json"), `${JSON.stringify(model, null, 2)}
278
+ `);
279
+ io.out(`craft null: ${measured.length} of ${pages.length} pages from ${flags.builder} in ${relative(io.cwd, join(out, "null.json")) || "null.json"}`);
280
+ for (const f of failed) io.err(` ${f}`);
281
+ return measured.length < pages.length ? 1 : 0;
282
+ }
147
283
  function copyTell(phrase) {
148
284
  const text = `We found that ${phrase} was there.`;
149
285
  const hit = checkCopy([{ path: "phrase.md", text }]).findings[0];
@@ -179,7 +315,13 @@ async function runNull(args, io) {
179
315
  try {
180
316
  const flags = parseFlags(args);
181
317
  if (typeof flags === "string") throw new Error(flags);
182
- if (flags.positional[0] !== "build") throw new Error('usage: craft null build --brief "<text>" --out <dir> [--runs 20] [--parallel 4] [--model <name>]');
318
+ if (flags.positional[0] === "import") return await importPages(flags, io);
319
+ if (flags.positional[0] === "prompt") {
320
+ if (!flags.brief || flags.brief.trim().split(/\s+/).length < 5) throw new Error('usage: craft null prompt --brief "<who, where, what they do>"');
321
+ io.out(nullPrompt(flags.brief));
322
+ return 0;
323
+ }
324
+ if (flags.positional[0] !== "build") throw new Error(NULL_USAGE);
183
325
  return await build(flags, io);
184
326
  } catch (error) {
185
327
  io.err(`craft: ${error.message}`);
@@ -191,14 +333,7 @@ function runHarvest(args, io) {
191
333
  if (typeof flags === "string") throw new Error(flags);
192
334
  const targets = flags.positional.slice(1);
193
335
  if (targets.length === 0) throw new Error("usage: craft tells harvest <null.json | dir>... [--share 0.25] [--json]");
194
- const paths = targets.flatMap((t) => {
195
- const abs = resolve(io.cwd, t);
196
- if (existsSync(abs) && statSync(abs).isDirectory() && !existsSync(join(abs, "null.json"))) {
197
- return readdirSync(abs).map((d) => join(abs, d, "null.json")).filter((p) => existsSync(p));
198
- }
199
- return [t];
200
- });
201
- const models = paths.map((p) => loadNull(p, io.cwd));
336
+ const models = expandNullTargets(targets, io.cwd).map((p) => loadNull(p, io.cwd));
202
337
  const minShare = flags.share === void 0 ? void 0 : Number(flags.share);
203
338
  if (minShare !== void 0 && !(minShare > 0 && minShare <= 1)) throw new Error("--share needs a number above 0 and at most 1");
204
339
  const candidates = harvest(models, { minShare, copyTell });
@@ -208,8 +343,11 @@ function runHarvest(args, io) {
208
343
 
209
344
  export {
210
345
  nullPath,
346
+ expandNullTargets,
211
347
  loadNull,
212
348
  measureRun,
349
+ findImportPages,
350
+ serveDir,
213
351
  formatHarvest,
214
352
  describeTypicality,
215
353
  runNull,
@@ -16,7 +16,7 @@ import {
16
16
  protectedFacts,
17
17
  scanSource,
18
18
  visibleText
19
- } from "./chunk-GQVEYTBY.js";
19
+ } from "./chunk-B2MJLGF6.js";
20
20
 
21
21
  // src/character/config.ts
22
22
  var DEFAULT_IGNORE = [".claude", ".agents", ".cursor"];
@@ -377,14 +377,18 @@ Usage
377
377
  craft snapshot <url> [--out <file>] [--width <px>] [--height <px>]
378
378
  craft audit <url | snapshot.json> [--repo <dir>] [--null <null.json>] [--out <file>] [--json] [--strict] [ci options]
379
379
  craft audit --pages <sitemap.xml | urls.txt> [--viewport 390,768,1440] [the same]
380
- craft direction init [--snapshot <file>] [--client <name>] [--out <file>]
381
- craft direction validate [--snapshot <file>] [--direction <file>] [--json]
380
+ craft direction init [--snapshot <file | url>] [--client <name>] [--brief "<text>"] [--out <file>]
381
+ craft direction validate [--snapshot <file | url>] [--page home] [--estate <estate.json>] [--direction <file>] [--strict] [--json]
382
+ craft direction research [--brief "<who, where, what>"] [--visual]
382
383
  craft direction propose [--snapshot <file>] [--estate <estate.json | dir>] [--out <file>]
383
384
  craft null build --brief "<text>" --out <dir> [--runs 20] [--parallel 4] [--model <name>]
384
- craft estate add <url | snapshot.json> --id <id> [--client <name>] [--estate <file>]
385
- craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--json] [--strict]
385
+ craft null import <dir> --builder <name> --brief "<text>" [--out <dir>] [--model <name>]
386
+ craft null prompt --brief "<text>"
387
+ craft estate add [<url | snapshot.json>] --id <id> [--client <name>] [--direction <art-direction.json>] [--estate <file>]
388
+ craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--directions] [--json] [--strict]
386
389
  craft report <url | snapshot.json> [--repo <dir>] [--null <dir>] [--estate <file>] [--direction <file>] [--json] [--strict]
387
390
  craft retrofit <url | snapshot.json> [the same] [--out RETROFIT.md]
391
+ craft calibrate <labels.json> [--out <dir>] [--fresh] [--json]
388
392
 
389
393
  scan Markup, component code and stylesheets (source and compiled CSS).
390
394
  --staged reads the git index, for a pre-commit hook.
@@ -418,6 +422,10 @@ direction The site's art-direction.json: every choice with a reason from the
418
422
  sources' PNG photos and drafts choices for a person to confirm.
419
423
  null The counterfactual: about 20 pages \`claude -p\` builds from the brief
420
424
  alone, snapshotted and fingerprinted into null.json. Resumable.
425
+ import does the same for pages another builder made (v0, Lovable):
426
+ each an HTML file, or a folder holding a built index.html, served
427
+ on loopback so its asset paths resolve. prompt prints what to give
428
+ the builder, so its pages answer the same brief. Resumable.
421
429
  estate The register of shipped sites (estate.json). add fingerprints a site;
422
430
  compare lists the nearest, and marks siblings: two sites closer than
423
431
  two pages Claude builds for one brief. --strict exits 1 on a sibling.
@@ -426,6 +434,11 @@ report Every signal for one site: tells, typicality, the estate, the reasons.
426
434
  Anything not given is "not measured", and never counts as a pass.
427
435
  retrofit The report as a checklist: decide, then change type, colour, shape,
428
436
  effects, motion and copy. Never the page grammar.
437
+ calibrate How well the catalogue separates a labelled set (ai, human,
438
+ ai-looking): precision and recall per tell, and CHARACTER.md's
439
+ first three targets. --out keeps results.json and any snapshots it
440
+ took; --fresh takes every snapshot again. A page not measured is
441
+ listed, and never counts as a pass. A measure: always exit 0.
429
442
 
430
443
  ci options (scan, copy, audit)
431
444
  --baseline <file> Report only findings the baseline does not already
@@ -461,10 +474,12 @@ var VALUE_FLAGS = {
461
474
  "--pages": "pages",
462
475
  "--viewport": "viewport",
463
476
  "--component": "component",
464
- "--competitor": "competitor"
477
+ "--competitor": "competitor",
478
+ "--page": "page",
479
+ "--builder": "builder"
465
480
  };
466
481
  function parseFlags(args) {
467
- const flags = { positional: [], json: false, strict: false, staged: false, gate: false, updateBaseline: false };
482
+ const flags = { positional: [], json: false, strict: false, staged: false, gate: false, updateBaseline: false, directions: false, visual: false, fresh: false };
468
483
  for (let i = 0; i < args.length; i += 1) {
469
484
  const a = args[i];
470
485
  if (a === "--json") flags.json = true;
@@ -472,6 +487,9 @@ function parseFlags(args) {
472
487
  else if (a === "--staged") flags.staged = true;
473
488
  else if (a === "--gate") flags.gate = true;
474
489
  else if (a === "--update-baseline") flags.updateBaseline = true;
490
+ else if (a === "--directions") flags.directions = true;
491
+ else if (a === "--visual") flags.visual = true;
492
+ else if (a === "--fresh") flags.fresh = true;
475
493
  else if (a in VALUE_FLAGS) {
476
494
  const value = args[i + 1];
477
495
  i += 1;
@@ -576,7 +594,7 @@ async function pageSnapshot(target, cwd) {
576
594
  return readSnapshot(JSON.parse(readFileSync(local, "utf8")), target);
577
595
  }
578
596
  if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL nor a snapshot file`);
579
- const { snapshotUrl } = await import("./audit-6WHR7ZTV.js");
597
+ const { snapshotUrl } = await import("./audit-UJCUWORL.js");
580
598
  return snapshotUrl(target);
581
599
  }
582
600
  async function briefFor(flags, cwd) {
@@ -587,7 +605,7 @@ async function briefFor(flags, cwd) {
587
605
  if (typeof brief === "string" && brief.trim()) return brief;
588
606
  } else if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
589
607
  if (flags.null) {
590
- const { loadNull } = await import("./cli-Z2IQG3LC.js");
608
+ const { loadNull } = await import("./cli-AVYNHBFJ.js");
591
609
  return loadNull(flags.null, cwd).brief;
592
610
  }
593
611
  return void 0;
@@ -613,25 +631,28 @@ async function compareWithCompetitor(flags, io) {
613
631
  function run(argv, io) {
614
632
  const [command, ...rest] = argv;
615
633
  if (command === "direction") {
616
- return import("./cli-O3CDSONO.js").then((m) => m.runDirection(rest, io));
634
+ return import("./cli-K4LSRYBE.js").then((m) => m.runDirection(rest, io));
617
635
  }
618
636
  if (command === "report" || command === "retrofit") {
619
- return import("./cli-WG3UJNUF.js").then((m) => m.runReport(command, rest, io));
637
+ return import("./cli-CWORJYOU.js").then((m) => m.runReport(command, rest, io));
620
638
  }
621
639
  if (command === "estate") {
622
- return import("./cli-3CGUKXY4.js").then((m) => m.runEstate(rest, io));
640
+ return import("./cli-PEATEW57.js").then((m) => m.runEstate(rest, io));
623
641
  }
624
642
  if (command === "null") {
625
- return import("./cli-Z2IQG3LC.js").then((m) => m.runNull(rest, io));
643
+ return import("./cli-AVYNHBFJ.js").then((m) => m.runNull(rest, io));
644
+ }
645
+ if (command === "calibrate") {
646
+ return import("./cli-3GQROFCB.js").then((m) => m.runCalibrate(rest, io));
626
647
  }
627
648
  if (command === "tells" && rest[0] === "harvest") {
628
- return import("./cli-Z2IQG3LC.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
649
+ return import("./cli-AVYNHBFJ.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
629
650
  io.err(`craft: ${error.message}`);
630
651
  return 2;
631
652
  });
632
653
  }
633
654
  if (command === "snapshot" || command === "audit") {
634
- return import("./cli-WSHPSWVU.js").then((m) => m.runAudit(command, rest, io));
655
+ return import("./cli-52M62QBN.js").then((m) => m.runAudit(command, rest, io));
635
656
  }
636
657
  if (!command || command === "help" || command === "--help" || command === "-h") {
637
658
  io.out(HELP);