@tendrilapp/cli 0.1.8 → 0.1.10

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.
@@ -46,6 +46,13 @@ var TOOLS = [
46
46
  ...i["componentSet"] !== void 0 ? ["--component-set", i["componentSet"]] : []
47
47
  ]
48
48
  },
49
+ {
50
+ name: "tendril_doctor",
51
+ annotations: { readOnlyHint: true },
52
+ 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.",
53
+ schema: z.object({}),
54
+ argv: () => ["doctor"]
55
+ },
49
56
  {
50
57
  name: "tendril_record_next",
51
58
  annotations: { readOnlyHint: true },
package/dist/tendril.js CHANGED
@@ -7251,6 +7251,14 @@ function authorComponentApi(opts) {
7251
7251
  props.push({ name: propNameFor(key), kind: "union", values: ordered.map(kebab3), default: kebab3(def) });
7252
7252
  }
7253
7253
  }
7254
+ const slots = (opts.textSlots ?? []).map((slot) => {
7255
+ let name = slot.prop;
7256
+ if (name !== "children" && (props.some((pr) => pr.name === name) || RESERVED_PROPS.has(name.toLowerCase()))) name = `${name}Text`;
7257
+ let n = 2;
7258
+ while (props.some((pr) => pr.name === name)) name = `${slot.prop}Text${n++}`;
7259
+ return { ...slot, prop: name };
7260
+ });
7261
+ for (const slot of slots) props.push({ name: slot.prop, kind: "string", default: slot.default });
7254
7262
  const configs = [];
7255
7263
  const seenAssignments = /* @__PURE__ */ new Map();
7256
7264
  const componentIdent = pascal(opts.component);
@@ -7272,6 +7280,10 @@ function authorComponentApi(opts) {
7272
7280
  }
7273
7281
  }
7274
7282
  if (tokens.length > 0) assignment["data-tendril-state"] = tokens.join(" ");
7283
+ for (const slot of slots) {
7284
+ const v = slot.overrides[p.slug];
7285
+ if (v !== void 0) assignment[slot.prop] = v;
7286
+ }
7275
7287
  const canonical = JSON.stringify(Object.fromEntries(Object.entries(assignment).sort(([a], [b]) => a.localeCompare(b))));
7276
7288
  const clash = seenAssignments.get(canonical);
7277
7289
  if (clash !== void 0) {
@@ -7290,7 +7302,7 @@ function authorComponentApi(opts) {
7290
7302
  name: componentIdent,
7291
7303
  props: props.map((pr) => ({
7292
7304
  name: pr.name,
7293
- type: pr.kind === "boolean" ? "boolean" : pr.values.map((v) => `"${v}"`).join(" | "),
7305
+ type: pr.kind === "boolean" ? "boolean" : pr.kind === "string" ? "string" : pr.values.map((v) => `"${v}"`).join(" | "),
7294
7306
  required: false,
7295
7307
  ...pr.default !== void 0 ? { default: pr.default } : {}
7296
7308
  })),
@@ -7298,7 +7310,7 @@ function authorComponentApi(opts) {
7298
7310
  poseCompleteness: { recordedPoses: opts.poses.length, expressible: configs.length }
7299
7311
  };
7300
7312
  const propLines = props.map(
7301
- (pr) => pr.kind === "boolean" ? ` ${pr.name}?: boolean;` : ` ${pr.name}?: ${pr.values.map((v) => `"${v}"`).join(" | ")}; // default "${pr.default}"`
7313
+ (pr) => pr.kind === "boolean" ? ` ${pr.name}?: boolean;` : pr.kind === "string" ? ` ${pr.name}?: string; // CONTENT \u2014 recorded default ${JSON.stringify(pr.default)}` : ` ${pr.name}?: ${pr.values.map((v) => `"${v}"`).join(" | ")}; // default "${pr.default}"`
7302
7314
  );
7303
7315
  const provided = opts.fonts ?? [];
7304
7316
  const recorded = opts.recordedFonts ?? [];
@@ -7311,7 +7323,8 @@ ${propLines.join("\n")}
7311
7323
  [key: string]: unknown; // MUST spread unknown props (incl. data-*) onto the root element
7312
7324
  })
7313
7325
 
