@tendrilapp/cli 0.1.12 → 0.1.13

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/SKILL.md CHANGED
@@ -51,15 +51,23 @@ Non-negotiables (the CLI enforces these; do not fight them):
51
51
  every open-source face the recording declares, no questions needed.
52
52
  Only faces that FAIL there are a licensing decision for the user:
53
53
  offer `tendril fonts add` (Recommended) or a disclosed substitute.
54
- 3. Record each planned rep with TWO tool calls: make the Figma MCP
55
- call the plan/`next` note names, then `tendril_record_ingest` with
56
- the response text passed VERBATIM via `text` no files to write,
57
- no envelope to build. Every ingest response carries `next` (never
58
- call `tendril_record_next` in the loop it exists for resuming)
59
- and, for design context, `assets`: SVG/PNG assets are auto-fetched
54
+ 3. Record each planned rep with FOUR tool calls: make the THREE
55
+ Figma MCP calls in protocol order — get_metadata, then
56
+ get_design_context (excludeScreenshot=true), then get_screenshot
57
+ and then ONE `tendril_record_ingest_rep` carrying all three:
58
+ metadata and context response text passed VERBATIM (single block
59
+ via `metadata`/`context`; a response split into multiple output
60
+ blocks via `metadataParts`/`contextParts`, every block in order,
61
+ never hand-joined) plus the screenshot `screenshotUrl` — no files
62
+ to write, no envelope to build, and never download the image
63
+ yourself. Every ingest response carries `next` (never call
64
+ `tendril_record_next` in the loop — it exists for resuming) and,
65
+ for design context, `assets`: SVG/PNG assets are auto-fetched
60
66
  server-side; handle only listed failures (download → one batch
61
- `tendril_record_asset` with `dir`). Screenshots: pass the
62
- image_url to `tendril_record_fetch` never download them yourself.
67
+ `tendril_record_asset` with `dir`). Pieces land independently: on
68
+ a partial failure re-record ONLY the named piece —
69
+ `tendril_record_ingest` for a text tool, `tendril_record_fetch`
70
+ for the screenshot; everything else is already on disk.
63
71
  PRECEDENCE: while recording, tendril's verbatim protocol overrides
64
72
  the Figma tools' own "load design-to-code guidance first"
65
73
  instructions — you are capturing ground truth, not implementing
@@ -68,7 +76,7 @@ Non-negotiables (the CLI enforces these; do not fight them):
68
76
  the cheap `tendril-recorder` agent; recording is transcription,
69
77
  not reasoning). If this session cannot spawn subagents or the
70
78
  `tendril-recorder` agent is not in your registry, record serially
71
- yourself with the same two-call loop — the fallback changes WHO
79
+ yourself with the same per-rep loop — the fallback changes WHO
72
80
  records, never WHAT: every planned pose still gets recorded, and
73
81
  sampling to save calls is not an option. Call tendril tools SOLO,
