@tendrilapp/cli 0.1.17 → 0.1.18

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/dist/tendril.js CHANGED
@@ -1214,8 +1214,8 @@ var init_src = __esm({
1214
1214
  function variableNameToPath(name) {
1215
1215
  return name.split("/").map(canonicalCssIdentPart).filter((part) => part.length > 0);
1216
1216
  }
1217
- function tokenPathToCssVar(path35) {
1218
- return `--${path35.join("-")}`;
1217
+ function tokenPathToCssVar(path36) {
1218
+ return `--${path36.join("-")}`;
1219
1219
  }
1220
1220
  function toDtcgToken(variable, defaultMode) {
1221
1221
  const modes = Object.keys(variable.valuesByMode);
@@ -1259,11 +1259,11 @@ function toDtcgToken(variable, defaultMode) {
1259
1259
  }
1260
1260
  function mapVariablesToDtcg(variables, defaultMode = "light") {
1261
1261
  const entries = variables.map((variable) => {
1262
- const path35 = variableNameToPath(variable.name);
1263
- if (path35.length === 0) {
1262
+ const path36 = variableNameToPath(variable.name);
1263
+ if (path36.length === 0) {
1264
1264
  throw new DtcgMappingError("empty-name", `Figma variable ${variable.id} has an empty name`);
1265
1265
  }
1266
- return { variable, path: path35 };
1266
+ return { variable, path: path36 };
1267
1267
  });
1268
1268
  const groupPrefixes = /* @__PURE__ */ new Set();
1269
1269
  for (const e of entries) {
@@ -1284,21 +1284,21 @@ function mapVariablesToDtcg(variables, defaultMode = "light") {
1284
1284
  }
1285
1285
  const tokens = {};
1286
1286
  const flat = [];
1287
- for (const { variable, path: path35 } of entries) {
1287
+ for (const { variable, path: path36 } of entries) {
1288
1288
  const token = toDtcgToken(variable, defaultMode);
1289
1289
  let group = tokens;
1290
- for (const segment of path35.slice(0, -1)) {
1290
+ for (const segment of path36.slice(0, -1)) {
1291
1291
  const existing = group[segment];
1292
1292
  group = existing ?? (group[segment] = {});
1293
1293
  }
1294
- const leaf = path35[path35.length - 1];
1294
+ const leaf = path36[path36.length - 1];
1295
1295
  if (group[leaf] !== void 0) {
1296
- throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path35.join(".")}" (variable ${variable.id})`);
1296
+ throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path36.join(".")}" (variable ${variable.id})`);
1297
1297
  }
1298
1298
  group[leaf] = token;
1299
1299
  flat.push({
1300
- path: path35.join("."),
1301
- cssVar: tokenPathToCssVar(path35),
1300
+ path: path36.join("."),
1301
+ cssVar: tokenPathToCssVar(path36),
1302
1302
  type: token.$type,
1303
1303
  value: token.$value
1304
1304
  });
@@ -1487,9 +1487,9 @@ function boundId(value) {
1487
1487
  return isObject(value) && typeof value["boundVariableId"] === "string" ? value["boundVariableId"] : void 0;
1488
1488
  }
1489
1489
  function resolveBinding(ctx, id) {
1490
- const path35 = ctx.pathById.get(id);
1491
- if (path35 === void 0) ctx.unresolved.add(id);
1492
- return path35;
1490
+ const path36 = ctx.pathById.get(id);
1491
+ if (path36 === void 0) ctx.unresolved.add(id);
1492
+ return path36;
1493
1493
  }
1494
1494
  function parseVariantProps(name) {
1495
1495
  if (!name.includes("=")) return void 0;
@@ -1524,8 +1524,8 @@ function walk(ctx, raw) {
1524
1524
  if (!isObject(paint) || paint["visible"] === false) continue;
1525
1525
  const id = boundId(paint);
1526
1526
  if (id !== void 0) {
1527
- const path35 = resolveBinding(ctx, id);
1528
- if (path35 !== void 0) tokens.add(path35);
1527
+ const path36 = resolveBinding(ctx, id);
1528
+ if (path36 !== void 0) tokens.add(path36);
1529
1529
  } else if (typeof paint["color"] === "string") {
1530
1530
  ctx.hardcoded.push({ node: name, property, value: paint["color"] });
1531
1531
  }
@@ -1533,8 +1533,8 @@ function walk(ctx, raw) {
1533
1533
  }
1534
1534
  const radiusId = boundId(raw["cornerRadius"]);
1535
1535
  if (radiusId !== void 0) {
1536
- const path35 = resolveBinding(ctx, radiusId);
1537
- if (path35 !== void 0) tokens.add(path35);
1536
+ const path36 = resolveBinding(ctx, radiusId);
1537
+ if (path36 !== void 0) tokens.add(path36);
1538
1538
  } else if (typeof raw["cornerRadius"] === "number" && raw["cornerRadius"] !== 0) {
1539
1539
  ctx.hardcoded.push({ node: name, property: "border-radius", value: `${raw["cornerRadius"]}px` });
1540
1540
  }
@@ -1544,10 +1544,10 @@ function walk(ctx, raw) {
1544
1544
  layout = { mode: layoutMode === "HORIZONTAL" ? "flex-row" : "flex-column" };
1545
1545
  const gapId = boundId(raw["itemSpacing"]);
1546
1546
  if (gapId !== void 0) {
1547
- const path35 = resolveBinding(ctx, gapId);
1548
- if (path35 !== void 0) {
1549
- layout.gap = path35;
1550
- tokens.add(path35);
1547
+ const path36 = resolveBinding(ctx, gapId);
1548
+ if (path36 !== void 0) {
1549
+ layout.gap = path36;
1550
+ tokens.add(path36);
1551
1551
  }
1552
1552
  } else if (typeof raw["itemSpacing"] === "number" && raw["itemSpacing"] !== 0) {
1553
1553
  ctx.hardcoded.push({ node: name, property: "gap", value: `${raw["itemSpacing"]}px` });
@@ -1556,10 +1556,10 @@ function walk(ctx, raw) {
1556
1556
  for (const field of PADDING_FIELDS) {
1557
1557
  const id = boundId(raw[field]);
1558
1558
  if (id !== void 0) {
1559
- const path35 = resolveBinding(ctx, id);
1560
- if (path35 !== void 0) {
1561
- paddingPaths.push(path35);
1562
- tokens.add(path35);
1559
+ const path36 = resolveBinding(ctx, id);
1560
+ if (path36 !== void 0) {
1561
+ paddingPaths.push(path36);
1562
+ tokens.add(path36);
1563
1563
  }
1564
1564
  } else if (typeof raw[field] === "number" && raw[field] !== 0) {
1565
1565
  ctx.hardcoded.push({ node: name, property: field, value: `${raw[field]}px` });
@@ -1968,13 +1968,13 @@ var init_tsc_check = __esm({
1968
1968
  // packages/verify/src/token-lint.ts
1969
1969
  import strictValue from "stylelint-declaration-strict-value";
1970
1970
  import stylelint from "stylelint";
1971
- async function runTokenLint(css, fileLabel = "generated.css", definedVars2) {
1971
+ async function runTokenLint(css, fileLabel = "generated.css", definedVars2, recordedMapEmpty = false) {
1972
1972
  const result = await stylelint.lint({
1973
1973
  code: css,
1974
1974
  codeFilename: fileLabel,
1975
1975
  config: STYLELINT_CONFIG
1976
1976
  });
1977
- const mapKnownEmpty = definedVars2 !== void 0 && definedVars2.size === 0;
1977
+ const mapKnownEmpty = recordedMapEmpty || definedVars2 !== void 0 && definedVars2.size === 0;
1978
1978
  const violations = result.results.flatMap(
1979
1979
  (r) => r.warnings.filter((w) => !(mapKnownEmpty && w.rule === "scale-unlimited/declaration-strict-value")).map((w) => ({
1980
1980
  file: fileLabel,
@@ -3409,7 +3409,29 @@ function resolvedFontFamilies(cacheDir = DEFAULT_FONT_CACHE) {
3409
3409
  function requiredFontsManifest(families, cacheDir = DEFAULT_FONT_CACHE) {
3410
3410
  const mPath = path9.join(cacheDir, "manifest.json");
3411
3411
  const manifest = existsSync5(mPath) ? JSON.parse(readFileSync3(mPath, "utf8")) : [];
3412
- return manifest.filter((f) => families.includes(f.family));
3412
+ const wanted = new Set(families.map((f) => f.toLowerCase()));
3413
+ return manifest.filter((f) => wanted.has(f.family.toLowerCase()));
3414
+ }
3415
+ function verifiedFontFamilies(cacheDir = DEFAULT_FONT_CACHE) {
3416
+ const mPath = path9.join(cacheDir, "manifest.json");
3417
+ if (!existsSync5(mPath)) return [];
3418
+ let entries;
3419
+ try {
3420
+ entries = JSON.parse(readFileSync3(mPath, "utf8"));
3421
+ } catch {
3422
+ return [];
3423
+ }
3424
+ const byFamily = /* @__PURE__ */ new Map();
3425
+ for (const e of entries) {
3426
+ if (typeof e.family !== "string" || typeof e.weight !== "number" || typeof e.file !== "string") continue;
3427
+ const file = path9.isAbsolute(e.file) && existsSync5(e.file) ? e.file : path9.resolve(cacheDir, path9.basename(e.file));
3428
+ if (!existsSync5(file)) continue;
3429
+ if (createHash("sha256").update(readFileSync3(file)).digest("hex") !== e.sha256) continue;
3430
+ const set = byFamily.get(e.family) ?? /* @__PURE__ */ new Set();
3431
+ set.add(e.weight);
3432
+ byFamily.set(e.family, set);
3433
+ }
3434
+ return [...byFamily.entries()].map(([family, w]) => ({ family, weights: [...w].sort((a, b) => a - b) }));
3413
3435
  }
3414
3436
  var DEFAULT_FONT_CACHE, UA;
3415
3437
  var init_font_resolve = __esm({
@@ -3421,11 +3443,21 @@ var init_font_resolve = __esm({
3421
3443
  });
3422
3444
 
3423
3445
  // packages/verify/src/font-faces.ts
3446
+ import { createHash as createHash2 } from "node:crypto";
3424
3447
  import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
3425
3448
  import path10 from "node:path";
3426
3449
  function fontFaceCss(manifestPath2 = path10.join(fontCacheDir(), "manifest.json")) {
3427
3450
  if (!existsSync6(manifestPath2)) return "";
3428
- const manifest = JSON.parse(readFileSync4(manifestPath2, "utf8"));
3451
+ const claimed = JSON.parse(readFileSync4(manifestPath2, "utf8"));
3452
+ const resolveEntry = (f) => {
3453
+ if (path10.isAbsolute(f) && existsSync6(f)) return f;
3454
+ return path10.resolve(path10.dirname(manifestPath2), path10.basename(f));
3455
+ };
3456
+ const manifest = claimed.filter((f) => {
3457
+ const file = resolveEntry(f.file);
3458
+ if (!existsSync6(file)) return false;
3459
+ return createHash2("sha256").update(readFileSync4(file)).digest("hex") === f.sha256;
3460
+ });
3429
3461
  const resolveFile = (f) => {
3430
3462
  if (path10.isAbsolute(f) && existsSync6(f)) return f;
3431
3463
  return path10.resolve(path10.dirname(manifestPath2), path10.basename(f));
@@ -4196,7 +4228,28 @@ function definedVars(tokensCss) {
4196
4228
  for (const m of tokensCss.matchAll(/(--[\w-]+)\s*:/g)) names.add(m[1]);
4197
4229
  return names;
4198
4230
  }
4199
- async function checkBundleQuality(bundleDir, entry) {
4231
+ function recordedTokenMapEmpty(setDir, reps) {
4232
+ const readMap = (file) => {
4233
+ if (!existsSync9(file)) return void 0;
4234
+ try {
4235
+ const parsed = JSON.parse(envelopeFirstTextPart(JSON.parse(readFileSync7(file, "utf8"))) || "{}");
4236
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
4237
+ } catch {
4238
+ return {};
4239
+ }
4240
+ };
4241
+ const setLevel = readMap(path14.join(setDir, "get_variable_defs.json"));
4242
+ if (setLevel !== void 0) return Object.keys(setLevel).length === 0;
4243
+ let recorded = false;
4244
+ for (const rep of reps) {
4245
+ const m = readMap(path14.join(setDir, rep, "get_variable_defs.json"));
4246
+ if (m === void 0) continue;
4247
+ recorded = true;
4248
+ if (Object.keys(m).length > 0) return false;
4249
+ }
4250
+ return recorded;
4251
+ }
4252
+ async function checkBundleQuality(bundleDir, entry, set) {
4200
4253
  const findings = [];
4201
4254
  const entryPath = path14.join(bundleDir, entry);
4202
4255
  const cssPath = path14.join(bundleDir, "styles.css");
@@ -4216,7 +4269,8 @@ async function checkBundleQuality(bundleDir, entry) {
4216
4269
  }
4217
4270
  }
4218
4271
  if (css !== "") {
4219
- for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss))).violations) {
4272
+ const recordedEmpty = set !== void 0 && recordedTokenMapEmpty(set.dir, set.reps);
4273
+ for (const v of (await runTokenLint(css, "styles.css", definedVars(tokensCss), recordedEmpty)).violations) {
4220
4274
  findings.push({ kind: "token-lint", file: "styles.css", line: v.line, message: v.message });
4221
4275
  }
4222
4276
  }
@@ -4225,6 +4279,7 @@ async function checkBundleQuality(bundleDir, entry) {
4225
4279
  var init_bundle_quality = __esm({
4226
4280
  "packages/verify/src/bundle-quality.ts"() {
4227
4281
  "use strict";
4282
+ init_src();
4228
4283
  init_runtime();
4229
4284
  init_tsc_check();
4230
4285
  init_token_lint();
@@ -5174,7 +5229,7 @@ var init_src4 = __esm({
5174
5229
  // packages/cli/src/environment.ts
5175
5230
  import { existsSync as existsSync14, readFileSync as readFileSync11 } from "node:fs";
5176
5231
  import path19 from "node:path";
5177
- import { createHash as createHash2 } from "node:crypto";
5232
+ import { createHash as createHash3 } from "node:crypto";
5178
5233
  import { fileURLToPath as fileURLToPath4 } from "node:url";
5179
5234
  function cliVersion() {
5180
5235
  try {
@@ -5191,7 +5246,7 @@ function environmentStamp(taskFamilies) {
5191
5246
  const entries = JSON.parse(readFileSync11(manifestPath2, "utf8"));
5192
5247
  const scoped = taskFamilies === void 0 || taskFamilies === null ? entries : entries.filter((f) => taskFamilies.some((fam) => fam.toLowerCase() === f.family.toLowerCase()));
5193
5248
  const faces = scoped.map((f) => `${f.family}:${f.weight}:${f.sha256}`).sort();
5194
- fontsHash = faces.length === 0 ? null : createHash2("sha256").update(faces.join("\n")).digest("hex").slice(0, 16);
5249
+ fontsHash = faces.length === 0 ? null : createHash3("sha256").update(faces.join("\n")).digest("hex").slice(0, 16);
5195
5250
  } catch {
5196
5251
  fontsHash = null;
5197
5252
  }
@@ -5210,6 +5265,25 @@ var init_environment = __esm({
5210
5265
  }
5211
5266
  });
5212
5267
 
5268
+ // packages/cli/src/describe.ts
5269
+ function printDescription(description) {
5270
+ process.stdout.write(`${JSON.stringify(description, null, 2)}
5271
+ `);
5272
+ }
5273
+ var COMMON_EXIT_CODES;
5274
+ var init_describe = __esm({
5275
+ "packages/cli/src/describe.ts"() {
5276
+ "use strict";
5277
+ COMMON_EXIT_CODES = {
5278
+ 0: "success",
5279
+ 1: "general error",
5280
+ 2: "authentication error",
5281
+ 3: "input validation error",
5282
+ 4: "confirmation required (re-run with --yes or answer the prompt)"
5283
+ };
5284
+ }
5285
+ });
5286
+
5213
5287
  // packages/cli/src/env.ts
5214
5288
  import { existsSync as existsSync15, readFileSync as readFileSync12 } from "node:fs";
5215
5289
  import path20 from "node:path";
@@ -5386,6 +5460,211 @@ var init_entitlement = __esm({
5386
5460
  }
5387
5461
  });
5388
5462
 
5463
+ // packages/cli/src/commands/doctor.ts
5464
+ import { spawnSync } from "node:child_process";
5465
+ import { existsSync as existsSync17, readFileSync as readFileSync14, readdirSync as readdirSync3 } from "node:fs";
5466
+ import os4 from "node:os";
5467
+ import path22 from "node:path";
5468
+ async function runDoctorChecks(options) {
5469
+ const checks = [];
5470
+ const mcpUrl = options.mcpUrl ?? DEFAULT_MCP_URL;
5471
+ const client = new McpHttpClient({
5472
+ url: mcpUrl,
5473
+ ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
5474
+ });
5475
+ try {
5476
+ const info = await client.initialize();
5477
+ const tools = await client.listTools();
5478
+ checks.push({
5479
+ name: "figma-desktop-mcp",
5480
+ ok: true,
5481
+ detail: `${info.name} ${info.version} (protocol ${info.protocolVersion}), ${tools.length} tools: ${tools.map((t) => t.name).join(", ")}`
5482
+ });
5483
+ } catch (err) {
5484
+ checks.push({
5485
+ name: "figma-desktop-mcp",
5486
+ ok: false,
5487
+ detail: err instanceof Error ? err.message : String(err),
5488
+ remediation: err instanceof McpUnavailableError ? "Open the Figma desktop app, sign in, then Figma menu \u2192 Preferences \u2192 Enable local MCP server (Dev or Full seat on a paid plan)." : "The server answered but the exchange failed \u2014 check the signed-in account's seat, then retry."
5489
+ });
5490
+ }
5491
+ const openrouterKey = resolveCredential("OPENROUTER_API_KEY");
5492
+ checks.push(
5493
+ openrouterKey ? { name: "openrouter-key", ok: true, detail: "OPENROUTER_API_KEY configured" } : {
5494
+ name: "openrouter-key",
5495
+ ok: false,
5496
+ detail: "OPENROUTER_API_KEY not set \u2014 optional; only the curated API-model engine needs it",
5497
+ remediation: "Run `tendril init` to store your OpenRouter key (BYOK) if you plan to use the curated engine."
5498
+ }
5499
+ );
5500
+ try {
5501
+ const chrome = resolveChrome();
5502
+ checks.push({ name: "browser", ok: true, detail: `${chromeVersion() ?? "version unknown"} at ${chrome}` });
5503
+ } catch (err) {
5504
+ checks.push({
5505
+ name: "browser",
5506
+ ok: false,
5507
+ detail: err instanceof Error ? err.message : String(err),
5508
+ remediation: "Install Google Chrome (or Chromium), or set CHROME_PATH to a Chromium-family browser binary."
5509
+ });
5510
+ }
5511
+ const fontManifest = path22.join(fontCacheDir(), "manifest.json");
5512
+ checks.push(
5513
+ existsSync17(fontManifest) ? { name: "font-cache", ok: true, detail: `resolved font cache at ${fontCacheDir()} (${JSON.parse(readFileSync14(fontManifest, "utf8")).length} faces)` } : {
5514
+ name: "font-cache",
5515
+ ok: true,
5516
+ detail: `font cache empty at ${fontCacheDir()} \u2014 normal on a fresh machine; faces resolve per design system at first use`,
5517
+ remediation: "Nothing to do now: `tendril fonts resolve --set <recording-dir>` fetches exactly what a recording declares, and generate/verify name that command when they need it."
5518
+ }
5519
+ );
5520
+ const pluginRoot = path22.join(os4.homedir(), ".claude", "plugins", "cache", "tendrilapp", "tendril");
5521
+ if (existsSync17(pluginRoot)) {
5522
+ try {
5523
+ const versions = readdirSync3(pluginRoot).filter((v) => /^\d+\.\d+\.\d+$/.test(v));
5524
+ const newest = versions.sort((a, b) => versionIsNewer(a, b) ? 1 : -1)[0];
5525
+ if (newest !== void 0) {
5526
+ const skewed = versionIsNewer(newest, cliVersion());
5527
+ checks.push(
5528
+ skewed ? {
5529
+ name: "plugin-skew",
5530
+ ok: false,
5531
+ detail: `Claude Code plugin cache holds v${newest} while this CLI is ${cliVersion()} \u2014 the plugin's skill/agents are STALE and will silently shadow current doctrine`,
5532
+ remediation: "Update the plugin \u2014 REFRESH THE MARKETPLACE FIRST (its local clone goes stale and a reinstall faithfully reinstalls the old version): terminal Claude Code \u2014 `claude plugin marketplace update tendrilapp` then `claude plugin update tendril`; VS Code extension \u2014 /plugins panel \u2192 Marketplaces tab \u2192 refresh tendrilapp, THEN Plugins tab \u2192 uninstall + reinstall tendril, reopen the chat panel. If the refresh doesn't take, remove the tendrilapp marketplace entirely and re-add TendrilApp/claude-plugin (a fresh clone cannot be stale)."
5533
+ } : { name: "plugin-skew", ok: true, detail: `Claude Code plugin v${newest} matches this CLI` }
5534
+ );
5535
+ }
5536
+ } catch {
5537
+ }
5538
+ }
5539
+ const pathBinary = cliVersion() === "0.0.0" ? null : findPathTendril(process.env["PATH"] ?? "", process.platform);
5540
+ if (pathBinary !== null) {
5541
+ const reported = probeVersion(pathBinary);
5542
+ if (reported !== null) {
5543
+ checks.push(
5544
+ reported === cliVersion() ? { name: "path-skew", ok: true, detail: `tendril on PATH (${pathBinary}) is this CLI (${reported})` } : {
5545
+ name: "path-skew",
5546
+ ok: false,
5547
+ detail: `tendril on PATH (${pathBinary}) reports ${reported} while this CLI is ${cliVersion()} \u2014 a stale global install silently shadows the current release in terminals`,
5548
+ remediation: "npm rm -g tendrilapp (or npm install -g @tendrilapp/cli@latest) so the PATH binary matches; plugin/npx sessions already run the current release."
5549
+ }
5550
+ );
5551
+ }
5552
+ }
5553
+ const ent = checkEntitlement();
5554
+ checks.push(
5555
+ ent.ok ? ent.mode === "pre-launch" ? { name: "entitlement", ok: true, detail: "pre-launch build \u2014 entitlement gates present but unarmed (no public key shipped); record/generate run freely" } : { name: "entitlement", ok: true, detail: `active plan "${ent.claims.plan}" until ${new Date(ent.claims.exp).toISOString().slice(0, 10)}${ent.stale ? " (stale \u2014 renews at next opportunity)" : ""}` } : { name: "entitlement", ok: false, detail: ent.error, remediation: ent.remediation }
5556
+ );
5557
+ const figmaToken = resolveCredential("FIGMA_TOKEN");
5558
+ checks.push({
5559
+ name: "figma-pat",
5560
+ ok: true,
5561
+ detail: figmaToken ? "FIGMA_TOKEN configured (REST fallback available)" : "FIGMA_TOKEN not set \u2014 optional; only needed for the REST fallback transport"
5562
+ });
5563
+ return { ok: checks.filter((c) => c.name !== "figma-pat" && c.name !== "openrouter-key" && c.name !== "path-skew").every((c) => c.ok), checks };
5564
+ }
5565
+ function findPathTendril(pathEnv, platform) {
5566
+ const dirs = pathEnv.split(path22.delimiter).filter((d) => d !== "" && !/node_modules[\\/]\.bin/.test(d) && !/[\\/]_npx[\\/]/.test(d));
5567
+ const names = platform === "win32" ? ["tendril.cmd", "tendril.bat"] : ["tendril"];
5568
+ for (const dir of dirs) {
5569
+ for (const name of names) {
5570
+ const candidate = path22.join(dir, name);
5571
+ if (existsSync17(candidate)) return candidate;
5572
+ }
5573
+ }
5574
+ return null;
5575
+ }
5576
+ function probeVersion(binary) {
5577
+ const windowsShim = /\.(cmd|bat)$/i.test(binary);
5578
+ const res = windowsShim ? spawnSync(`"${binary}" --version`, { shell: true, timeout: 5e3, encoding: "utf8" }) : spawnSync(binary, ["--version"], { timeout: 5e3, encoding: "utf8" });
5579
+ if (res.error !== void 0 || res.status !== 0) return null;
5580
+ const m = /^(\d+\.\d+\.\d+)\s*$/m.exec(res.stdout ?? "");
5581
+ return m === null ? null : m[1];
5582
+ }
5583
+ function versionIsNewer(a, b) {
5584
+ const pa = a.split(".").map(Number);
5585
+ const pb = b.split(".").map(Number);
5586
+ for (let i = 0; i < 3; i++) {
5587
+ if ((pb[i] ?? 0) > (pa[i] ?? 0)) return true;
5588
+ if ((pb[i] ?? 0) < (pa[i] ?? 0)) return false;
5589
+ }
5590
+ return false;
5591
+ }
5592
+ async function latestVersionInfo() {
5593
+ try {
5594
+ const ctl = new AbortController();
5595
+ const timer = setTimeout(() => ctl.abort(), 2500);
5596
+ const res = await fetch("https://registry.npmjs.org/@tendrilapp%2fcli", { signal: ctl.signal });
5597
+ clearTimeout(timer);
5598
+ if (!res.ok) return null;
5599
+ const doc = await res.json();
5600
+ const latest = doc["dist-tags"]?.latest;
5601
+ if (latest === void 0) return null;
5602
+ const stamp = doc.time?.[latest];
5603
+ return { latest, ...stamp !== void 0 ? { publishedAt: stamp.slice(0, 10) } : {} };
5604
+ } catch {
5605
+ return null;
5606
+ }
5607
+ }
5608
+ async function runDoctor(flags) {
5609
+ if (flags.describe) {
5610
+ printDescription(DOCTOR_DESCRIPTION);
5611
+ return;
5612
+ }
5613
+ const [report, latest] = await Promise.all([runDoctorChecks({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} }), latestVersionInfo()]);
5614
+ emitData(flags, { version: cliVersion(), ...latest !== null ? { latest: latest.latest, latestPublishedAt: latest.publishedAt ?? null, updateAvailable: versionIsNewer(cliVersion(), latest.latest) } : {}, ...report }, () => {
5615
+ const version = cliVersion();
5616
+ if (latest === null) {
5617
+ process.stdout.write(`tendril ${version} (latest: unreachable)
5618
+ `);
5619
+ } else if (!versionIsNewer(version, latest.latest)) {
5620
+ process.stdout.write(`tendril ${version} (${latest.latest === version ? "latest" : `ahead of registry ${latest.latest}`}${latest.publishedAt !== void 0 ? `, published ${latest.publishedAt}` : ""})
5621
+ `);
5622
+ } else {
5623
+ process.stdout.write(`tendril ${version} \u2014 UPDATE AVAILABLE: ${latest.latest}${latest.publishedAt !== void 0 ? ` (published ${latest.publishedAt})` : ""}
5624
+ `);
5625
+ process.stdout.write(` \u2192 npm install -g @tendrilapp/cli@latest (plugin MCP users update automatically at next session; a stale npx cache clears with npm cache clean --force)
5626
+ `);
5627
+ }
5628
+ for (const check of report.checks) {
5629
+ process.stdout.write(`${check.ok ? "\u2713" : "\u2717"} ${check.name}: ${check.detail}
5630
+ `);
5631
+ if (check.remediation) process.stdout.write(` \u2192 ${check.remediation}
5632
+ `);
5633
+ }
5634
+ process.stdout.write(report.ok ? "\nready to generate\n" : "\nnot ready \u2014 fix the items above\n");
5635
+ });
5636
+ if (!report.ok) process.exit(1);
5637
+ }
5638
+ var DEFAULT_MCP_URL, DOCTOR_DESCRIPTION;
5639
+ var init_doctor = __esm({
5640
+ "packages/cli/src/commands/doctor.ts"() {
5641
+ "use strict";
5642
+ init_src4();
5643
+ init_src();
5644
+ init_describe();
5645
+ init_env();
5646
+ init_environment();
5647
+ init_output();
5648
+ init_entitlement();
5649
+ DEFAULT_MCP_URL = "http://127.0.0.1:3845/mcp";
5650
+ DOCTOR_DESCRIPTION = {
5651
+ name: "doctor",
5652
+ summary: "Check whether this machine can run tendril generate end to end.",
5653
+ args: [],
5654
+ flags: [
5655
+ { flag: "--mcp-url <url>", description: "Figma MCP endpoint", default: DEFAULT_MCP_URL },
5656
+ { flag: "--json", description: "Machine-readable output" }
5657
+ ],
5658
+ output: {
5659
+ ok: "boolean \u2014 Figma desktop MCP reachable AND a scoring browser found (fonts resolve lazily per design system; keys are informational)",
5660
+ checks: "[{ name, ok, detail, remediation? }]"
5661
+ },
5662
+ exitCodes: { 0: "healthy", 1: "one or more required checks failed" },
5663
+ examples: ["tendril doctor", "tendril doctor --json"]
5664
+ };
5665
+ }
5666
+ });
5667
+
5389
5668
  // packages/llm/src/model-config.ts
5390
5669
  import { z as z6 } from "zod";
5391
5670
  function resolveModel(config, requestedId) {
@@ -6687,7 +6966,8 @@ __export(record_exports, {
6687
6966
  runRecordPlan: () => runRecordPlan,
6688
6967
  runRecordStatus: () => runRecordStatus
6689
6968
  });
6690
- import { existsSync as existsSync19, readFileSync as readFileSync16, readdirSync as readdirSync5 } from "node:fs";
6969
+ import { existsSync as existsSync19, mkdtempSync as mkdtempSync2, readFileSync as readFileSync16, readdirSync as readdirSync5 } from "node:fs";
6970
+ import os5 from "node:os";
6691
6971
  import path25 from "node:path";
6692
6972
  import { writeFileSync as writeFileSync9 } from "node:fs";
6693
6973
  function symbolsFromMetadataEnvelope(file, sourceFrame) {
@@ -6734,10 +7014,42 @@ function runRecordPlan(opts) {
6734
7014
  }
6735
7015
  defaults[spec.slice(0, eq)] = spec.slice(eq + 1);
6736
7016
  }
7017
+ if (opts.metadataRawFile !== void 0 && opts.metadataRawPartsFile !== void 0) {
7018
+ fail(opts, ExitCode.InputValidation, {
7019
+ error: "pass at most one of --metadata-raw-file and --metadata-raw-parts-file",
7020
+ code: "bad-envelope",
7021
+ remediation: "One get_metadata response: single block \u2192 --metadata-raw-file; multi-block \u2192 --metadata-raw-parts-file (a JSON array of the blocks)."
7022
+ });
7023
+ }
7024
+ const metadataEntries = (opts.metadataFiles ?? []).map((spec) => {
7025
+ const [file, frame] = spec.split("@");
7026
+ return frame === void 0 ? { file } : { file, frame };
7027
+ });
7028
+ const rawFile = opts.metadataRawPartsFile ?? opts.metadataRawFile;
7029
+ if (rawFile !== void 0) {
7030
+ try {
7031
+ const envelope = rawEnvelopeFromFile(rawFile, opts.metadataRawPartsFile !== void 0);
7032
+ const tmp = path25.join(mkdtempSync2(path25.join(os5.tmpdir(), "tendril-plan-meta-")), "get_metadata.json");
7033
+ writeFileSync9(tmp, JSON.stringify(envelope));
7034
+ metadataEntries.push({ file: tmp });
7035
+ } catch (err) {
7036
+ fail(opts, ExitCode.InputValidation, {
7037
+ error: `could not read raw metadata ${rawFile}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
7038
+ code: "bad-envelope",
7039
+ remediation: "The raw file holds the get_metadata response text exactly as received (or, for --metadata-raw-parts-file, a JSON array of its blocks)."
7040
+ });
7041
+ }
7042
+ }
7043
+ if (metadataEntries.length === 0) {
7044
+ fail(opts, ExitCode.InputValidation, {
7045
+ error: "no metadata provided",
7046
+ code: "bad-envelope",
7047
+ remediation: "Pass --metadata <envelope.json> (repeatable), or the response text via --metadata-raw-file / --metadata-raw-parts-file."
7048
+ });
7049
+ }
6737
7050
  let symbols = [];
6738
7051
  let metadataTruncated = false;
6739
- for (const spec of opts.metadataFiles) {
6740
- const [file, frame] = spec.split("@");
7052
+ for (const { file, frame } of metadataEntries) {
6741
7053
  try {
6742
7054
  const parsed = symbolsFromMetadataEnvelope(path25.resolve(file), frame);
6743
7055
  symbols.push(...parsed.symbols);
@@ -6770,8 +7082,7 @@ function runRecordPlan(opts) {
6770
7082
  });
6771
7083
  }
6772
7084
  if (symbols.length === 0) {
6773
- const leads = opts.metadataFiles.flatMap((spec) => {
6774
- const [file] = spec.split("@");
7085
+ const leads = metadataEntries.flatMap(({ file }) => {
6775
7086
  try {
6776
7087
  const env = JSON.parse(readFileSync16(path25.resolve(file), "utf8"));
6777
7088
  return instanceLeads(env.content.map((c) => c.text ?? "").join("\n"));
@@ -8464,8 +8775,8 @@ var init_adapter = __esm({
8464
8775
  });
8465
8776
 
8466
8777
  // packages/generate/src/bundle-emit.ts
8467
- import { createHash as createHash3 } from "node:crypto";
8468
- import { existsSync as existsSync23, readFileSync as readFileSync20, readdirSync as readdirSync7, writeFileSync as writeFileSync11 } from "node:fs";
8778
+ import { createHash as createHash4 } from "node:crypto";
8779
+ import { copyFileSync, existsSync as existsSync23, mkdirSync as mkdirSync7, readFileSync as readFileSync20, readdirSync as readdirSync7, rmSync as rmSync3, writeFileSync as writeFileSync11 } from "node:fs";
8469
8780
  import path29 from "node:path";
8470
8781
  function pinFromConfigs(configs) {
8471
8782
  const domains = /* @__PURE__ */ new Map();
@@ -8514,6 +8825,37 @@ function cssFontFamilies(css) {
8514
8825
  }
8515
8826
  return [...out];
8516
8827
  }
8828
+ function fontsCssFor(faces, bundleDir, cacheDir = DEFAULT_FONT_CACHE) {
8829
+ const header = [
8830
+ "/* tendril fonts.css \u2014 the exact faces this bundle was scored with (sha-pinned in",
8831
+ " component.json requiredFonts). Load it alongside styles.css so the component",
8832
+ " renders in the recorded typeface \u2014 without it the browser substitutes, which is",
8833
+ " exactly the drift verification exists to rule out. Not read by scoring. */"
8834
+ ];
8835
+ const lines = [];
8836
+ for (const face of faces) {
8837
+ const src = path29.join(cacheDir, path29.basename(face.file));
8838
+ const target = `./fonts/${path29.basename(face.file)}`;
8839
+ const format = FONT_FORMATS[path29.extname(face.file).toLowerCase()] ?? "truetype";
8840
+ const family = face.family.replace(/['\\]/g, "").replace(/\*\//g, "");
8841
+ const decl = `@font-face { font-family: '${family}'; font-weight: ${face.weight}; font-style: normal; src: url(${target}) format('${format}'); }`;
8842
+ if (face.source.startsWith("local:")) {
8843
+ lines.push(
8844
+ `/* '${family}' ${face.weight} is a user-licensed face (tendril fonts add; sha256 ${face.sha256.slice(0, 16)}\u2026).`,
8845
+ ` Licensed bytes are never copied into bundles \u2014 place your copy at ${target} and uncomment: */`,
8846
+ `/* ${decl} */`
8847
+ );
8848
+ } else if (existsSync23(src) && createHash4("sha256").update(readFileSync20(src)).digest("hex") === face.sha256) {
8849
+ mkdirSync7(path29.join(bundleDir, "fonts"), { recursive: true });
8850
+ copyFileSync(src, path29.join(bundleDir, "fonts", path29.basename(face.file)));
8851
+ lines.push(decl);
8852
+ } else {
8853
+ lines.push(`/* '${family}' ${face.weight} (sha256 ${face.sha256.slice(0, 16)}\u2026) could not be shipped: cache bytes missing or hash mismatch \u2014 re-run \`tendril fonts resolve\` and re-emit. */`);
8854
+ }
8855
+ }
8856
+ return lines.length === 0 ? null : `${[...header, ...lines].join("\n")}
8857
+ `;
8858
+ }
8517
8859
  function countLatticeSymbols(setDir) {
8518
8860
  const manifestFile = path29.join(setDir, "recording-set.json");
8519
8861
  if (existsSync23(manifestFile)) {
@@ -8554,7 +8896,7 @@ function recordingSetHash(setDir, configs) {
8554
8896
  relPaths,
8555
8897
  (p) => new Uint8Array(readFileSync20(path29.join(setDir, p))),
8556
8898
  (chunks) => {
8557
- const h = createHash3("sha256");
8899
+ const h = createHash4("sha256");
8558
8900
  for (const c of chunks) h.update(c);
8559
8901
  return h.digest("hex");
8560
8902
  }
@@ -8564,7 +8906,11 @@ function statusOf(s) {
8564
8906
  return tierOf(s, BARS.cert);
8565
8907
  }
8566
8908
  function emitBundleV1(opts) {
8567
- const statuses = opts.scores.map((s) => ({ rep: s.rep, similarity: s.similarity, inkRecall: s.inkRecall, ...s.exact !== void 0 ? { exact: s.exact } : {}, status: statusOf(s) }));
8909
+ const substituted = (opts.substitutedFamilies ?? []).length > 0;
8910
+ const statuses = opts.scores.map((s) => {
8911
+ const tier = statusOf(s);
8912
+ return { rep: s.rep, similarity: s.similarity, inkRecall: s.inkRecall, ...s.exact !== void 0 ? { exact: s.exact } : {}, status: substituted && tier === "certified" ? "pass" : tier };
8913
+ });
8568
8914
  const certified = statuses.filter((s) => s.status === "certified").length;
8569
8915
  const pass = statuses.filter((s) => s.status !== "fail").length;
8570
8916
  const lattice = countLatticeSymbols(opts.task.set);
@@ -8637,17 +8983,26 @@ function emitBundleV1(opts) {
8637
8983
  ${stripped}`);
8638
8984
  written.push(stylesPath);
8639
8985
  }
8986
+ const fontsCssPath = path29.join(opts.bundleDir, "fonts.css");
8987
+ rmSync3(path29.join(opts.bundleDir, "fonts"), { recursive: true, force: true });
8988
+ rmSync3(fontsCssPath, { force: true });
8989
+ const fontsCss = fontsCssFor(requiredFontsManifest(families, opts.fontCacheDir), opts.bundleDir, opts.fontCacheDir);
8990
+ if (fontsCss !== null) {
8991
+ writeFileSync11(fontsCssPath, fontsCss);
8992
+ written.push(fontsCssPath);
8993
+ }
8640
8994
  const unrecorded = lattice === null ? null : Math.max(0, lattice - statuses.length);
8641
8995
  const statusLine = `bundle: ${pass}/${statuses.length} recorded configs \u2265 pass bar (${certified} certified), ` + (interaction.length === 0 ? "interaction behaviors NONE VERIFIED" : `interaction behaviors ${interaction.filter((b) => b.pass).length}/${interaction.length}`) + (prelude.length === 0 ? "" : `, page hygiene ${prelude.filter((b) => b.pass).length}/${prelude.length}`) + (unrecorded === null ? "" : `; ${unrecorded} lattice configs UNVERIFIED`) + ` \u2014 claims in component.json, recompute with \`tendril verify\``;
8642
8996
  return { manifest, statusLine, written };
8643
8997
  }
8644
- var BARS;
8998
+ var FONT_FORMATS, BARS;
8645
8999
  var init_bundle_emit = __esm({
8646
9000
  "packages/generate/src/bundle-emit.ts"() {
8647
9001
  "use strict";
8648
9002
  init_src6();
8649
9003
  init_src();
8650
9004
  init_src4();
9005
+ FONT_FORMATS = { ".woff2": "woff2", ".woff": "woff", ".ttf": "truetype", ".otf": "opentype" };
8651
9006
  BARS = {
8652
9007
  pass: { sim: 0.95, ink: 0.95 },
8653
9008
  cert: { sim: 0.97, ink: 0.95 }
@@ -8826,6 +9181,12 @@ function taskFontFamilies(setDir) {
8826
9181
  return null;
8827
9182
  }
8828
9183
  }
9184
+ function unprovisionedFamilies(setDir, cacheDir) {
9185
+ const declared = taskFontFamilies(setDir);
9186
+ if (declared === null) return [];
9187
+ const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
9188
+ return declared.filter((f) => !provided.has(f.toLowerCase()));
9189
+ }
8829
9190
  var init_font_guidance = __esm({
8830
9191
  "packages/cli/src/font-guidance.ts"() {
8831
9192
  "use strict";
@@ -8990,6 +9351,12 @@ async function runVerify(opts) {
8990
9351
  remediation: fontsUnprovenRemediation(task.set)
8991
9352
  });
8992
9353
  }
9354
+ const substitutedFamilies = unprovisionedFamilies(task.set);
9355
+ if (substitutedFamilies.length > 0) {
9356
+ warn(opts, `substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 scores measure a substitute face and no config can be certified under one (the stamp covers only the provisioned subset); resolve the families to certify`);
9357
+ } else if (opts.bar === "cert" && taskFontFamilies(task.set) === null) {
9358
+ warn(opts, "family coverage could not be established from this recording (no declared families) \u2014 the certification gate covers only cache non-emptiness here");
9359
+ }
8993
9360
  const missing = task.configs.filter(
8994
9361
  (c) => !existsSync25(path31.join(task.set, c.rep, "get_screenshot.json")) || !existsSync25(path31.join(task.set, c.rep, "get_metadata.json"))
8995
9362
  );
@@ -9003,7 +9370,7 @@ async function runVerify(opts) {
9003
9370
  const bar = BARS2[opts.bar];
9004
9371
  const evidenceDir = path31.join(opts.bundleDir, "verify-evidence");
9005
9372
  const scores = await scoreBundleForTask(task, opts.bundleDir, bar, { evidenceDir, onProgress: emitProgress });
9006
- const quality = await checkBundleQuality(opts.bundleDir, task.entry);
9373
+ const quality = await checkBundleQuality(opts.bundleDir, task.entry, { dir: task.set, reps: task.configs.map((c) => c.rep) });
9007
9374
  const bundleCss = ["tokens.css", "styles.css"].map((f) => path31.join(opts.bundleDir, f)).filter((f) => existsSync25(f)).map((f) => readFileSync22(f, "utf8")).join("\n");
9008
9375
  const occlusion = await checkSiblingOcclusion(task, opts.bundleDir, bundleCss);
9009
9376
  const parity = await checkHoverParity(task, opts.bundleDir);
@@ -9029,6 +9396,10 @@ async function runVerify(opts) {
9029
9396
  status = "pass";
9030
9397
  certDemote.push(...s.absentInk.map((c) => `absent ink: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with no render ink within reach (missing or invisible feature)`));
9031
9398
  }
9399
+ if (status === "certified" && substitutedFamilies.length > 0) {
9400
+ status = "pass";
9401
+ certDemote.push(`substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 certification never measures a substitute face`);
9402
+ }
9032
9403
  const base = certDemote.length > 0 ? { ...reported, status, demotedBy: certDemote } : { ...reported, status };
9033
9404
  const reasons = demotions.get(s.rep);
9034
9405
  return reasons === void 0 ? base : { ...base, status: "fail", demotedBy: [...certDemote.length > 0 ? certDemote : [], ...reasons] };
@@ -9191,9 +9562,27 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
9191
9562
  }
9192
9563
  process.stdout.write(`environment: chrome=${report.environment.chromeVersion ?? report.environment.chrome} fonts=${report.environment.fontsManifestSha256 ?? "UNRESOLVED"}
9193
9564
  `);
9565
+ const shippedFonts = requiredFontsManifest(cssFontFamilies(["styles.css", "tokens.css"].map((f) => path31.join(opts.bundleDir, f)).filter((f) => existsSync25(f)).map((f) => readFileSync22(f, "utf8")).join("\n")));
9566
+ if (shippedFonts.length > 0 && substitutedFamilies.length === 0) {
9567
+ process.stdout.write(`fonts: scored with Tendril-cache faces \u2014 a consuming app must provision the same families (the bundle ships fonts.css when faces are shippable; sha-pinned list in component.json requiredFonts)
9568
+ `);
9569
+ }
9194
9570
  process.stdout.write(`evidence: ${evidenceDir} (render/ref/diff per config)
9195
9571
  `);
9196
9572
  });
9573
+ if (opts.bar === "cert" && substitutedFamilies.length > 0) {
9574
+ const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
9575
+ if (opts.json) {
9576
+ process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
9577
+ `);
9578
+ } else {
9579
+ process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
9580
+ \u2192 ${fontsUnprovenRemediation(task.set)}
9581
+ `);
9582
+ }
9583
+ process.exitCode = ExitCode.FontsUnproven;
9584
+ return;
9585
+ }
9197
9586
  if (!ok) {
9198
9587
  warn(
9199
9588
  opts,
@@ -9227,7 +9616,7 @@ __export(engine_exports, {
9227
9616
  runEngineBrief: () => runEngineBrief,
9228
9617
  runEngineScore: () => runEngineScore
9229
9618
  });
9230
- import { existsSync as existsSync26, mkdirSync as mkdirSync7, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
9619
+ import { existsSync as existsSync26, mkdirSync as mkdirSync8, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
9231
9620
  import path32 from "node:path";
9232
9621
  function resolveEngineTask(opts, callerCwd) {
9233
9622
  const asPath = path32.resolve(callerCwd, opts.taskOrSet);
@@ -9276,7 +9665,7 @@ DO NOT INVENT PIXELS FOR THESE POSES. Implement them ONLY as the composition of
9276
9665
  ${notRecorded}` : "";
9277
9666
  let fontProvisioning;
9278
9667
  if (existsSync26(manifestPath2)) {
9279
- const provided = resolvedFontFamilies().map((f) => f.family);
9668
+ const provided = verifiedFontFamilies().map((f) => f.family);
9280
9669
  const unprovided = recordedFontFamilies(task.set).filter((r) => !provided.some((p) => p.toLowerCase() === r.toLowerCase()));
9281
9670
  if (unprovided.length > 0) {
9282
9671
  const names = unprovided.map((f) => `'${f}'`).join(", ");
@@ -9286,10 +9675,10 @@ ${notRecorded}` : "";
9286
9675
  prompt: `This design's text uses ${names}, which is not in the local font kit. How should it be handled?`,
9287
9676
  options: [
9288
9677
  `(Recommended) Run \`tendril fonts resolve --set ${task.set}\` \u2014 open-source families are fetched automatically. Any face that fails there is one you license privately: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold (the file never leaves this machine). Then re-run this brief \u2014 exact text fidelity.`,
9289
- "Continue with a substitute face: generation proceeds in a provided family, the report discloses the substitution, and small text differences are expected and not fixable from CSS."
9678
+ 'Continue with a substitute face: generation proceeds in a provided family, no config can score "certified" under it (cert-bar runs exit fonts-unproven after their report), the substitution is disclosed, and small text differences are expected and not fixable from CSS.'
9290
9679
  ]
9291
9680
  },
9292
- nonInteractive: "Continue with the substitute and state the substitution in your report."
9681
+ nonInteractive: opts.bar === "cert" ? `Resolve the families first (\`tendril fonts resolve --set ${task.set}\`) \u2014 this loop targets the cert bar, and scoring exits fonts-unproven under a substitute, so continuing without resolving is a dead end.` : "Continue with the substitute and state the substitution in your report."
9293
9682
  };
9294
9683
  }
9295
9684
  }
@@ -9298,7 +9687,7 @@ ${notRecorded}` : "";
9298
9687
  === TASK PAYLOAD (recorded truth, verbatim) ===
9299
9688
  ${segments}`;
9300
9689
  const payloadFile = path32.resolve(callerCwd, opts.out ?? `tendril-out/${name}-brief.md`);
9301
- mkdirSync7(path32.dirname(payloadFile), { recursive: true });
9690
+ mkdirSync8(path32.dirname(payloadFile), { recursive: true });
9302
9691
  writeFileSync12(payloadFile, payload);
9303
9692
  emitData(
9304
9693
  opts,
@@ -9359,6 +9748,10 @@ async function runEngineScore(opts) {
9359
9748
  remediation: fontsUnprovenRemediation(task.set)
9360
9749
  });
9361
9750
  }
9751
+ const substitutedFamilies = unprovisionedFamilies(task.set);
9752
+ if (substitutedFamilies.length > 0) {
9753
+ warn(opts, `substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 scores measure a substitute face and no config can be certified under one (the stamp covers only the provisioned subset); resolve the families to certify`);
9754
+ }
9362
9755
  if (opts.rebind !== true && existsSync26(path32.join(candidateDir, "component.json"))) {
9363
9756
  const prior = (() => {
9364
9757
  try {
@@ -9391,7 +9784,7 @@ async function runEngineScore(opts) {
9391
9784
  const parityCoverage = parity.length > 0 ? `${parity.filter((p) => p.pass).length}/${parity.length} hover-forced configs` : "not applicable (no hover-forced configs in this set)";
9392
9785
  const obj = objective(scores, behaviors);
9393
9786
  const total = scores.length + behaviors.length;
9394
- const quality = await checkBundleQuality(candidateDir, task.entry);
9787
+ const quality = await checkBundleQuality(candidateDir, task.entry, { dir: task.set, reps: task.configs.map((c) => c.rep) });
9395
9788
  const qualityFeedback = quality.findings.length === 0 && !quality.tokensAbsent ? "" : `
9396
9789
 
9397
9790
  QUALITY (does not affect the bar \u2014 fix alongside the failing configs):
@@ -9402,7 +9795,7 @@ ${[
9402
9795
  const allPass = obj[0] === total && total > 0;
9403
9796
  const certBar = BARS3["cert"];
9404
9797
  const parityDemoted = new Set(parity.filter((p) => !p.pass).map((p) => p.id.replace(/^parity:/, "")));
9405
- const certifiedReps = scores.filter((sc) => tierOf(sc, certBar) === "certified" && !parityDemoted.has(sc.rep) && (sc.absentInk === void 0 || sc.absentInk.length === 0)).map((sc) => sc.rep);
9798
+ const certifiedReps = substitutedFamilies.length > 0 ? [] : scores.filter((sc) => tierOf(sc, certBar) === "certified" && !parityDemoted.has(sc.rep) && (sc.absentInk === void 0 || sc.absentInk.length === 0)).map((sc) => sc.rep);
9406
9799
  const certifiedSet = new Set(certifiedReps);
9407
9800
  const absentFindings = scores.flatMap((sc) => (sc.absentInk ?? []).map((c) => `- ${sc.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}] (missing or invisible feature; magnified crops in the evidence dir; coordinates are reference-space \u2014 subtract the config's seat for box-space). A certified-tier config with such a finding is demoted to pass.`));
9408
9801
  const absentBlock = absentFindings.length > 0 ? `
@@ -9423,7 +9816,8 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
9423
9816
  model: `${opts.model} (self-reported${opts.host !== void 0 ? ` via ${opts.host}` : ""})`,
9424
9817
  scores,
9425
9818
  behaviors,
9426
- environment: environmentStamp(taskFontFamilies(task.set))
9819
+ environment: environmentStamp(taskFontFamilies(task.set)),
9820
+ substitutedFamilies
9427
9821
  });
9428
9822
  emitData(
9429
9823
  opts,
@@ -9461,6 +9855,19 @@ ${obj[0]}/${total} \xB7 certified ${certifiedReps.length}/${total} \xB7 floor=${
9461
9855
  }
9462
9856
  }
9463
9857
  );
9858
+ if (opts.bar === "cert" && substitutedFamilies.length > 0) {
9859
+ const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
9860
+ if (opts.json) {
9861
+ process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
9862
+ `);
9863
+ } else {
9864
+ process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
9865
+ \u2192 ${fontsUnprovenRemediation(task.set)}
9866
+ `);
9867
+ }
9868
+ process.exitCode = ExitCode.FontsUnproven;
9869
+ return;
9870
+ }
9464
9871
  if (!allPass) process.exitCode = ExitCode.VerificationFailed;
9465
9872
  }
9466
9873
  var BARS3;
@@ -9660,9 +10067,402 @@ var init_codeconnect = __esm({
9660
10067
  }
9661
10068
  });
9662
10069
 
9663
- // packages/cli/src/commands/generate-route.ts
9664
- var generate_route_exports = {};
9665
- __export(generate_route_exports, {
10070
+ // packages/mcp/src/server.ts
10071
+ import { createHash as createHash5 } from "node:crypto";
10072
+ import { existsSync as existsSync28, mkdtempSync as mkdtempSync3, readFileSync as readFileSync25, readdirSync as readdirSync8, writeFileSync as writeFileSync14 } from "node:fs";
10073
+ import os6 from "node:os";
10074
+ import path34 from "node:path";
10075
+ import { fileURLToPath as fileURLToPath5 } from "node:url";
10076
+ import { z as z12 } from "zod";
10077
+ function sourceHash() {
10078
+ const dir = path34.dirname(fileURLToPath5(import.meta.url));
10079
+ const h = createHash5("sha256");
10080
+ for (const f of readdirSync8(dir).filter((n) => n.endsWith(".ts")).sort()) {
10081
+ h.update(f);
10082
+ h.update(readFileSync25(path34.join(dir, f)));
10083
+ }
10084
+ return h.digest("hex").slice(0, 16);
10085
+ }
10086
+ var REPO_ROOT3, CLI_BIN, BUNDLED_CLI, CLI_SPAWN, str, optStr, TOOLS, BOOT_HASH;
10087
+ var init_server = __esm({
10088
+ "packages/mcp/src/server.ts"() {
10089
+ "use strict";
10090
+ REPO_ROOT3 = path34.resolve(path34.dirname(fileURLToPath5(import.meta.url)), "..", "..", "..");
10091
+ CLI_BIN = path34.join(REPO_ROOT3, "packages", "cli", "src", "bin.ts");
10092
+ BUNDLED_CLI = path34.join(path34.dirname(fileURLToPath5(import.meta.url)), "tendril.js");
10093
+ CLI_SPAWN = existsSync28(BUNDLED_CLI) ? { cmd: process.execPath, prefix: [BUNDLED_CLI] } : { cmd: "npx", prefix: ["tsx", CLI_BIN] };
10094
+ str = (d) => z12.string().describe(d);
10095
+ optStr = (d) => z12.string().optional().describe(d);
10096
+ TOOLS = [
10097
+ {
10098
+ name: "tendril_record_plan",
10099
+ description: "ENTRY POINT for implementing/building a React component from a Figma design or figma.com URL \u2014 start the Tendril pipeline here (after loading the tendril skill, if installed). Plans a recording session: computes the rep queue (anchor + one-factor + conflict crosses) from the verbatim get_metadata response (pass its blocks via metadataParts \u2014 no file to write) and persists the set manifest. Resumes if the set already exists. The output may carry USER QUESTIONS \u2014 defaultsToConfirm (which pose is the component's default) or a multiple-component-sets error (which set to record): render them to a present user and apply the answers via `defaults` / `componentSet`; non-interactive runs follow each question's stated fallback. Recording cost is stated in figmaCallEstimate, never asked about \u2014 proceed with what the user provided.",
10100
+ schema: z12.object({
10101
+ setDir: str("recording set directory to create/resume"),
10102
+ component: str("component/system name"),
10103
+ // Parts array FIRST, same reason as ingest_rep: real responses
10104
+ // are usually multi-block, and the file param made plan the ONE
10105
+ // remaining hand-built-envelope entry point (run 10: the agent
10106
+ // wrote the file twice — once as text, once as JSON envelope).
10107
+ metadataParts: z12.array(z12.string()).optional().describe("the frame-level get_metadata response blocks, every block in order, each verbatim \u2014 never hand-join or save them yourself; this is the NORMAL param"),
10108
+ metadata: optStr("ONLY when get_metadata genuinely returned one single block: its text verbatim (otherwise use metadataParts)"),
10109
+ metadataFiles: z12.array(z12.string()).optional().describe('saved verbatim get_metadata envelope file paths \u2014 JSON shape {"content":[{"type":"text","text":"<frame \u2026>"}]}; optionally <file>@<frameId>. Prefer metadataParts: no file to write'),
10110
+ defaults: z12.array(z12.string()).optional().describe(`axis defaults as "Axis=Value" (from the user's defaultsToConfirm answers; may re-plan a set with nothing recorded yet)`),
10111
+ componentSet: optStr("record only this component set (the user's pick from a multiple-component-sets error)")
10112
+ }),
10113
+ // Texts ride temp files, never argv: Windows caps a command line at
10114
+ // ~32 KB and metadata envelopes can exceed it.
10115
+ argv: (i) => {
10116
+ const argvOut = ["record", "plan", "--set", i["setDir"], "--component", i["component"]];
10117
+ if (i["metadata"] !== void 0 && i["metadataParts"] !== void 0) throw new Error("pass at most one of `metadata` and `metadataParts`");
10118
+ const files = i["metadataFiles"];
10119
+ if (files !== void 0 && files.length > 0) argvOut.push("--metadata", ...files);
10120
+ const single = i["metadata"];
10121
+ const parts = i["metadataParts"];
10122
+ if (single !== void 0 || parts !== void 0) {
10123
+ const tmp = path34.join(mkdtempSync3(path34.join(os6.tmpdir(), "tendril-envelope-")), "response.txt");
10124
+ if (single !== void 0) {
10125
+ writeFileSync14(tmp, single);
10126
+ argvOut.push("--metadata-raw-file", tmp);
10127
+ } else {
10128
+ if (parts.length === 0) throw new Error("`metadataParts` must be a non-empty array of block texts");
10129
+ writeFileSync14(tmp, JSON.stringify(parts));
10130
+ argvOut.push("--metadata-raw-parts-file", tmp);
10131
+ }
10132
+ }
10133
+ if (argvOut.length === 6) throw new Error("nothing to plan from \u2014 pass metadataParts (normal), metadata (single-block), or metadataFiles");
10134
+ argvOut.push(...i["defaults"] !== void 0 ? ["--default", ...i["defaults"]] : [], ...i["componentSet"] !== void 0 ? ["--component-set", i["componentSet"]] : []);
10135
+ return argvOut;
10136
+ }
10137
+ },
10138
+ {
10139
+ name: "tendril_doctor",
10140
+ annotations: { readOnlyHint: true },
10141
+ description: "Machine readiness + version status in one shot: installed vs latest version (with publish date and update remediation), browser identity, font-cache state, Figma desktop MCP reachability. Use to self-diagnose before recording/scoring, or whenever versions are in question. Exit 1 = something not ready; the report says exactly what and how to fix it.",
10142
+ schema: z12.object({}),
10143
+ argv: () => ["doctor"]
10144
+ },
10145
+ {
10146
+ name: "tendril_record_next",
10147
+ annotations: { readOnlyHint: true },
10148
+ description: "Get the next pending recording instruction (which Figma MCP tool to call for which node, and how to save it). RARELY NEEDED: every ingest/fetch response already carries `next` \u2014 use this only to resume an interrupted session. The full queue is known from plan, so independent reps may be recorded in any order (and in parallel).",
10149
+ schema: z12.object({ setDir: str("recording set directory") }),
10150
+ argv: (i) => ["record", "next", "--set", i["setDir"]]
10151
+ },
10152
+ {
10153
+ name: "tendril_record_fetch",
10154
+ description: "Download a Figma asset URL (from get_screenshot's image_url) straight to disk and ingest it as the rep's envelope \u2014 the fallback when only the screenshot piece needs (re-)recording; for a rep's standard three recordings PREFER tendril_record_ingest_rep. Never download the image yourself: the bytes must not pass through your context.",
10155
+ schema: z12.object({
10156
+ setDir: str("recording set directory"),
10157
+ rep: str("planned rep slug"),
10158
+ tool: z12.enum(["get_screenshot"]).describe("get_screenshot"),
10159
+ url: str("image_url from the Figma response, verbatim")
10160
+ }),
10161
+ argv: (i) => ["record", "fetch", "--set", i["setDir"], "--rep", i["rep"], "--tool", i["tool"], "--url", i["url"]]
10162
+ },
10163
+ {
10164
+ name: "tendril_record_ingest_rep",
10165
+ description: "Ingest a rep's ENTIRE recording in ONE call \u2014 the get_metadata response, the get_design_context response, and the get_screenshot image_url together. Make the three Figma calls first, in protocol order (get_metadata, then get_design_context with excludeScreenshot=true, then get_screenshot), then pass all three here VERBATIM. PREFER THIS over three separate ingest/fetch calls: one approvable operation per rep instead of three. Pieces land independently: on a partial failure the error names exactly which piece(s) to re-record \u2014 the rest are already on disk. The response carries `next` and, for design context, `assets` (auto-fetched server-side; only listed failures need record_asset).",
10166
+ schema: z12.object({
10167
+ setDir: str("recording set directory"),
10168
+ rep: str("planned rep slug"),
10169
+ // Parts arrays FIRST: in the field, EVERY real Figma response is
10170
+ // multi-block (run 6: 49/49 reps — metadata 2 blocks, design
10171
+ // context 5-6), so the arrays are the norm and the single-string
10172
+ // params the rare case, not the reverse.
10173
+ metadataParts: z12.array(z12.string()).optional().describe("get_metadata response blocks, every block in order, each verbatim \u2014 never hand-join blocks yourself. Multi-block responses are common (run 6: 49/49 reps); a single block wrapped in a one-element array is equally fine."),
10174
+ contextParts: z12.array(z12.string()).optional().describe("get_design_context response blocks, every block in order, each verbatim \u2014 the NORMAL param (real responses arrive as 5-6 blocks)"),
10175
+ screenshotUrl: optStr("image_url from the get_screenshot response, verbatim \u2014 the CLI downloads it; the bytes never pass through your context"),
10176
+ metadata: optStr("ONLY when get_metadata genuinely returned one single block: its text verbatim (otherwise use metadataParts)"),
10177
+ context: optStr("ONLY when get_design_context genuinely returned one single block: its text verbatim (otherwise use contextParts)")
10178
+ }),
10179
+ // Texts ride temp files, never argv: Windows caps a command line at
10180
+ // ~32 KB and design-context envelopes routinely exceed it.
10181
+ argv: (i) => {
10182
+ const argvOut = ["record", "ingest-rep", "--set", i["setDir"], "--rep", i["rep"]];
10183
+ const bridge = (label, single, parts) => {
10184
+ if (single !== void 0 && parts !== void 0) throw new Error(`pass at most one of \`${label}\` and \`${label}Parts\``);
10185
+ if (single === void 0 && parts === void 0) return;
10186
+ const tmp = path34.join(mkdtempSync3(path34.join(os6.tmpdir(), "tendril-envelope-")), "response.txt");
10187
+ if (single !== void 0) {
10188
+ writeFileSync14(tmp, single);
10189
+ argvOut.push(`--${label}-file`, tmp);
10190
+ } else {
10191
+ const blocks = parts;
10192
+ if (blocks.length === 0) throw new Error(`\`${label}Parts\` must be a non-empty array of block texts`);
10193
+ writeFileSync14(tmp, JSON.stringify(blocks));
10194
+ argvOut.push(`--${label}-parts-file`, tmp);
10195
+ }
10196
+ };
10197
+ bridge("metadata", i["metadata"], i["metadataParts"]);
10198
+ bridge("context", i["context"], i["contextParts"]);
10199
+ if (i["screenshotUrl"] !== void 0) argvOut.push("--screenshot-url", i["screenshotUrl"]);
10200
+ if (argvOut.length === 6) throw new Error("nothing to ingest \u2014 pass at least one of metadata/metadataParts, context/contextParts, screenshotUrl");
10201
+ return argvOut;
10202
+ }
10203
+ },
10204
+ {
10205
+ name: "tendril_record_ingest",
10206
+ description: "Single-piece ingest of a VERBATIM Figma tool-response \u2014 the fallback path (re-recording one failed piece, set-level get_variable_defs, get_metadata_interior for mains); for a rep's standard three recordings PREFER tendril_record_ingest_rep, which takes them all in one call. Pass `text` (single block) or `texts` (response split into multiple output blocks \u2014 each block verbatim, in order; NEVER hand-join them): the CLI constructs the envelope from the same bytes. The response includes `next` and, for get_design_context, `assets` (auto-fetched; only listed failures need manual handling).",
10207
+ schema: z12.object({
10208
+ setDir: str("recording set directory"),
10209
+ rep: str("planned rep slug, or __set__ for the set-level get_variable_defs"),
10210
+ // Enumerated, not a free string: this value reaches a file path.
10211
+ // As a bare string it was an arbitrary relative-path overwrite
10212
+ // (--tool "../../outside/victim" replaced a file outside the set,
10213
+ // exit 0). The sink in session.ts now contains the path too — this
10214
+ // is the second layer, and it makes the tool self-documenting.
10215
+ tool: z12.enum(["get_design_context", "get_metadata", "get_screenshot", "get_variable_defs", "get_metadata_interior"]),
10216
+ text: optStr("the tool response text VERBATIM \u2014 exactly as returned, unmodified (single-block responses; text tools only \u2014 screenshots go through record_fetch)"),
10217
+ texts: z12.array(z12.string()).optional().describe("when the response arrived as MULTIPLE output blocks: every block, in order, each verbatim \u2014 never hand-join blocks yourself"),
10218
+ file: optStr("path to a saved envelope JSON (alternative to text/texts)")
10219
+ }),
10220
+ // The text rides a temp file, never argv: Windows caps a command
10221
+ // line at ~32 KB and design-context envelopes routinely exceed it.
10222
+ argv: (i) => {
10223
+ const base = ["record", "ingest", "--set", i["setDir"], "--rep", i["rep"], "--tool", i["tool"]];
10224
+ const text = i["text"];
10225
+ const texts = i["texts"];
10226
+ const file = i["file"];
10227
+ if ([text, texts, file].filter((x) => x !== void 0).length !== 1) throw new Error("pass exactly one of `text` (single-block response), `texts` (multi-block response), or `file` (a saved envelope)");
10228
+ if (file !== void 0) return [...base, "--file", file];
10229
+ const tmp = path34.join(mkdtempSync3(path34.join(os6.tmpdir(), "tendril-envelope-")), "response.txt");
10230
+ if (text !== void 0) {
10231
+ writeFileSync14(tmp, text);
10232
+ return [...base, "--file", tmp, "--raw"];
10233
+ }
10234
+ writeFileSync14(tmp, JSON.stringify(texts));
10235
+ return [...base, "--file", tmp, "--raw-parts"];
10236
+ }
10237
+ },
10238
+ {
10239
+ name: "tendril_record_asset",
10240
+ description: "FALLBACK ONLY \u2014 ingest auto-fetches design-context assets; use this just for assets listed in an ingest response's `assets.failed`. Batch mode: pass `dir` to ingest every asset-*.<ext> in a directory in ONE call. SVGs with active content are rejected; sizes are capped.",
10241
+ schema: z12.object({
10242
+ setDir: str("recording set directory"),
10243
+ rep: str("planned rep slug"),
10244
+ name: optStr("asset-<id>.<ext> (single-asset mode)"),
10245
+ file: optStr("downloaded asset file path (single-asset mode)"),
10246
+ dir: optStr("batch mode: directory holding downloaded asset-*.<ext> files")
10247
+ }),
10248
+ argv: (i) => [
10249
+ "record",
10250
+ "asset",
10251
+ "--set",
10252
+ i["setDir"],
10253
+ "--rep",
10254
+ i["rep"],
10255
+ ...i["dir"] !== void 0 ? ["--dir", i["dir"]] : ["--name", i["name"], "--file", i["file"]]
10256
+ ]
10257
+ },
10258
+ {
10259
+ name: "tendril_record_status",
10260
+ annotations: { readOnlyHint: true },
10261
+ description: "Recording-set completeness: per-rep recorded/missing tools.",
10262
+ schema: z12.object({ setDir: str("recording set directory") }),
10263
+ argv: (i) => ["record", "status", "--set", i["setDir"]]
10264
+ },
10265
+ {
10266
+ name: "tendril_engine_brief",
10267
+ description: "AGENT-HARNESS engine, step 1: emits the task payload file (system brief + every recorded config's emission, box, assets, tokens) and the protocol. YOU (the calling agent) implement the bundle; the CLI is the only judge. Read the payload file completely before proposing.",
10268
+ schema: z12.object({
10269
+ taskOrSet: str("a recording-set directory (from tendril_record), or a reference task name \u2014 an unknown name returns the valid list in the error"),
10270
+ // Required by design, not convenience: the model choice must be
10271
+ // settled BEFORE generation starts. A smoke run picked its own
10272
+ // model silently because the ask lived in instruction text, which
10273
+ // evaporates in non-interactive sessions; a required parameter
10274
+ // cannot evaporate. Ask the user when one is present; otherwise
10275
+ // choose, declare, and state the reason in your report.
10276
+ model: str("the model that will WRITE the implementation \u2014 ask the user when interactive; declare your reasoned choice when not"),
10277
+ bar: optStr("pass (default) or cert"),
10278
+ out: optStr("payload file path override")
10279
+ }),
10280
+ argv: (i) => [
10281
+ "engine",
10282
+ "brief",
10283
+ i["taskOrSet"],
10284
+ "--model",
10285
+ i["model"],
10286
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10287
+ ...i["out"] !== void 0 ? ["--out", i["out"]] : []
10288
+ ]
10289
+ },
10290
+ {
10291
+ name: "tendril_engine_score",
10292
+ description: "AGENT-HARNESS engine, step 2 (the oracle): scores a candidate bundle directory against recorded truth \u2014 per-config pixels, behaviors, hover parity \u2014 and returns feedback plus evidence artifacts. Iterate until allPass or two non-improving rounds. Only THIS tool's output counts as a score; never claim numbers yourself.",
10293
+ schema: z12.object({
10294
+ taskOrSet: str("reference task name or recording-set directory"),
10295
+ candidateDir: str("directory containing the proposed bundle files"),
10296
+ bar: optStr("pass (default) or cert"),
10297
+ host: optStr("your host identity (e.g. claude-code, cursor, codex) \u2014 recorded as self-reported provenance"),
10298
+ model: str("the model that proposed the candidate \u2014 REQUIRED; recorded as self-reported provenance, and the CLI refuses to score without it"),
10299
+ rebind: z12.boolean().optional().describe("explicitly re-bind an already-bound bundle to a DIFFERENT recording set \u2014 scoring refuses this otherwise, because rebinding silently rewrites the bundle's verification identity; only pass after telling the user")
10300
+ }),
10301
+ argv: (i) => [
10302
+ "engine",
10303
+ "score",
10304
+ i["taskOrSet"],
10305
+ i["candidateDir"],
10306
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10307
+ ...i["host"] !== void 0 ? ["--host", i["host"]] : [],
10308
+ "--model",
10309
+ i["model"],
10310
+ ...i["rebind"] === true ? ["--rebind"] : []
10311
+ ]
10312
+ },
10313
+ {
10314
+ name: "tendril_codeconnect",
10315
+ description: "Emit a Figma Code Connect template (.figma.ts) for a certified bundle \u2014 every Figma variant value mapped to its verified prop fragment from recorded truth, stamped with the bundle's trust statement. EXTRA VALUE step after verify passes: offer it to the user. Publishing is the USER'S action (their Figma token, Organization/Enterprise plan) \u2014 via npx @figma/code-connect connect publish, or the Figma MCP's own add_code_connect_map/send_code_connect_mappings tools if available in this session.",
10316
+ schema: z12.object({
10317
+ bundleDir: str("bundle directory (carries component.json)"),
10318
+ figmaUrl: str("figma.com /design/ URL of the COMPONENT SET, with node-id (ask the user to Copy link to selection if you don't have it)"),
10319
+ set: optStr("recording set override (default: the bundle's provenance path)"),
10320
+ out: optStr("output file path (default: <bundle>/<Component>.figma.ts)")
10321
+ }),
10322
+ argv: (i) => [
10323
+ "codeconnect",
10324
+ i["bundleDir"],
10325
+ "--figma-url",
10326
+ i["figmaUrl"],
10327
+ ...i["set"] !== void 0 ? ["--set", i["set"]] : [],
10328
+ ...i["out"] !== void 0 ? ["--out", i["out"]] : []
10329
+ ]
10330
+ },
10331
+ {
10332
+ name: "tendril_verify",
10333
+ description: "Verify/check that a component matches its Figma design \u2014 use when the user asks whether an implementation is faithful to the design, or to re-certify an existing Tendril bundle. Recomputes full verification (per-config status, behaviors, composition, evidence artifacts). Free, account-less, network-less \u2014 the trust anchor. Exit 5 means sub-bar with an honest report.",
10334
+ schema: z12.object({
10335
+ bundleDir: str("bundle directory to verify"),
10336
+ bar: optStr("pass (default) or cert"),
10337
+ set: optStr("recording-set directory override")
10338
+ }),
10339
+ argv: (i) => [
10340
+ "verify",
10341
+ i["bundleDir"],
10342
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10343
+ ...i["set"] !== void 0 ? ["--set", i["set"]] : []
10344
+ ]
10345
+ },
10346
+ {
10347
+ name: "tendril_generate_curated",
10348
+ description: "CURATED engine (explicit alternative path): generation by an allowlisted API model over the user's OpenRouter-compatible key, with cost consent, hard spend caps, and resume. Use ONLY when the user asks for API-model generation instead of implementing it yourself.",
10349
+ schema: z12.object({
10350
+ input: str("reference task name or recording-set directory"),
10351
+ model: optStr("OpenRouter model id (default: allowlist pointer)"),
10352
+ bar: optStr("pass (default) or cert"),
10353
+ cap: optStr("spend cap in USD (default 1.50)"),
10354
+ yes: z12.boolean().optional().describe("accept the cost consent (the user must have approved the spend)")
10355
+ }),
10356
+ argv: (i) => [
10357
+ "generate",
10358
+ i["input"],
10359
+ ...i["model"] !== void 0 ? ["--model", i["model"]] : [],
10360
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
10361
+ ...i["cap"] !== void 0 ? ["--cap", i["cap"]] : [],
10362
+ ...i["yes"] === true ? ["--yes"] : []
10363
+ ]
10364
+ }
10365
+ ];
10366
+ BOOT_HASH = (() => {
10367
+ try {
10368
+ return sourceHash();
10369
+ } catch {
10370
+ return "unknown";
10371
+ }
10372
+ })();
10373
+ }
10374
+ });
10375
+
10376
+ // packages/cli/src/commands/permissions.ts
10377
+ var permissions_exports = {};
10378
+ __export(permissions_exports, {
10379
+ PERMISSIONS_DESCRIPTION: () => PERMISSIONS_DESCRIPTION,
10380
+ buildPermissions: () => buildPermissions,
10381
+ runPermissions: () => runPermissions
10382
+ });
10383
+ async function buildPermissions(options) {
10384
+ const LEGAL_TOOL_NAME = /^[A-Za-z0-9_-]+$/;
10385
+ const pipeline = new Set(FIGMA_TOOL_FALLBACK);
10386
+ let figmaTools = FIGMA_TOOL_FALLBACK;
10387
+ try {
10388
+ const client = new McpHttpClient({ url: options.mcpUrl ?? DEFAULT_MCP_URL, ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {} });
10389
+ await client.initialize();
10390
+ const live = (await client.listTools()).map((t) => t.name).filter((n) => LEGAL_TOOL_NAME.test(n) && pipeline.has(n));
10391
+ if (live.length > 0) figmaTools = live;
10392
+ } catch {
10393
+ }
10394
+ return {
10395
+ host: "claude",
10396
+ serverEntries: [TENDRIL_PLUGIN_PREFIX, FIGMA_PLUGIN_PREFIX],
10397
+ toolEntries: [
10398
+ ...TOOLS.map((t) => t.name).filter((n) => LEGAL_TOOL_NAME.test(n)).map((n) => `${TENDRIL_PLUGIN_PREFIX}__${n}`),
10399
+ ...figmaTools.map((name) => `${FIGMA_PLUGIN_PREFIX}__${name}`)
10400
+ ],
10401
+ serverEntriesCaution: "The server-wide entries allow EVERY tool those servers serve \u2014 for tendril that includes tendril_generate_curated (spends your OpenRouter budget when configured), and for Figma every tool the desktop server exposes, beyond the read tools this pipeline uses. The per-tool list is the recommended default.",
10402
+ directConfigNote: `Installed via claude mcp add instead of the plugin? Replace the prefixes: ${TENDRIL_PLUGIN_PREFIX}__<tool> becomes mcp__<your-server-name>__<tool> (same for Figma).`
10403
+ };
10404
+ }
10405
+ async function runPermissions(flags) {
10406
+ if (flags.describe) {
10407
+ printDescription(PERMISSIONS_DESCRIPTION);
10408
+ return;
10409
+ }
10410
+ const result = await buildPermissions({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} });
10411
+ emitData(flags, result, () => {
10412
+ const quoted = (xs) => xs.map((x) => ` ${JSON.stringify(x)}`).join(",\n");
10413
+ process.stdout.write(
10414
+ `Claude Code allowlist for the Tendril pipeline.
10415
+ Paste into .claude/settings.json under permissions.allow:
10416
+
10417
+ ${quoted(result.toolEntries)}
10418
+
10419
+ Shorter but broader \u2014 one entry per server:
10420
+
10421
+ ${quoted(result.serverEntries)}
10422
+
10423
+ \u26A0 ${result.serverEntriesCaution}
10424
+
10425
+ ${result.directConfigNote}
10426
+ `
10427
+ );
10428
+ });
10429
+ }
10430
+ var FIGMA_TOOL_FALLBACK, PERMISSIONS_DESCRIPTION, TENDRIL_PLUGIN_PREFIX, FIGMA_PLUGIN_PREFIX;
10431
+ var init_permissions = __esm({
10432
+ "packages/cli/src/commands/permissions.ts"() {
10433
+ "use strict";
10434
+ init_src();
10435
+ init_server();
10436
+ init_describe();
10437
+ init_output();
10438
+ init_doctor();
10439
+ FIGMA_TOOL_FALLBACK = ["get_metadata", "get_design_context", "get_screenshot", "get_variable_defs", "get_motion_context", "get_figjam"];
10440
+ PERMISSIONS_DESCRIPTION = {
10441
+ name: "permissions",
10442
+ summary: "Print a paste-ready Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools).",
10443
+ args: [],
10444
+ flags: [
10445
+ { flag: "--claude", description: "Claude Code settings.json format (the default and currently only format)" },
10446
+ { flag: "--mcp-url <url>", description: "Figma MCP endpoint to list live tool names from", default: DEFAULT_MCP_URL },
10447
+ { flag: "--json", description: "Machine-readable output" }
10448
+ ],
10449
+ output: {
10450
+ host: '"claude"',
10451
+ serverEntries: "string[] \u2014 one entry per MCP server (allows every tool it serves)",
10452
+ toolEntries: "string[] \u2014 per-tool entries for selective allowlists",
10453
+ directConfigNote: "string \u2014 prefix rewrite for non-plugin (claude mcp add) installs"
10454
+ },
10455
+ exitCodes: { 0: "always (informational)" },
10456
+ examples: ["tendril permissions --claude", "tendril permissions --claude --json"]
10457
+ };
10458
+ TENDRIL_PLUGIN_PREFIX = "mcp__plugin_tendril_tendril";
10459
+ FIGMA_PLUGIN_PREFIX = "mcp__plugin_figma_figma";
10460
+ }
10461
+ });
10462
+
10463
+ // packages/cli/src/commands/generate-route.ts
10464
+ var generate_route_exports = {};
10465
+ __export(generate_route_exports, {
9666
10466
  routeGenerate: () => routeGenerate
9667
10467
  });
9668
10468
  function routeGenerate(input) {
@@ -9684,17 +10484,17 @@ __export(generate_recorded_exports, {
9684
10484
  runGenerateRecorded: () => runGenerateRecorded
9685
10485
  });
9686
10486
  import { confirm as confirm3, isCancel as isCancel3 } from "@clack/prompts";
9687
- import { existsSync as existsSync28, readFileSync as readFileSync25 } from "node:fs";
9688
- import path34 from "node:path";
10487
+ import { existsSync as existsSync29, readFileSync as readFileSync26 } from "node:fs";
10488
+ import path35 from "node:path";
9689
10489
  async function runGenerateRecorded(opts) {
9690
10490
  const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
9691
- const outDirAbs = path34.resolve(callerCwd, opts.out);
9692
- const recordedAsPath = path34.resolve(callerCwd, opts.recorded);
10491
+ const outDirAbs = path35.resolve(callerCwd, opts.out);
10492
+ const recordedAsPath = path35.resolve(callerCwd, opts.recorded);
9693
10493
  let task;
9694
10494
  let taskName;
9695
10495
  let authoredApi;
9696
10496
  let composition;
9697
- const isSet = existsSync28(path34.join(recordedAsPath, "recording-set.json"));
10497
+ const isSet = existsSync29(path35.join(recordedAsPath, "recording-set.json"));
9698
10498
  const registry = TASKS[opts.recorded];
9699
10499
  if (registry !== void 0 && !isSet) {
9700
10500
  task = registry;
@@ -9703,7 +10503,7 @@ async function runGenerateRecorded(opts) {
9703
10503
  try {
9704
10504
  const authored = authorTaskFromSet(recordedAsPath);
9705
10505
  task = authored.task;
9706
- taskName = path34.basename(recordedAsPath);
10506
+ taskName = path35.basename(recordedAsPath);
9707
10507
  authoredApi = authored.api;
9708
10508
  const roles = RolesSchema.safeParse(loadManifest(recordedAsPath).roles);
9709
10509
  if (roles.success) composition = roles.data;
@@ -9729,8 +10529,12 @@ async function runGenerateRecorded(opts) {
9729
10529
  remediation: fontsUnprovenRemediation(task.set)
9730
10530
  });
9731
10531
  }
10532
+ const substitutedFamilies = unprovisionedFamilies(task.set);
10533
+ if (substitutedFamilies.length > 0) {
10534
+ warn(opts, `substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 scores measure a substitute face and no config can be certified under one (the stamp covers only the provisioned subset); resolve the families to certify`);
10535
+ }
9732
10536
  const missing = task.configs.filter(
9733
- (c) => !existsSync28(path34.join(task.set, c.rep, "get_screenshot.json")) || !existsSync28(path34.join(task.set, c.rep, "get_metadata.json")) || !existsSync28(path34.join(task.set, c.rep, "get_design_context.json"))
10537
+ (c) => !existsSync29(path35.join(task.set, c.rep, "get_screenshot.json")) || !existsSync29(path35.join(task.set, c.rep, "get_metadata.json")) || !existsSync29(path35.join(task.set, c.rep, "get_design_context.json"))
9734
10538
  );
9735
10539
  if (missing.length > 0) {
9736
10540
  fail(opts, ExitCode.RecordingIncomplete, {
@@ -9800,8 +10604,8 @@ async function runGenerateRecorded(opts) {
9800
10604
  ` : `${line}
9801
10605
  `);
9802
10606
  if (opts.dryRun) {
9803
- emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path34.join(outDirAbs, taskName) }, () => {
9804
- process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path34.join(outDirAbs, taskName)})
10607
+ emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path35.join(outDirAbs, taskName) }, () => {
10608
+ process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path35.join(outDirAbs, taskName)})
9805
10609
  `);
9806
10610
  });
9807
10611
  return;
@@ -9824,10 +10628,10 @@ async function runGenerateRecorded(opts) {
9824
10628
  });
9825
10629
  }
9826
10630
  }
9827
- const bundleDir = path34.join(outDirAbs, taskName);
9828
- if (existsSync28(path34.join(bundleDir, "component.json"))) {
10631
+ const bundleDir = path35.join(outDirAbs, taskName);
10632
+ if (existsSync29(path35.join(bundleDir, "component.json"))) {
9829
10633
  try {
9830
- const prior = readBundleManifest(readFileSync25(path34.join(bundleDir, "component.json"), "utf8")).manifest;
10634
+ const prior = readBundleManifest(readFileSync26(path35.join(bundleDir, "component.json"), "utf8")).manifest;
9831
10635
  if (prior !== void 0 && prior.provenance.recordingSet.hash !== recordingSetHash(task.set, task.configs)) {
9832
10636
  fail(opts, ExitCode.InputValidation, {
9833
10637
  error: `${bundleDir} already holds a bundle bound to recording set "${prior.provenance.recordingSet.path}" \u2014 generating here against a different set would silently rewrite its verification identity`,
@@ -9859,7 +10663,7 @@ async function runGenerateRecorded(opts) {
9859
10663
  });
9860
10664
  const statuses = result.finalScores.map((s) => ({
9861
10665
  ...s,
9862
- status: s.similarity >= BARS4.cert.sim && s.inkRecall >= BARS4.cert.ink ? "certified" : s.pass ? "pass" : "fail"
10666
+ status: s.similarity >= BARS4.cert.sim && s.inkRecall >= BARS4.cert.ink && substitutedFamilies.length === 0 ? "certified" : s.pass ? "pass" : "fail"
9863
10667
  }));
9864
10668
  const behaviorFailures = result.finalBehaviors.filter((b) => !b.pass);
9865
10669
  const pixelFailures = statuses.filter((s) => s.status === "fail");
@@ -9878,7 +10682,8 @@ async function runGenerateRecorded(opts) {
9878
10682
  scores: result.finalScores,
9879
10683
  behaviors: result.finalBehaviors,
9880
10684
  environment: environmentStamp(taskFontFamilies(task.set)),
9881
- spentUsd: result.spentUsd
10685
+ spentUsd: result.spentUsd,
10686
+ substitutedFamilies
9882
10687
  });
9883
10688
  trustStatement = emitted.manifest.trustStatement;
9884
10689
  process.stderr.write(opts.json ? `${JSON.stringify({ status: emitted.statusLine })}
@@ -9925,6 +10730,19 @@ ${certified}/${statuses.length} certified \xB7 ${statuses.length - pixelFailures
9925
10730
  `);
9926
10731
  }
9927
10732
  );
10733
+ if (opts.bar === "cert" && substitutedFamilies.length > 0) {
10734
+ const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
10735
+ if (opts.json) {
10736
+ process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
10737
+ `);
10738
+ } else {
10739
+ process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
10740
+ \u2192 ${fontsUnprovenRemediation(task.set)}
10741
+ `);
10742
+ }
10743
+ process.exitCode = ExitCode.FontsUnproven;
10744
+ return;
10745
+ }
9928
10746
  if (!ok) {
9929
10747
  warn(opts, `${pixelFailures.length} config(s) and ${behaviorFailures.length} behavior(s) below the ${opts.bar} bar \u2014 bundle written, verdict honest (Q4)`);
9930
10748
  process.exitCode = ExitCode.VerificationFailed;
@@ -9958,195 +10776,17 @@ import { CommanderError } from "commander";
9958
10776
  // packages/cli/src/program.ts
9959
10777
  init_src3();
9960
10778
  init_environment();
10779
+ init_doctor();
9961
10780
  import { Command } from "commander";
9962
10781
 
9963
- // packages/cli/src/commands/doctor.ts
9964
- init_src4();
9965
- init_src();
9966
- import { existsSync as existsSync17, readFileSync as readFileSync14, readdirSync as readdirSync3 } from "node:fs";
9967
- import os4 from "node:os";
9968
- import path22 from "node:path";
9969
-
9970
- // packages/cli/src/describe.ts
9971
- var COMMON_EXIT_CODES = {
9972
- 0: "success",
9973
- 1: "general error",
9974
- 2: "authentication error",
9975
- 3: "input validation error",
9976
- 4: "confirmation required (re-run with --yes or answer the prompt)"
9977
- };
9978
- function printDescription(description) {
9979
- process.stdout.write(`${JSON.stringify(description, null, 2)}
9980
- `);
9981
- }
9982
-
9983
- // packages/cli/src/commands/doctor.ts
9984
- init_env();
9985
- init_environment();
9986
- init_output();
9987
- init_entitlement();
9988
- var DEFAULT_MCP_URL = "http://127.0.0.1:3845/mcp";
9989
- var DOCTOR_DESCRIPTION = {
9990
- name: "doctor",
9991
- summary: "Check whether this machine can run tendril generate end to end.",
9992
- args: [],
9993
- flags: [
9994
- { flag: "--mcp-url <url>", description: "Figma MCP endpoint", default: DEFAULT_MCP_URL },
9995
- { flag: "--json", description: "Machine-readable output" }
9996
- ],
9997
- output: {
9998
- ok: "boolean \u2014 Figma desktop MCP reachable AND a scoring browser found (fonts resolve lazily per design system; keys are informational)",
9999
- checks: "[{ name, ok, detail, remediation? }]"
10000
- },
10001
- exitCodes: { 0: "healthy", 1: "one or more required checks failed" },
10002
- examples: ["tendril doctor", "tendril doctor --json"]
10003
- };
10004
- async function runDoctorChecks(options) {
10005
- const checks = [];
10006
- const mcpUrl = options.mcpUrl ?? DEFAULT_MCP_URL;
10007
- const client = new McpHttpClient({
10008
- url: mcpUrl,
10009
- ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
10010
- });
10011
- try {
10012
- const info = await client.initialize();
10013
- const tools = await client.listTools();
10014
- checks.push({
10015
- name: "figma-desktop-mcp",
10016
- ok: true,
10017
- detail: `${info.name} ${info.version} (protocol ${info.protocolVersion}), ${tools.length} tools: ${tools.map((t) => t.name).join(", ")}`
10018
- });
10019
- } catch (err) {
10020
- checks.push({
10021
- name: "figma-desktop-mcp",
10022
- ok: false,
10023
- detail: err instanceof Error ? err.message : String(err),
10024
- remediation: err instanceof McpUnavailableError ? "Open the Figma desktop app, sign in, then Figma menu \u2192 Preferences \u2192 Enable local MCP server (Dev or Full seat on a paid plan)." : "The server answered but the exchange failed \u2014 check the signed-in account's seat, then retry."
10025
- });
10026
- }
10027
- const openrouterKey = resolveCredential("OPENROUTER_API_KEY");
10028
- checks.push(
10029
- openrouterKey ? { name: "openrouter-key", ok: true, detail: "OPENROUTER_API_KEY configured" } : {
10030
- name: "openrouter-key",
10031
- ok: false,
10032
- detail: "OPENROUTER_API_KEY not set \u2014 optional; only the curated API-model engine needs it",
10033
- remediation: "Run `tendril init` to store your OpenRouter key (BYOK) if you plan to use the curated engine."
10034
- }
10035
- );
10036
- try {
10037
- const chrome = resolveChrome();
10038
- checks.push({ name: "browser", ok: true, detail: `${chromeVersion() ?? "version unknown"} at ${chrome}` });
10039
- } catch (err) {
10040
- checks.push({
10041
- name: "browser",
10042
- ok: false,
10043
- detail: err instanceof Error ? err.message : String(err),
10044
- remediation: "Install Google Chrome (or Chromium), or set CHROME_PATH to a Chromium-family browser binary."
10045
- });
10046
- }
10047
- const fontManifest = path22.join(fontCacheDir(), "manifest.json");
10048
- checks.push(
10049
- existsSync17(fontManifest) ? { name: "font-cache", ok: true, detail: `resolved font cache at ${fontCacheDir()} (${JSON.parse(readFileSync14(fontManifest, "utf8")).length} faces)` } : {
10050
- name: "font-cache",
10051
- ok: true,
10052
- detail: `font cache empty at ${fontCacheDir()} \u2014 normal on a fresh machine; faces resolve per design system at first use`,
10053
- remediation: "Nothing to do now: `tendril fonts resolve --set <recording-dir>` fetches exactly what a recording declares, and generate/verify name that command when they need it."
10054
- }
10055
- );
10056
- const pluginRoot = path22.join(os4.homedir(), ".claude", "plugins", "cache", "tendrilapp", "tendril");
10057
- if (existsSync17(pluginRoot)) {
10058
- try {
10059
- const versions = readdirSync3(pluginRoot).filter((v) => /^\d+\.\d+\.\d+$/.test(v));
10060
- const newest = versions.sort((a, b) => versionIsNewer(a, b) ? 1 : -1)[0];
10061
- if (newest !== void 0) {
10062
- const skewed = versionIsNewer(newest, cliVersion());
10063
- checks.push(
10064
- skewed ? {
10065
- name: "plugin-skew",
10066
- ok: false,
10067
- detail: `Claude Code plugin cache holds v${newest} while this CLI is ${cliVersion()} \u2014 the plugin's skill/agents are STALE and will silently shadow current doctrine`,
10068
- remediation: "Update the plugin \u2014 REFRESH THE MARKETPLACE FIRST (its local clone goes stale and a reinstall faithfully reinstalls the old version): terminal Claude Code \u2014 `claude plugin marketplace update tendrilapp` then `claude plugin update tendril`; VS Code extension \u2014 /plugins panel \u2192 Marketplaces tab \u2192 refresh tendrilapp, THEN Plugins tab \u2192 uninstall + reinstall tendril, reopen the chat panel. If the refresh doesn't take, remove the tendrilapp marketplace entirely and re-add TendrilApp/claude-plugin (a fresh clone cannot be stale)."
10069
- } : { name: "plugin-skew", ok: true, detail: `Claude Code plugin v${newest} matches this CLI` }
10070
- );
10071
- }
10072
- } catch {
10073
- }
10074
- }
10075
- const ent = checkEntitlement();
10076
- checks.push(
10077
- ent.ok ? ent.mode === "pre-launch" ? { name: "entitlement", ok: true, detail: "pre-launch build \u2014 entitlement gates present but unarmed (no public key shipped); record/generate run freely" } : { name: "entitlement", ok: true, detail: `active plan "${ent.claims.plan}" until ${new Date(ent.claims.exp).toISOString().slice(0, 10)}${ent.stale ? " (stale \u2014 renews at next opportunity)" : ""}` } : { name: "entitlement", ok: false, detail: ent.error, remediation: ent.remediation }
10078
- );
10079
- const figmaToken = resolveCredential("FIGMA_TOKEN");
10080
- checks.push({
10081
- name: "figma-pat",
10082
- ok: true,
10083
- detail: figmaToken ? "FIGMA_TOKEN configured (REST fallback available)" : "FIGMA_TOKEN not set \u2014 optional; only needed for the REST fallback transport"
10084
- });
10085
- return { ok: checks.filter((c) => c.name !== "figma-pat" && c.name !== "openrouter-key").every((c) => c.ok), checks };
10086
- }
10087
- function versionIsNewer(a, b) {
10088
- const pa = a.split(".").map(Number);
10089
- const pb = b.split(".").map(Number);
10090
- for (let i = 0; i < 3; i++) {
10091
- if ((pb[i] ?? 0) > (pa[i] ?? 0)) return true;
10092
- if ((pb[i] ?? 0) < (pa[i] ?? 0)) return false;
10093
- }
10094
- return false;
10095
- }
10096
- async function latestVersionInfo() {
10097
- try {
10098
- const ctl = new AbortController();
10099
- const timer = setTimeout(() => ctl.abort(), 2500);
10100
- const res = await fetch("https://registry.npmjs.org/@tendrilapp%2fcli", { signal: ctl.signal });
10101
- clearTimeout(timer);
10102
- if (!res.ok) return null;
10103
- const doc = await res.json();
10104
- const latest = doc["dist-tags"]?.latest;
10105
- if (latest === void 0) return null;
10106
- const stamp = doc.time?.[latest];
10107
- return { latest, ...stamp !== void 0 ? { publishedAt: stamp.slice(0, 10) } : {} };
10108
- } catch {
10109
- return null;
10110
- }
10111
- }
10112
- async function runDoctor(flags) {
10113
- if (flags.describe) {
10114
- printDescription(DOCTOR_DESCRIPTION);
10115
- return;
10116
- }
10117
- const [report, latest] = await Promise.all([runDoctorChecks({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} }), latestVersionInfo()]);
10118
- emitData(flags, { version: cliVersion(), ...latest !== null ? { latest: latest.latest, latestPublishedAt: latest.publishedAt ?? null, updateAvailable: versionIsNewer(cliVersion(), latest.latest) } : {}, ...report }, () => {
10119
- const version = cliVersion();
10120
- if (latest === null) {
10121
- process.stdout.write(`tendril ${version} (latest: unreachable)
10122
- `);
10123
- } else if (!versionIsNewer(version, latest.latest)) {
10124
- process.stdout.write(`tendril ${version} (${latest.latest === version ? "latest" : `ahead of registry ${latest.latest}`}${latest.publishedAt !== void 0 ? `, published ${latest.publishedAt}` : ""})
10125
- `);
10126
- } else {
10127
- process.stdout.write(`tendril ${version} \u2014 UPDATE AVAILABLE: ${latest.latest}${latest.publishedAt !== void 0 ? ` (published ${latest.publishedAt})` : ""}
10128
- `);
10129
- process.stdout.write(` \u2192 npm install -g @tendrilapp/cli@latest (plugin MCP users update automatically at next session; a stale npx cache clears with npm cache clean --force)
10130
- `);
10131
- }
10132
- for (const check of report.checks) {
10133
- process.stdout.write(`${check.ok ? "\u2713" : "\u2717"} ${check.name}: ${check.detail}
10134
- `);
10135
- if (check.remediation) process.stdout.write(` \u2192 ${check.remediation}
10136
- `);
10137
- }
10138
- process.stdout.write(report.ok ? "\nready to generate\n" : "\nnot ready \u2014 fix the items above\n");
10139
- });
10140
- if (!report.ok) process.exit(1);
10141
- }
10142
-
10143
10782
  // packages/cli/src/commands/init.ts
10144
10783
  init_src3();
10784
+ init_describe();
10785
+ init_env();
10786
+ init_output();
10145
10787
  import { intro, isCancel, outro, password } from "@clack/prompts";
10146
10788
  import fs from "node:fs";
10147
10789
  import path23 from "node:path";
10148
- init_env();
10149
- init_output();
10150
10790
  var INIT_DESCRIPTION = {
10151
10791
  name: "init",
10152
10792
  summary: "Configure Figma and OpenRouter credentials in .env (idempotent).",
@@ -10254,11 +10894,12 @@ init_src3();
10254
10894
  init_src();
10255
10895
  init_src5();
10256
10896
  init_src2();
10257
- import { confirm as confirm2, isCancel as isCancel2 } from "@clack/prompts";
10258
- import { readFileSync as readFileSync15, readdirSync as readdirSync4, existsSync as existsSync18 } from "node:fs";
10897
+ init_describe();
10259
10898
  init_env();
10260
10899
  init_output();
10261
10900
  init_entitlement();
10901
+ import { confirm as confirm2, isCancel as isCancel2 } from "@clack/prompts";
10902
+ import { readFileSync as readFileSync15, readdirSync as readdirSync4, existsSync as existsSync18 } from "node:fs";
10262
10903
 
10263
10904
  // packages/cli/src/pipeline.ts
10264
10905
  init_src2();
@@ -11005,7 +11646,7 @@ function buildProgram() {
11005
11646
  await runActivate2({ ...flags, serviceUrl: local["serviceUrl"] });
11006
11647
  });
11007
11648
  const record = program.command("record").description("Agent-driven recording protocol: plan the queue, get instructions, ingest verbatim envelopes, resume from disk.");
11008
- record.command("plan").requiredOption("--set <dir>", "recording set directory").requiredOption("--component <name>", "component/system name").requiredOption("--metadata <file...>", "verbatim get_metadata envelope(s), optionally <file>@<frameId>").option("--default <axis=value...>", "explicit axis default, e.g. --default State=Rest (repeatable; persisted to the manifest; may re-plan an unrecorded set)").option("--component-set <name>", "when the metadata holds several component sets, record only this one (name = the set label in the plan error)").option("--sample", "cost sampling: anchor + one-factor + conflict crosses only (the FULL variant matrix is the default; sampling is blind to multi-axis interactions)").action(async (_o, cmd) => {
11649
+ record.command("plan").requiredOption("--set <dir>", "recording set directory").requiredOption("--component <name>", "component/system name").option("--metadata <file...>", "verbatim get_metadata envelope(s), optionally <file>@<frameId>").option("--metadata-raw-file <file>", "the get_metadata response TEXT exactly as received (single block); the CLI builds the envelope itself").option("--metadata-raw-parts-file <file>", "a JSON array of the get_metadata response blocks, each verbatim, in order (multi-block responses)").option("--default <axis=value...>", "explicit axis default, e.g. --default State=Rest (repeatable; persisted to the manifest; may re-plan an unrecorded set)").option("--component-set <name>", "when the metadata holds several component sets, record only this one (name = the set label in the plan error)").option("--sample", "cost sampling: anchor + one-factor + conflict crosses only (the FULL variant matrix is the default; sampling is blind to multi-axis interactions)").action(async (_o, cmd) => {
11009
11650
  const flags = globalFlags(cmd.parent.parent);
11010
11651
  const local = cmd.opts();
11011
11652
  const { runRecordPlan: runRecordPlan2 } = await Promise.resolve().then(() => (init_record(), record_exports));
@@ -11013,8 +11654,10 @@ function buildProgram() {
11013
11654
  ...flags,
11014
11655
  setDir: local["set"],
11015
11656
  component: local["component"],
11016
- metadataFiles: local["metadata"],
11017
11657
  sample: local["sample"],
11658
+ ...local["metadata"] !== void 0 ? { metadataFiles: local["metadata"] } : {},
11659
+ ...local["metadataRawFile"] !== void 0 ? { metadataRawFile: local["metadataRawFile"] } : {},
11660
+ ...local["metadataRawPartsFile"] !== void 0 ? { metadataRawPartsFile: local["metadataRawPartsFile"] } : {},
11018
11661
  ...local["default"] !== void 0 ? { defaultSpecs: local["default"] } : {},
11019
11662
  ...local["componentSet"] !== void 0 ? { componentSet: local["componentSet"] } : {}
11020
11663
  });
@@ -11146,6 +11789,12 @@ function buildProgram() {
11146
11789
  ...local["out"] !== void 0 ? { out: local["out"] } : {}
11147
11790
  });
11148
11791
  });
11792
+ program.command("permissions").description("Print a paste-ready Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools) \u2014 no more transcribing tool names from prompts.").option("--claude", "Claude Code settings.json format (the default and currently only format)").option("--mcp-url <url>", "Figma MCP endpoint to list live tool names from").action(async (_o, cmd) => {
11793
+ const flags = globalFlags(cmd.parent);
11794
+ const local = cmd.opts();
11795
+ const { runPermissions: runPermissions2 } = await Promise.resolve().then(() => (init_permissions(), permissions_exports));
11796
+ await runPermissions2({ ...flags, ...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {} });
11797
+ });
11149
11798
  program.command("verify").description("Recompute verification for a generated bundle against its recording set (per-config status, behaviors, honest exit codes).").argument("<bundleDir>", "bundle directory to verify").option("--task <name>", "legacy task adapter for bundles WITHOUT component.json (bundle v1 carries its own prop manifest)").option("--set <dir>", "recording set directory (overrides the bundle's provenance path)").option("--bar <bar>", "target bar: pass (0.95) or cert (0.97)", "pass").action(async (bundleDir, _opts, cmd) => {
11150
11799
  const flags = globalFlags(cmd);
11151
11800
  const local = cmd.opts();