@tendrilapp/cli 0.1.3

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/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ Tendril — End User License
2
+
3
+ Copyright (c) 2026 Jordy Arnoldussen. All rights reserved.
4
+
5
+ Subject to these terms, you are granted a non-exclusive,
6
+ non-transferable license to install and run this software, in compiled
7
+ form, for the purpose of using the Tendril product.
8
+
9
+ You may not: reverse engineer, decompile, or disassemble the software
10
+ except where permitted by applicable law; redistribute, sublicense,
11
+ sell, or host the software for third parties; or remove or alter any
12
+ notices in it.
13
+
14
+ Verification note: the `tendril verify` command is and remains free to
15
+ run — it requires no account and no network connection, and nothing in
16
+ these terms may be read to gate it.
17
+
18
+ The software is provided "as is", without warranty of any kind. To the
19
+ maximum extent permitted by law, the author is not liable for any
20
+ damages arising from its use.
21
+
22
+ Third-party dependencies installed alongside this package retain their
23
+ own licenses.
package/README.md ADDED
@@ -0,0 +1,12 @@
1
+ # @tendrilapp/cli
2
+
3
+ Figma design systems → verified React components.
4
+
5
+ - `tendril` — the CLI: record, generate, verify. `tendril doctor` first.
6
+ - `tendril-mcp` — the MCP server (stdio). Register it with your agent host:
7
+
8
+ ```json
9
+ { "mcpServers": { "tendril": { "command": "tendril-mcp" } } }
10
+ ```
11
+
12
+ Verification is local, account-less, and network-less — always.
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+
3
+ // packages/mcp/src/bin.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+
7
+ // packages/mcp/src/server.ts
8
+ import { execFile } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { z } from "zod";
14
+ var REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
15
+ var CLI_BIN = path.join(REPO_ROOT, "packages", "cli", "src", "bin.ts");
16
+ var BUNDLED_CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), "tendril.js");
17
+ var CLI_SPAWN = existsSync(BUNDLED_CLI) ? { cmd: process.execPath, prefix: [BUNDLED_CLI] } : { cmd: "npx", prefix: ["tsx", CLI_BIN] };
18
+ var str = (d) => z.string().describe(d);
19
+ var optStr = (d) => z.string().optional().describe(d);
20
+ var TOOLS = [
21
+ {
22
+ name: "tendril_record_plan",
23
+ description: "Plan a recording session: computes the rep queue (anchor + one-factor + conflict crosses) from verbatim get_metadata envelope file(s) 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.",
24
+ schema: z.object({
25
+ setDir: str("recording set directory to create/resume"),
26
+ component: str("component/system name"),
27
+ metadataFiles: z.array(z.string()).describe('verbatim get_metadata envelope file paths \u2014 the tool response saved AS-IS, JSON shape {"content":[{"type":"text","text":"<frame \u2026>"}]}; raw copied text is rejected. Optionally <file>@<frameId>'),
28
+ defaults: z.array(z.string()).optional().describe(`axis defaults as "Axis=Value" (from the user's defaultsToConfirm answers; may re-plan a set with nothing recorded yet)`),
29
+ componentSet: optStr("record only this component set (the user's pick from a multiple-component-sets error)")
30
+ }),
31
+ argv: (i) => [
32
+ "record",
33
+ "plan",
34
+ "--set",
35
+ i["setDir"],
36
+ "--component",
37
+ i["component"],
38
+ "--metadata",
39
+ ...i["metadataFiles"],
40
+ ...i["defaults"] !== void 0 ? ["--default", ...i["defaults"]] : [],
41
+ ...i["componentSet"] !== void 0 ? ["--component-set", i["componentSet"]] : []
42
+ ]
43
+ },
44
+ {
45
+ name: "tendril_record_next",
46
+ annotations: { readOnlyHint: true },
47
+ description: "Get the next pending recording instruction (which Figma MCP tool to call for which node, and how to save it). Null instruction means the set is complete.",
48
+ schema: z.object({ setDir: str("recording set directory") }),
49
+ argv: (i) => ["record", "next", "--set", i["setDir"]]
50
+ },
51
+ {
52
+ name: "tendril_record_fetch",
53
+ 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.",
54
+ schema: z.object({
55
+ setDir: str("recording set directory"),
56
+ rep: str("planned rep slug"),
57
+ tool: z.enum(["get_screenshot"]).describe("get_screenshot"),
58
+ url: str("image_url from the Figma response, verbatim")
59
+ }),
60
+ argv: (i) => ["record", "fetch", "--set", i["setDir"], "--rep", i["rep"], "--tool", i["tool"], "--url", i["url"]]
61
+ },
62
+ {
63
+ name: "tendril_record_ingest",
64
+ description: "Ingest a VERBATIM Figma tool-response envelope for a planned rep. Save the raw response to a file first; the CLI validates at the boundary and never rewrites bytes.",
65
+ schema: z.object({
66
+ setDir: str("recording set directory"),
67
+ rep: str("planned rep slug, or __set__ for the set-level get_variable_defs"),
68
+ // Enumerated, not a free string: this value reaches a file path.
69
+ // As a bare string it was an arbitrary relative-path overwrite
70
+ // (--tool "../../outside/victim" replaced a file outside the set,
71
+ // exit 0). The sink in session.ts now contains the path too — this
72
+ // is the second layer, and it makes the tool self-documenting.
73
+ tool: z.enum(["get_design_context", "get_metadata", "get_screenshot", "get_variable_defs", "get_metadata_interior"]),
74
+ file: str("path to the verbatim envelope JSON")
75
+ }),
76
+ argv: (i) => ["record", "ingest", "--set", i["setDir"], "--rep", i["rep"], "--tool", i["tool"], "--file", i["file"]]
77
+ },
78
+ {
79
+ name: "tendril_record_asset",
80
+ description: "Ingest a downloaded asset file (asset-<id>.<ext>) for a rep. SVGs with active content are rejected; sizes are capped.",
81
+ schema: z.object({
82
+ setDir: str("recording set directory"),
83
+ rep: str("planned rep slug"),
84
+ name: str("asset-<id>.<ext>"),
85
+ file: str("downloaded asset file path")
86
+ }),
87
+ argv: (i) => ["record", "asset", "--set", i["setDir"], "--rep", i["rep"], "--name", i["name"], "--file", i["file"]]
88
+ },
89
+ {
90
+ name: "tendril_record_status",
91
+ annotations: { readOnlyHint: true },
92
+ description: "Recording-set completeness: per-rep recorded/missing tools.",
93
+ schema: z.object({ setDir: str("recording set directory") }),
94
+ argv: (i) => ["record", "status", "--set", i["setDir"]]
95
+ },
96
+ {
97
+ name: "tendril_engine_brief",
98
+ 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.",
99
+ schema: z.object({
100
+ 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"),
101
+ // Required by design, not convenience: the model choice must be
102
+ // settled BEFORE generation starts. A smoke run picked its own
103
+ // model silently because the ask lived in instruction text, which
104
+ // evaporates in non-interactive sessions; a required parameter
105
+ // cannot evaporate. Ask the user when one is present; otherwise
106
+ // choose, declare, and state the reason in your report.
107
+ model: str("the model that will WRITE the implementation \u2014 ask the user when interactive; declare your reasoned choice when not"),
108
+ bar: optStr("pass (default) or cert"),
109
+ out: optStr("payload file path override")
110
+ }),
111
+ argv: (i) => [
112
+ "engine",
113
+ "brief",
114
+ i["taskOrSet"],
115
+ "--model",
116
+ i["model"],
117
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
118
+ ...i["out"] !== void 0 ? ["--out", i["out"]] : []
119
+ ]
120
+ },
121
+ {
122
+ name: "tendril_engine_score",
123
+ 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.",
124
+ schema: z.object({
125
+ taskOrSet: str("reference task name or recording-set directory"),
126
+ candidateDir: str("directory containing the proposed bundle files"),
127
+ bar: optStr("pass (default) or cert"),
128
+ host: optStr("your host identity (e.g. claude-code, cursor, codex) \u2014 recorded as self-reported provenance"),
129
+ model: str("the model that proposed the candidate \u2014 REQUIRED; recorded as self-reported provenance, and the CLI refuses to score without it")
130
+ }),
131
+ argv: (i) => [
132
+ "engine",
133
+ "score",
134
+ i["taskOrSet"],
135
+ i["candidateDir"],
136
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
137
+ ...i["host"] !== void 0 ? ["--host", i["host"]] : [],
138
+ "--model",
139
+ i["model"]
140
+ ]
141
+ },
142
+ {
143
+ name: "tendril_verify",
144
+ description: "Recompute full verification for a generated bundle (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.",
145
+ schema: z.object({
146
+ bundleDir: str("bundle directory to verify"),
147
+ bar: optStr("pass (default) or cert"),
148
+ set: optStr("recording-set directory override")
149
+ }),
150
+ argv: (i) => [
151
+ "verify",
152
+ i["bundleDir"],
153
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
154
+ ...i["set"] !== void 0 ? ["--set", i["set"]] : []
155
+ ]
156
+ },
157
+ {
158
+ name: "tendril_generate_curated",
159
+ 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.",
160
+ schema: z.object({
161
+ input: str("reference task name or recording-set directory"),
162
+ model: optStr("OpenRouter model id (default: allowlist pointer)"),
163
+ bar: optStr("pass (default) or cert"),
164
+ cap: optStr("spend cap in USD (default 1.50)"),
165
+ yes: z.boolean().optional().describe("accept the cost consent (the user must have approved the spend)")
166
+ }),
167
+ argv: (i) => [
168
+ "generate",
169
+ i["input"],
170
+ ...i["model"] !== void 0 ? ["--model", i["model"]] : [],
171
+ ...i["bar"] !== void 0 ? ["--bar", i["bar"]] : [],
172
+ ...i["cap"] !== void 0 ? ["--cap", i["cap"]] : [],
173
+ ...i["yes"] === true ? ["--yes"] : []
174
+ ]
175
+ }
176
+ ];
177
+ var PROGRESS_PREFIX = "@tendril-progress ";
178
+ function parseProgressLine(line) {
179
+ if (!line.startsWith(PROGRESS_PREFIX)) return null;
180
+ try {
181
+ return JSON.parse(line.slice(PROGRESS_PREFIX.length));
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+ function stripProgressLines(stderr) {
187
+ return stderr.split("\n").filter((l) => !l.startsWith(PROGRESS_PREFIX)).join("\n");
188
+ }
189
+ function consumeProgressChunk(pending, chunk, onProgress) {
190
+ const lines = (pending + chunk).split("\n");
191
+ const rest = lines.pop() ?? "";
192
+ for (const line of lines) {
193
+ const p = parseProgressLine(line);
194
+ if (p !== null) onProgress(p);
195
+ }
196
+ return rest;
197
+ }
198
+ function runCli(argv, timeoutMs = 9e5, onProgress) {
199
+ return new Promise((resolve) => {
200
+ const child = execFile(
201
+ CLI_SPAWN.cmd,
202
+ [...CLI_SPAWN.prefix, ...argv, "--json"],
203
+ { cwd: process.cwd(), timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, INIT_CWD: process.cwd() } },
204
+ (err, stdout, stderrRaw) => {
205
+ const exitCode = err === null ? 0 : err.code ?? 1;
206
+ resolve({ ok: exitCode === 0, exitCode: typeof exitCode === "number" ? exitCode : 1, stdout, stderr: stripProgressLines(stderrRaw) });
207
+ }
208
+ );
209
+ if (onProgress === void 0) return;
210
+ let pending = "";
211
+ child.stderr?.on("data", (chunk) => {
212
+ pending = consumeProgressChunk(pending, chunk.toString(), onProgress);
213
+ });
214
+ });
215
+ }
216
+ function sourceHash() {
217
+ const dir = path.dirname(fileURLToPath(import.meta.url));
218
+ const h = createHash("sha256");
219
+ for (const f of readdirSync(dir).filter((n) => n.endsWith(".ts")).sort()) {
220
+ h.update(f);
221
+ h.update(readFileSync(path.join(dir, f)));
222
+ }
223
+ return h.digest("hex").slice(0, 16);
224
+ }
225
+ var BOOT_HASH = (() => {
226
+ try {
227
+ return sourceHash();
228
+ } catch {
229
+ return "unknown";
230
+ }
231
+ })();
232
+ function serverStale() {
233
+ if (BOOT_HASH === "unknown") return false;
234
+ try {
235
+ return sourceHash() !== BOOT_HASH;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+ function toolResult(result) {
241
+ const stale = serverStale() ? `
242
+ {"serverStale":true,"remediation":"packages/mcp changed since this server started \u2014 reload the Tendril MCP server before trusting this result or reporting a bug against it"}` : "";
243
+ const body = (result.stdout.trim() !== "" ? result.stdout : result.stderr) + stale;
244
+ if (result.exitCode === 5) {
245
+ return { content: [{ type: "text", text: `${body}
246
+ {"exitCode":5,"note":"sub-bar HONEST result \u2014 the report and bundle are valid; this is not a tool failure"}` }] };
247
+ }
248
+ const text = result.ok ? body : `${body}
249
+ {"exitCode":${result.exitCode},"note":"CLI exit-code contract: 3 input, 4 confirmation required, 6 fonts unproven, 7 recording incomplete"}`;
250
+ return { content: [{ type: "text", text }], ...result.ok ? {} : { isError: true } };
251
+ }
252
+
253
+ // packages/mcp/src/bin.ts
254
+ var server = new McpServer({ name: "tendril", version: "0.1.0" });
255
+ for (const tool of TOOLS) {
256
+ server.registerTool(
257
+ tool.name,
258
+ { description: tool.description, inputSchema: tool.schema.shape, ...tool.annotations !== void 0 ? { annotations: tool.annotations } : {} },
259
+ async (input, extra) => {
260
+ const token = extra?._meta?.["progressToken"];
261
+ const onProgress = token === void 0 || extra?.sendNotification === void 0 ? void 0 : (p) => {
262
+ void extra.sendNotification({
263
+ method: "notifications/progress",
264
+ params: { progressToken: token, progress: p.done, total: p.total, message: `scored ${p.done}/${p.total} configs (${p.label})` }
265
+ });
266
+ };
267
+ return toolResult(await runCli(tool.argv(input), 9e5, onProgress));
268
+ }
269
+ );
270
+ }
271
+ var transport = new StdioServerTransport();
272
+ await server.connect(transport);