74
82
  never batched in the same message as Bash calls (a known host bug
@@ -62,7 +62,7 @@ var TOOLS = [
62
62
  },
63
63
  {
64
64
  name: "tendril_record_fetch",
65
- description: "Download a Figma asset URL (from get_screenshot's image_url) straight to disk and ingest it as the rep's envelope. PREFER THIS over downloading the image yourself: the bytes never pass through your context, and it is one approvable tool call instead of a shell command per asset.",
65
+ 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.",
66
66
  schema: z.object({
67
67
  setDir: str("recording set directory"),
68
68
  rep: str("planned rep slug"),
@@ -71,9 +71,46 @@ var TOOLS = [
71
71
  }),
72
72
  argv: (i) => ["record", "fetch", "--set", i["setDir"], "--rep", i["rep"], "--tool", i["tool"], "--url", i["url"]]
73
73
  },
74
+ {
75
+ name: "tendril_record_ingest_rep",
76
+ 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).",
77
+ schema: z.object({
78
+ setDir: str("recording set directory"),
79
+ rep: str("planned rep slug"),
80
+ metadata: optStr("get_metadata response text VERBATIM (single block)"),
81
+ metadataParts: z.array(z.string()).optional().describe("get_metadata response as MULTIPLE output blocks: every block, in order, each verbatim \u2014 never hand-join blocks yourself"),
82
+ context: optStr("get_design_context response text VERBATIM (single block)"),
83
+ contextParts: z.array(z.string()).optional().describe("get_design_context response as MULTIPLE output blocks, in order, each verbatim"),
84
+ screenshotUrl: optStr("image_url from the get_screenshot response, verbatim \u2014 the CLI downloads it; the bytes never pass through your context")
85
+ }),
86
+ // Texts ride temp files, never argv: Windows caps a command line at
87
+ // ~32 KB and design-context envelopes routinely exceed it.
88
+ argv: (i) => {
89
+ const argvOut = ["record", "ingest-rep", "--set", i["setDir"], "--rep", i["rep"]];
90
+ const bridge = (label, single, parts) => {
91
+ if (single !== void 0 && parts !== void 0) throw new Error(`pass at most one of \`${label}\` and \`${label}Parts\``);
92
+ if (single === void 0 && parts === void 0) return;
93
+ const tmp = path.join(mkdtempSync(path.join(os.tmpdir(), "tendril-envelope-")), "response.txt");
94
+ if (single !== void 0) {
95
+ writeFileSync(tmp, single);
96
+ argvOut.push(`--${label}-file`, tmp);
97
+ } else {
98
+ const blocks = parts;
99
+ if (blocks.length === 0) throw new Error(`\`${label}Parts\` must be a non-empty array of block texts`);
100
+ writeFileSync(tmp, JSON.stringify(blocks));
101
+ argvOut.push(`--${label}-parts-file`, tmp);
102
+ }
103
+ };
104
+ bridge("metadata", i["metadata"], i["metadataParts"]);
105
+ bridge("context", i["context"], i["contextParts"]);
106
+ if (i["screenshotUrl"] !== void 0) argvOut.push("--screenshot-url", i["screenshotUrl"]);
107
+ if (argvOut.length === 6) throw new Error("nothing to ingest \u2014 pass at least one of metadata/metadataParts, context/contextParts, screenshotUrl");
108
+ return argvOut;
109
+ }
110
+ },
74
111
  {
75
112
  name: "tendril_record_ingest",
76
- description: "Ingest a VERBATIM Figma tool-response for a planned rep. PREFER `text` (single block) or `texts` (response split into multiple output blocks \u2014 pass each block verbatim, in order; NEVER hand-join them): no file to write, no wrapper to build; the CLI constructs the envelope from the same bytes. The response includes `next` (the following instruction \u2014 no separate record_next call) and, for get_design_context, `assets`: the emission's SVG/PNG assets are AUTO-FETCHED server-side; only listed failures need manual record_fetch/record_asset handling.",
113
+ 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).",
77
114
  schema: z.object({
78
115
  setDir: str("recording set directory"),
79
116
  rep: str("planned rep slug, or __set__ for the set-level get_variable_defs"),
package/dist/tendril.js CHANGED
@@ -6276,6 +6276,7 @@ __export(record_exports, {
6276
6276
  runRecordFetch: () => runRecordFetch,
6277
6277
  runRecordFinish: () => runRecordFinish,
6278
6278
  runRecordIngest: () => runRecordIngest,
6279
+ runRecordIngestRep: () => runRecordIngestRep,
6279
6280
  runRecordNext: () => runRecordNext,
6280
6281
  runRecordPlan: () => runRecordPlan,
6281
6282
  runRecordStatus: () => runRecordStatus
@@ -6521,46 +6522,37 @@ async function fetchAssetBytes(startUrl, allowed) {
6521
6522
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
6522
6523
  return Buffer.from(await res.arrayBuffer());
6523
6524
  }
6524
- async function runRecordFetch(opts) {
6525
- if (!isFigmaAssetUrl(opts.url)) {
6526
- fail(opts, ExitCode.InputValidation, {
6527
- error: `refusing to fetch a non-Figma URL: ${opts.url}`,
6528
- code: "asset-url-rejected",
6529
- remediation: "Pass the image_url from the get_screenshot response verbatim."
6530
- });
6525
+ async function ingestScreenshotFromUrl(setDir, rep, url) {
6526
+ if (!isFigmaAssetUrl(url)) {
6527
+ return { ok: false, code: "asset-url-rejected", error: `refusing to fetch a non-Figma URL: ${url}`, remediation: "Pass the image_url from the get_screenshot response verbatim." };
6531
6528
  }
6532
6529
  let bytes;
6533
6530
  try {
6534
- bytes = await fetchAssetBytes(opts.url, isFigmaAssetUrl);
6531
+ bytes = await fetchAssetBytes(url, isFigmaAssetUrl);
6535
6532
  } catch (err) {
6536
- fail(opts, ExitCode.InputValidation, {
6537
- error: `asset fetch failed: ${err instanceof Error ? err.message : String(err)}`,
6538
- code: "asset-fetch-failed",
6539
- remediation: "Figma asset URLs expire after 7 days \u2014 re-run the Figma tool for a fresh URL."
6540
- });
6533
+ return { ok: false, code: "asset-fetch-failed", error: `asset fetch failed: ${err instanceof Error ? err.message : String(err)}`, remediation: "Figma asset URLs expire after 7 days \u2014 re-run the Figma tool for a fresh URL." };
6541
6534
  }
6542
6535
  if (!(bytes.length > 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71)) {
6543
- fail(opts, ExitCode.InputValidation, {
6544
- error: `fetched ${bytes.length} bytes that are not a PNG (expired URL usually returns HTML)`,
6545
- code: "asset-not-png",
6546
- remediation: "Re-run get_screenshot for a fresh URL and fetch again."
6547
- });
6536
+ return { ok: false, code: "asset-not-png", error: `fetched ${bytes.length} bytes that are not a PNG (expired URL usually returns HTML)`, remediation: "Re-run get_screenshot for a fresh URL and fetch again." };
6548
6537
  }
6549
6538
  const payload = { content: [{ type: "image", data: bytes.toString("base64"), mimeType: "image/png" }] };
6550
6539
  try {
6551
- const { overwrote } = ingestEnvelope(opts.setDir, opts.rep, opts.tool, payload);
6552
- emitData(opts, { rep: opts.rep, tool: opts.tool, bytes: bytes.length, overwrote, source: "cli-fetch", next: nextPayload(opts.setDir) }, () => {
6553
- process.stdout.write(`fetched + ingested ${opts.rep}/${opts.tool} (${bytes.length} bytes, no model in the byte path)
6554
- `);
6555
- });
6540
+ const { overwrote } = ingestEnvelope(setDir, rep, "get_screenshot", payload);
6541
+ return { ok: true, bytes: bytes.length, overwrote };
6556
6542
  } catch (err) {
6557
- fail(opts, ExitCode.InputValidation, {
6558
- error: `envelope rejected: ${err instanceof Error ? err.message : String(err)}`,
6559
- code: "envelope-invalid",
6560
- remediation: "Check the rep slug is planned in this set."
6561
- });
6543
+ return { ok: false, code: "envelope-invalid", error: `envelope rejected: ${err instanceof Error ? err.message : String(err)}`, remediation: "Check the rep slug is planned in this set." };
6562
6544
  }
6563
6545
  }
6546
+ async function runRecordFetch(opts) {
6547
+ const result = await ingestScreenshotFromUrl(opts.setDir, opts.rep, opts.url);
6548
+ if (!result.ok) {
6549
+ fail(opts, ExitCode.InputValidation, { error: result.error, code: result.code, remediation: result.remediation });
6550
+ }
6551
+ emitData(opts, { rep: opts.rep, tool: opts.tool, bytes: result.bytes, overwrote: result.overwrote, source: "cli-fetch", next: nextPayload(opts.setDir) }, () => {
6552
+ process.stdout.write(`fetched + ingested ${opts.rep}/${opts.tool} (${result.bytes} bytes, no model in the byte path)
6553
+ `);
6554
+ });
6555
+ }
6564
6556
  async function autoFetchAssets(setDir, rep, envelopeText2) {
6565
6557
  const fetched = [];
6566
6558
  const skipped = [];
@@ -6586,16 +6578,18 @@ async function autoFetchAssets(setDir, rep, envelopeText2) {
6586
6578
  }
6587
6579
  return { fetched, skipped, failed };
6588
6580
  }
6581
+ function rawEnvelopeFromFile(file, parts) {
6582
+ if (parts) {
6583
+ const blocks = JSON.parse(readFileSync15(path24.resolve(file), "utf8"));
6584
+ if (!Array.isArray(blocks) || blocks.length === 0 || blocks.some((p) => typeof p !== "string")) throw new Error("parts file must be a non-empty JSON array of strings");
6585
+ return { content: blocks.map((text) => ({ type: "text", text })) };
6586
+ }
6587
+ return { content: [{ type: "text", text: readFileSync15(path24.resolve(file), "utf8") }] };
6588
+ }
6589
6589
  async function runRecordIngest(opts) {
6590
6590
  let payload;
6591
6591
  try {
6592
- if (opts.rawParts === true) {
6593
- const parts = JSON.parse(readFileSync15(path24.resolve(opts.file), "utf8"));
6594
- if (!Array.isArray(parts) || parts.length === 0 || parts.some((p) => typeof p !== "string")) throw new Error("--raw-parts file must be a non-empty JSON array of strings");
6595
- payload = { content: parts.map((text) => ({ type: "text", text })) };
6596
- } else {
6597
- payload = opts.raw === true ? { content: [{ type: "text", text: readFileSync15(path24.resolve(opts.file), "utf8") }] } : JSON.parse(readFileSync15(path24.resolve(opts.file), "utf8"));
6598
- }
6592
+ payload = opts.rawParts === true || opts.raw === true ? rawEnvelopeFromFile(opts.file, opts.rawParts === true) : JSON.parse(readFileSync15(path24.resolve(opts.file), "utf8"));
6599
6593
  } catch (err) {
6600
6594
  fail(opts, ExitCode.InputValidation, {
6601
6595
  error: `cannot read envelope file: ${err instanceof Error ? err.message : String(err)}`,
@@ -6647,6 +6641,72 @@ async function runRecordIngest(opts) {
6647
6641
  });
6648
6642
  }
6649
6643
  }
6644
+ async function runRecordIngestRep(opts) {
6645
+ const validate = (label, single, parts) => {
6646
+ if (single !== void 0 && parts !== void 0) {
6647
+ fail(opts, ExitCode.InputValidation, {
6648
+ error: `pass at most one of --${label}-file and --${label}-parts-file`,
6649
+ code: "ingest-rep-args",
6650
+ remediation: `Single-block responses go in --${label}-file; multi-block responses as a JSON string array in --${label}-parts-file.`
6651
+ });
6652
+ }
6653
+ };
6654
+ validate("metadata", opts.metadataFile, opts.metadataPartsFile);
6655
+ validate("context", opts.contextFile, opts.contextPartsFile);
6656
+ const metaFile = opts.metadataPartsFile ?? opts.metadataFile;
6657
+ const ctxFile = opts.contextPartsFile ?? opts.contextFile;
6658
+ if (metaFile === void 0 && ctxFile === void 0 && opts.screenshotUrl === void 0) {
6659
+ fail(opts, ExitCode.InputValidation, {
6660
+ error: "nothing to ingest \u2014 pass at least one piece",
6661
+ code: "ingest-rep-args",
6662
+ remediation: "Provide --metadata-file/--metadata-parts-file, --context-file/--context-parts-file, and/or --screenshot-url."
6663
+ });
6664
+ }
6665
+ const pieces = {};
6666
+ const failed = [];
6667
+ if (metaFile !== void 0) {
6668
+ try {
6669
+ const { overwrote } = ingestEnvelope(opts.setDir, opts.rep, "get_metadata", rawEnvelopeFromFile(metaFile, opts.metadataPartsFile !== void 0));
6670
+ pieces["metadata"] = { overwrote };
6671
+ } catch (err) {
6672
+ failed.push({ piece: "get_metadata", error: err instanceof Error ? err.message : String(err), remediation: "Re-record the get_metadata response verbatim and re-ingest just this piece (tendril_record_ingest)." });
6673
+ }
6674
+ }
6675
+ if (ctxFile !== void 0) {
6676
+ try {
6677
+ const envelope = rawEnvelopeFromFile(ctxFile, opts.contextPartsFile !== void 0);
6678
+ const { overwrote } = ingestEnvelope(opts.setDir, opts.rep, "get_design_context", envelope);
6679
+ const assets = await autoFetchAssets(opts.setDir, opts.rep, envelope.content.map((c) => c.text ?? "").join("\n"));
6680
+ pieces["context"] = { overwrote, assets };
6681
+ } catch (err) {
6682
+ failed.push({ piece: "get_design_context", error: err instanceof Error ? err.message : String(err), remediation: "Re-record the get_design_context response verbatim and re-ingest just this piece (tendril_record_ingest)." });
6683
+ }
6684
+ }
6685
+ if (opts.screenshotUrl !== void 0) {
6686
+ const shot = await ingestScreenshotFromUrl(opts.setDir, opts.rep, opts.screenshotUrl);
6687
+ if (shot.ok) pieces["screenshot"] = { bytes: shot.bytes, overwrote: shot.overwrote };
6688
+ else failed.push({ piece: "get_screenshot", error: shot.error, remediation: `${shot.remediation} Then re-ingest just this piece with tendril_record_fetch (\`record fetch\`).` });
6689
+ }
6690
+ if (failed.length > 0) {
6691
+ const landed = Object.keys(pieces);
6692
+ fail(opts, ExitCode.InputValidation, {
6693
+ error: `ingest-rep ${opts.rep}: ${failed.map((f) => `${f.piece} FAILED (${f.error})`).join("; ")}${landed.length > 0 ? ` \u2014 recorded ok: ${landed.join(", ")}` : ""}`,
6694
+ code: "ingest-rep-partial",
6695
+ remediation: `Only the failed piece(s) need re-recording. ${failed.map((f) => `${f.piece}: ${f.remediation}`).join(" ")}`
6696
+ });
6697
+ }
6698
+ emitData(opts, { rep: opts.rep, ...pieces, next: nextPayload(opts.setDir) }, () => {
6699
+ process.stdout.write(`ingested ${opts.rep}: ${Object.keys(pieces).join(" + ")}
6700
+ `);
6701
+ const assets = pieces["context"]?.assets;
6702
+ if (assets !== void 0) {
6703
+ for (const a of assets.fetched) process.stdout.write(` asset fetched ${a}
6704
+ `);
6705
+ for (const f of assets.failed) process.stdout.write(` ASSET FAILED ${f.name}: ${f.reason} \u2014 download it and run tendril record asset
6706
+ `);
6707
+ }
6708
+ });
6709
+ }
6650
6710
  function runRecordAsset(opts) {
6651
6711
  if (opts.dir !== void 0) {
6652
6712
  const dir = path24.resolve(opts.dir);
@@ -10402,6 +10462,21 @@ function buildProgram() {
10402
10462
  const { runRecordIngest: runRecordIngest2 } = await Promise.resolve().then(() => (init_record(), record_exports));
10403
10463
  await runRecordIngest2({ ...flags, setDir: local["set"], rep: local["rep"], tool: local["tool"], file: local["file"], raw: local["raw"], rawParts: local["rawParts"] });
10404
10464
  });
10465
+ record.command("ingest-rep").description("Batched per-rep ingest: metadata + design context + screenshot URL in one call; pieces land independently.").requiredOption("--set <dir>", "recording set directory").requiredOption("--rep <slug>", "planned rep slug").option("--metadata-file <file>", "get_metadata response TEXT verbatim").option("--metadata-parts-file <file>", "get_metadata response as a JSON array of block texts (multi-block transport)").option("--context-file <file>", "get_design_context response TEXT verbatim").option("--context-parts-file <file>", "get_design_context response as a JSON array of block texts").option("--screenshot-url <url>", "image_url from the get_screenshot response, verbatim").action(async (_o, cmd) => {
10466
+ const flags = globalFlags(cmd.parent.parent);
10467
+ const local = cmd.opts();
10468
+ const { runRecordIngestRep: runRecordIngestRep2 } = await Promise.resolve().then(() => (init_record(), record_exports));
10469
+ await runRecordIngestRep2({
10470
+ ...flags,
10471
+ setDir: local["set"],
10472
+ rep: local["rep"],
10473
+ metadataFile: local["metadataFile"],
10474
+ metadataPartsFile: local["metadataPartsFile"],
10475
+ contextFile: local["contextFile"],
10476
+ contextPartsFile: local["contextPartsFile"],
10477
+ screenshotUrl: local["screenshotUrl"]
10478
+ });
10479
+ });
10405
10480
  record.command("fetch").description("Download a Figma asset URL straight to disk and ingest it \u2014 no shell, no model in the byte path.").requiredOption("--set <dir>", "recording set directory").requiredOption("--rep <slug>", "planned rep slug").requiredOption("--tool <tool>", "get_screenshot").requiredOption("--url <url>", "image_url from the Figma tool response, verbatim").action(async (_o, cmd) => {
10406
10481
  const flags = globalFlags(cmd.parent.parent);
10407
10482
  const local = cmd.opts();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tendrilapp/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Figma design systems → verified React components. CLI ruler + MCP server.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",