7314
- Rules: plain CSS in styles.css (tokens.css optional, loaded first) \u2014 no Tailwind, no imports beyond react/react-dom, and NEVER import the stylesheet from the entry module: the harness injects tokens.css and styles.css itself, and an entry that imports CSS does not compile here (measured cost: one full round, every config 0). The component renders the RECORDED content as its defaults \u2014 reproduce it from the emissions. ${forcingCanon}${fontsLine}The host sizes nothing: the component is its natural recorded size.`;
7326
+ ${slots.length > 0 ? `CONTENT PROPS: every string prop defaults to its RECORDED text \u2014 render the PROP, never a hardcoded literal. Configs pass per-pose recorded strings wherever the recording varies, and pixels enforce them: a hardcoded string fails those configs. Content presence (a heading that only exists in some poses) follows the AXIS props; the string prop only supplies the text.
7327
+ ` : ""}Rules: plain CSS in styles.css (tokens.css optional, loaded first) \u2014 no Tailwind, no imports beyond react/react-dom, and NEVER import the stylesheet from the entry module: the harness injects tokens.css and styles.css itself, and an entry that imports CSS does not compile here (measured cost: one full round, every config 0). The component renders the RECORDED content as its defaults \u2014 reproduce it from the emissions. ${forcingCanon}${fontsLine}The host sizes nothing: the component is its natural recorded size.`;
7315
7328
  const mappedTokens = /* @__PURE__ */ new Set([...forcedStates, ...props.filter((p) => p.kind === "boolean").map((p) => p.name)]);
7316
7329
  const unmappedInteractionEvidence = interactionEvidence.filter((e) => {
7317
7330
  const sel = e.endsWith(" (selection axis)");
@@ -7421,6 +7434,67 @@ function recordedFontNeeds(setDir) {
7421
7434
  function recordedFontFamilies(setDir) {
7422
7435
  return recordedFontNeeds(setDir).map((n) => n.family);
7423
7436
  }
7437
+ function recordedTextSlots(setDir, repSlugs) {
7438
+ const perRep = [];
7439
+ for (const slug of repSlugs) {
7440
+ const f = path26.join(setDir, slug, "get_design_context.json");
7441
+ if (!existsSync20(f)) continue;
7442
+ const code = envelopeText(f);
7443
+ const texts = [];
7444
+ for (const m of code.matchAll(/>([^<>{}]+)</g)) {
7445
+ const t = decodeXmlEntities(m[1]).trim();
7446
+ if (t === "" || !/[A-Za-z0-9]/.test(t)) continue;
7447
+ texts.push(t);
7448
+ }
7449
+ perRep.push({ slug, texts });
7450
+ }
7451
+ if (perRep.length === 0) return [];
7452
+ const slots = [];
7453
+ for (const rep of perRep) {
7454
+ const used = /* @__PURE__ */ new Set();
7455
+ const unmatched = [];
7456
+ for (const t of rep.texts) {
7457
+ const i = slots.findIndex((sl, idx) => !used.has(idx) && [...sl.values.values()].includes(t));
7458
+ if (i >= 0) {
7459
+ used.add(i);
7460
+ slots[i].values.set(rep.slug, t);
7461
+ } else {
7462
+ unmatched.push(t);
7463
+ }
7464
+ }
7465
+ for (const t of unmatched) {
7466
+ const i = slots.findIndex((_, idx) => !used.has(idx) && !slots[idx].values.has(rep.slug));
7467
+ if (i >= 0) {
7468
+ used.add(i);
7469
+ slots[i].values.set(rep.slug, t);
7470
+ } else {
7471
+ const sl = { values: /* @__PURE__ */ new Map() };
7472
+ sl.values.set(rep.slug, t);
7473
+ slots.push(sl);
7474
+ used.add(slots.length - 1);
7475
+ }
7476
+ }
7477
+ }
7478
+ const usedNames = /* @__PURE__ */ new Set();
7479
+ return slots.map((sl, idx) => {
7480
+ const counts = /* @__PURE__ */ new Map();
7481
+ for (const v of sl.values.values()) counts.set(v, (counts.get(v) ?? 0) + 1);
7482
+ const def = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
7483
+ let prop;
7484
+ if (slots.length === 1) {
7485
+ prop = "children";
7486
+ } else {
7487
+ const word = /[A-Za-z][A-Za-z0-9]*/.exec(def)?.[0]?.toLowerCase();
7488
+ prop = word !== void 0 && word.length >= 2 ? word : `text${idx + 1}`;
7489
+ let n = 2;
7490
+ while (usedNames.has(prop)) prop = `${prop}${n++}`;
7491
+ }
7492
+ usedNames.add(prop);
7493
+ const overrides = {};
7494
+ for (const [slug, v] of sl.values) if (v !== def) overrides[slug] = v;
7495
+ return { prop, default: def, overrides, varies: Object.keys(overrides).length > 0 };
7496
+ });
7497
+ }
7424
7498
  function authorTaskFromSet(setDir, opts = {}) {
7425
7499
  if (opts.fonts === void 0) {
7426
7500
  const resolved = resolvedFontFamilies().map((f) => f.family);
@@ -7449,15 +7523,24 @@ function authorTaskFromSet(setDir, opts = {}) {
7449
7523
  const latticeNames = manifest.latticeNames ?? (existsSync20(setMeta) ? [...envelopeText(setMeta).matchAll(/name="([^"]*)"/g)].map((m) => decodeXmlEntities(m[1])) : void 0);
7450
7524
  const defaults = { ...manifest.defaults ?? {}, ...opts.defaults ?? {} };
7451
7525
  const recordedFonts = recordedFontFamilies(setDir);
7526
+ const textSlots = recordedTextSlots(setDir, manifest.reps.map((r) => r.slug));
7452
7527
  const api = authorComponentApi({
7453
7528
  component: manifest.component,
7454
7529
  poses,
7455
7530
  ...latticeNames !== void 0 ? { latticeNames } : {},
7456
7531
  ...opts.fonts !== void 0 ? { fonts: opts.fonts } : {},
7457
7532
  ...recordedFonts.length > 0 ? { recordedFonts } : {},
7458
- ...Object.keys(defaults).length > 0 ? { defaults } : {}
7533
+ ...Object.keys(defaults).length > 0 ? { defaults } : {},
7534
+ ...textSlots.length > 0 ? { textSlots } : {}
7459
7535
  });
7460
7536
  const { behaviors, prelude, disclosures } = authorBehaviors(api);
7537
+ for (const slot of textSlots) {
7538
+ if (!slot.varies) {
7539
+ disclosures.push(
7540
+ `content prop from recorded text is UNEXERCISED: the recording shows one constant string (${JSON.stringify(slot.default)}) for it, so no config pixel-verifies that the prop reaches the render \u2014 wire it anyway; a sentinel check is future work`
7541
+ );
7542
+ }
7543
+ }
7461
7544
  if (manifest.notRecorded !== void 0) disclosures.push(`not recorded (from the completeness manifest): ${manifest.notRecorded}`);
7462
7545
  const task = {
7463
7546
  set: setDir,
@@ -9322,15 +9405,51 @@ async function runDoctorChecks(options) {
9322
9405
  });
9323
9406
  return { ok: checks.filter((c) => c.name !== "figma-pat" && c.name !== "openrouter-key").every((c) => c.ok), checks };
9324
9407
  }
9408
+ function versionIsNewer(a, b) {
9409
+ const pa = a.split(".").map(Number);
9410
+ const pb = b.split(".").map(Number);
9411
+ for (let i = 0; i < 3; i++) {
9412
+ if ((pb[i] ?? 0) > (pa[i] ?? 0)) return true;
9413
+ if ((pb[i] ?? 0) < (pa[i] ?? 0)) return false;
9414
+ }
9415
+ return false;
9416
+ }
9417
+ async function latestVersionInfo() {
9418
+ try {
9419
+ const ctl = new AbortController();
9420
+ const timer = setTimeout(() => ctl.abort(), 2500);
9421
+ const res = await fetch("https://registry.npmjs.org/@tendrilapp%2fcli", { signal: ctl.signal });
9422
+ clearTimeout(timer);
9423
+ if (!res.ok) return null;
9424
+ const doc = await res.json();
9425
+ const latest = doc["dist-tags"]?.latest;
9426
+ if (latest === void 0) return null;
9427
+ const stamp = doc.time?.[latest];
9428
+ return { latest, ...stamp !== void 0 ? { publishedAt: stamp.slice(0, 10) } : {} };
9429
+ } catch {
9430
+ return null;
9431
+ }
9432
+ }
9325
9433
  async function runDoctor(flags) {
9326
9434
  if (flags.describe) {
9327
9435
  printDescription(DOCTOR_DESCRIPTION);
9328
9436
  return;
9329
9437
  }
9330
- const report = await runDoctorChecks({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} });
9331
- emitData(flags, { version: cliVersion(), ...report }, () => {
9332
- process.stdout.write(`tendril ${cliVersion()}
9438
+ const [report, latest] = await Promise.all([runDoctorChecks({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} }), latestVersionInfo()]);
9439
+ emitData(flags, { version: cliVersion(), ...latest !== null ? { latest: latest.latest, latestPublishedAt: latest.publishedAt ?? null, updateAvailable: versionIsNewer(cliVersion(), latest.latest) } : {}, ...report }, () => {
9440
+ const version = cliVersion();
9441
+ if (latest === null) {
9442
+ process.stdout.write(`tendril ${version} (latest: unreachable)
9333
9443
  `);
9444
+ } else if (!versionIsNewer(version, latest.latest)) {
9445
+ process.stdout.write(`tendril ${version} (${latest.latest === version ? "latest" : `ahead of registry ${latest.latest}`}${latest.publishedAt !== void 0 ? `, published ${latest.publishedAt}` : ""})
9446
+ `);
9447
+ } else {
9448
+ process.stdout.write(`tendril ${version} \u2014 UPDATE AVAILABLE: ${latest.latest}${latest.publishedAt !== void 0 ? ` (published ${latest.publishedAt})` : ""}
9449
+ `);
9450
+ 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)
9451
+ `);
9452
+ }
9334
9453
  for (const check of report.checks) {
9335
9454
  process.stdout.write(`${check.ok ? "\u2713" : "\u2717"} ${check.name}: ${check.detail}
9336
9455
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",