figctl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tiaan du Plessis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,446 @@
1
+ # figctl
2
+
3
+ One static binary that reads a Figma file and hands a coding agent everything it needs to implement the design.
4
+
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/tiaanduplessis/figctl/blob/main/LICENSE)
6
+ [![repository](https://img.shields.io/badge/source-github-181717.svg)](https://github.com/tiaanduplessis/figctl)
7
+
8
+ Create a Figma personal access token with `current_user:read`,
9
+ `file_content:read`, `file_metadata:read`, and `library_content:read`.
10
+ See [Tokens and scopes](#tokens-and-scopes) for optional features.
11
+
12
+ ```sh
13
+ npm install --save-dev figctl
14
+ npx figctl version
15
+ npx figctl auth login personal --default
16
+ npx figctl file tree "https://www.figma.com/design/KEY/Web-App"
17
+ ```
18
+
19
+ Paste the token into the hidden login prompt. Replace the URL with a file your
20
+ account can access. `KEY` is the segment after `/design/` or `/file/`.
21
+ The tree prints node ids and names; choose a frame id and replace `2:2`:
22
+
23
+ ```sh
24
+ npx figctl node context KEY --node 2:2
25
+ ```
26
+
27
+ The result includes layout and style data and downloaded asset paths.
28
+ Read any `hints` for optional data unavailable with your token or plan.
29
+
30
+ figctl is a Go binary. This package downloads the release build for your
31
+ platform and runs it through a shim, so there is no toolchain to install, no
32
+ native module to compile, and no native runtime dependency. The npm shim requires Node.js 18 or later.
33
+
34
+ Installing it as a dev dependency pins the version in `package.json`, so every
35
+ machine and CI job in a repository gets the same figctl. That matters more than
36
+ usual here: an agent depends on the shape of the output, so a tool that silently
37
+ changes underneath it changes the agent's behaviour.
38
+
39
+ ## Contents
40
+
41
+ - [Why this exists](#why-this-exists)
42
+ - [The workflow](#the-workflow)
43
+ - [Teaching your agent to use it](#teaching-your-agent-to-use-it)
44
+ - [Command surface](#command-surface)
45
+ - [Output contract](#output-contract)
46
+ - [Exit codes and error codes](#exit-codes-and-error-codes)
47
+ - [Tokens and scopes](#tokens-and-scopes)
48
+ - [Working across several Figma accounts](#working-across-several-figma-accounts)
49
+ - [Caching and rate limits](#caching-and-rate-limits)
50
+ - [What this package does on install](#what-this-package-does-on-install)
51
+ - [Other ways to install](#other-ways-to-install)
52
+ - [Documentation](#documentation)
53
+
54
+ ## Why this exists
55
+
56
+ An agent that can run a shell can implement a Figma design end to end if it can
57
+ answer six questions cheaply: what is in this file, what does this node look
58
+ like, what are the exact layout and style values, which design token does each
59
+ value come from, where are the assets, and what did the designer say about it.
60
+ figctl answers all six from the public Figma REST API and prints JSON an agent
61
+ can parse.
62
+
63
+ ### CLI or MCP
64
+
65
+ figctl exposes explicit shell commands and JSON output for scripts and coding
66
+ agents. It uses a personal access token and can run without the Figma desktop
67
+ app, including in CI when credentials and network access are available.
68
+
69
+ [Figma's MCP server](https://developers.figma.com/docs/figma-mcp-server/) connects
70
+ supported agents to design context, Code Connect, and canvas-writing tools.
71
+ Figma recommends its hosted remote server, which also needs no desktop app;
72
+ a desktop server is available for local workflows.
73
+
74
+ Choose figctl when you want commands you can inspect, pipe, cache, and pin to a
75
+ version. Choose Figma MCP when you want its native agent integration and Figma
76
+ features. Authentication, available tools, and client support differ; consult
77
+ Figma's documentation for current MCP requirements.
78
+
79
+ ## The workflow
80
+
81
+ ### 1. Outline the file
82
+
83
+ ```sh
84
+ npx figctl file tree KEY -o md
85
+ ```
86
+
87
+ ```
88
+ - 0:1 CANVAS "Screens" (1 children)
89
+ - 2:1 SECTION "Onboarding" 470x964 @-40,-80 (1 children) [dev=READY_FOR_DEV]
90
+ - 1:2 CANVAS "Design System" (3 children)
91
+ - 3:10 COMPONENT_SET "Button" 368x132 @0,0 (4 children) [component]
92
+ - 3:20 COMPONENT "Input/Text" 342x56 @0,160 (3 children) [component, layout=column]
93
+ ```
94
+
95
+ Default depth is 2, so one call shows the pages and their top-level frames.
96
+ Narrow with `--node`, `--page`, `--depth`, `--type`, `--name`, `--visible-only`.
97
+ Search the whole document with `figctl file find KEY --name "Login*"` or
98
+ `--text "sign in"`.
99
+
100
+ ### 2. Get everything needed for one node
101
+
102
+ ```sh
103
+ npx figctl node context KEY --node 2:2
104
+ ```
105
+
106
+ One call returns the normalized model, a PNG screenshot written to disk, SVG
107
+ exports of the icon-like layers, the raster image fills, only the design tokens
108
+ that subtree actually uses, the component and variant definitions of every
109
+ instance, the distances the designer pinned in Dev Mode, the comments pinned on
110
+ the node, and the Dev Mode resource links.
111
+
112
+ ````
113
+ ## Node: Login (2:2, FRAME)
114
+
115
+ - page: Screens
116
+ - path: Screens / Onboarding / Login
117
+ - size: 390 x 844
118
+
119
+ #### 2:2 Login (FRAME)
120
+
121
+ - layout: column; gap 16px [space/4]; padding 24px; w fixed 390px; h fixed 844px; clips
122
+ - fills: #ffffff [bg/surface]
123
+ - effects: box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1) {shadow/md}
124
+
125
+ ```css
126
+ background: var(--bg-surface);
127
+ box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
128
+ display: flex;
129
+ flex-direction: column;
130
+ gap: var(--space-4);
131
+ padding: 24px;
132
+ ```
133
+ ````
134
+
135
+ Square brackets are variables, braces are styles. The `token` field has three
136
+ states: a `name` means it is resolved, a bare `variableId` with
137
+ `"unresolved": true` means a variable governs the value but naming it needs an
138
+ Enterprise plan, and `null` means the designer typed it by hand. Only the last
139
+ is safe to hardcode.
140
+
141
+ Trim the payload with `--depth`, `--max-nodes`, `--no-assets`, `--no-comments`,
142
+ `--no-screenshot`, `--no-css`.
143
+
144
+ ### 3. Implement it
145
+
146
+ The agent writes the code. figctl deliberately does not generate framework
147
+ code: the agent knows the repository's conventions and figctl does not.
148
+
149
+ ### 4. Compare the result
150
+
151
+ ```sh
152
+ npx figctl render KEY --node 2:2 --scale 2 --out ./design
153
+ ```
154
+
155
+ Renders to PNG, JPG, SVG, or PDF and prints absolute paths, so the image can go
156
+ straight into a vision tool beside a screenshot of the built component.
157
+
158
+ ### 5. Wire up the design system once
159
+
160
+ ```sh
161
+ npx figctl tokens export KEY --format css --out ./src/styles
162
+ ```
163
+
164
+ ```css
165
+ :root {
166
+ --color-brand-500: #3366ff;
167
+ --space-4: 16px;
168
+ --bg-surface: var(--neutral-0);
169
+ --shadow-md: 0px 4px 12px rgba(0, 0, 0, 0.1);
170
+ }
171
+
172
+ [data-theme="dark"] {
173
+ --bg-surface: #111827;
174
+ }
175
+ ```
176
+
177
+ Also `--format dtcg` (the default, the W3C Design Tokens Format Module 2025.10),
178
+ `--format style-dictionary` (the same bytes, consumed by Style Dictionary v4),
179
+ `--format tailwind`, and `--format json`. Variables resolve across every mode,
180
+ so light and dark come out of one export.
181
+
182
+ ## Teaching your agent to use it
183
+
184
+ figctl ships its own instructions, so an agent learns the workflow without you
185
+ writing a prompt:
186
+
187
+ ```sh
188
+ npx figctl skill install --agent claude # or: agents, cursor, copilot, codex, all
189
+ ```
190
+
191
+ That writes a `SKILL.md` plus a generated command and output-schema reference
192
+ into `.agents/skills/figctl/`, the location Codex and other clients read, and
193
+ links `.claude/skills/figctl` to it. Add `--project` to install into the
194
+ repository instead of your home directory, and `--dry-run` to see what would be
195
+ written first.
196
+
197
+ The skill is also published in the repository, so it can be installed without
198
+ figctl being present yet:
199
+
200
+ ```sh
201
+ npx skills add tiaanduplessis/figctl
202
+ ```
203
+
204
+ ## Command surface
205
+
206
+ Run `npx figctl <command> --help`, or read the
207
+ [generated reference](https://github.com/tiaanduplessis/figctl/blob/main/docs/commands.md).
208
+
209
+ | Command | What it does |
210
+ | --- | --- |
211
+ | `file info`, `tree`, `find`, `get` | file metadata, sparse outline, search, raw Figma JSON |
212
+ | `node inspect`, `context` | normalized model of nodes; everything needed to implement one node |
213
+ | `render` | nodes to PNG, JPG, SVG, or PDF files |
214
+ | `assets list`, `export` | icons, export-marked layers, and raster image fills |
215
+ | `tokens resolve`, `export` | one variable or style across modes; DTCG, CSS, Tailwind, JSON |
216
+ | `variables list`, `get` | raw variables and collections per mode (Enterprise) |
217
+ | `styles list`, `get` | styles with resolved values, for files and team libraries |
218
+ | `components list`, `get` | components, sets, and variant property definitions |
219
+ | `comments list`, `add` | designer intent, and leaving implementation notes |
220
+ | `devresources list`, `add`, `update`, `remove` | Dev Mode links, for pointing a component at its Storybook story |
221
+ | `versions list` | saved file versions, for use with `--file-version` |
222
+ | `projects`, `folders` | discovery through the projects and folders APIs |
223
+ | `profile add`, `list`, `use`, `show`, `remove` | named accounts |
224
+ | `auth login`, `logout`, `status`, `scopes` | tokens and which scopes to request |
225
+ | `skill install`, `print`, `uninstall` | write the agent skill files |
226
+ | `cache status`, `clear` | inspect and clear the on-disk cache |
227
+ | `me`, `init`, `schema`, `version`, `completion` | account, project setup, output schemas, build metadata, shell completion |
228
+
229
+ `<ref>` is a file key or any Figma URL. A `node-id=1-2` in the URL is converted
230
+ to `1:2` and used as the default `--node`, so an agent can paste the URL from
231
+ the browser unchanged.
232
+
233
+ ## Output contract
234
+
235
+ Output is JSON when stdout is not a terminal, which is what an agent gets, and a
236
+ table when it is. `--json` forces JSON, `-o md` produces markdown for agents that
237
+ read tool output as text, `-o plain` produces tab-separated rows.
238
+
239
+ Every success is one envelope:
240
+
241
+ ```json
242
+ {
243
+ "schemaVersion": 1,
244
+ "command": "file.tree",
245
+ "profile": {"name": "acme", "handle": "Jane"},
246
+ "file": {"key": "KEY", "name": "Web App", "version": "2100123456"},
247
+ "data": [],
248
+ "truncated": false,
249
+ "nextCursor": null,
250
+ "hints": ["Next: figctl node context KEY --node <id> to implement a node."]
251
+ }
252
+ ```
253
+
254
+ Every failure is one envelope too, on stdout in JSON mode:
255
+
256
+ ```json
257
+ {
258
+ "schemaVersion": 1,
259
+ "error": {
260
+ "code": "NOT_FOUND",
261
+ "message": "file NOSUCHFILEKEY000000001 not found",
262
+ "hint": "Check the key or id and that the active profile has access.",
263
+ "httpStatus": 404
264
+ }
265
+ }
266
+ ```
267
+
268
+ Data goes to stdout; progress, warnings, and the `--verbose` HTTP trace go to
269
+ stderr. Read `hints` on every call: they say what was degraded, what was
270
+ truncated, and what to run next. `schemaVersion` changes only on a breaking
271
+ change. `npx figctl schema <command>` prints the JSON Schema of any command's
272
+ payload, so field names never have to be guessed, and a test in the repository
273
+ fails if a payload ever drifts from the schema it advertises.
274
+
275
+ ## Exit codes and error codes
276
+
277
+ | Exit | Meaning |
278
+ | --- | --- |
279
+ | 0 | success |
280
+ | 1 | runtime or API error |
281
+ | 2 | usage error |
282
+ | 3 | auth error |
283
+ | 4 | not found |
284
+ | 5 | rate limited |
285
+ | 6 | partial success: `data` is usable, `failures` lists what did not work |
286
+
287
+ Exit 6 is not a failure to retry. The good results are already on stdout.
288
+
289
+ | Error code | Exit | Meaning |
290
+ | --- | --- | --- |
291
+ | `USAGE` | 2 | bad flags or arguments |
292
+ | `AUTH_MISSING` | 3 | no profile or token selected |
293
+ | `AUTH_INVALID` | 3 | the token was rejected or has expired |
294
+ | `AUTH_SCOPE` | 3 | the token lacks a scope; the message names it |
295
+ | `NOT_FOUND` | 4 | file, node, page, or style does not exist |
296
+ | `FORBIDDEN` | 1 | the account cannot see this resource |
297
+ | `RATE_LIMITED` | 5 | carries `retryAfterSeconds` |
298
+ | `PLAN_REQUIRED` | 1 | variables need Enterprise; the hint names the styles fallback |
299
+ | `RENDER_FAILED` | 1 | Figma could not render any requested node |
300
+ | `PARTIAL` | 6 | some items succeeded, some failed |
301
+ | `NETWORK` | 1 | connection or timeout |
302
+ | `INTERNAL` | 1 | a bug in figctl |
303
+
304
+ ## Tokens and scopes
305
+
306
+ figctl uses a Figma personal access token, sent in the `X-Figma-Token` header.
307
+ Create one in Figma under Settings, Security, Personal access tokens. Scopes are
308
+ chosen when the token is created and cannot be changed afterwards, and tokens
309
+ expire after at most 90 days.
310
+
311
+ Run `npx figctl auth scopes` for the current list. The ones marked required
312
+ cover the core workflow:
313
+
314
+ | Scope | Required | Used by |
315
+ | --- | --- | --- |
316
+ | `file_content:read` | yes | `file`, `node`, `render`, `assets export` |
317
+ | `file_metadata:read` | yes | `file info`, cache validation |
318
+ | `library_content:read` | yes | `components list`, `styles list` for a file |
319
+ | `current_user:read` | yes | `me`, `auth status` |
320
+ | `library_assets:read` | no | `components get --key`, `styles get --key` |
321
+ | `team_library_content:read` | no | `components`, `styles` with `--team` |
322
+ | `file_variables:read` | no | `variables`, `tokens export`. Enterprise plan only |
323
+ | `file_dev_resources:read` | no | `devresources list`, `node context` |
324
+ | `file_dev_resources:write` | no | `devresources add`, `update`, `remove` |
325
+ | `file_comments:read` | no | `comments list`, `node context` |
326
+ | `file_comments:write` | no | `comments add` only |
327
+ | `file_versions:read` | no | `versions list`, `--file-version` |
328
+ | `projects:read`, `folders:read` | no | project and folder discovery |
329
+
330
+ The old blanket `files:read` scope is deprecated; request the granular scopes.
331
+
332
+ Variables need an Enterprise plan and a full seat. The `file_variables:read`
333
+ scope is not offered on the token screen on other plans, so a token cannot
334
+ carry it at all. `tokens export` still exports the styles and says so in
335
+ `hints` rather than failing.
336
+
337
+ In CI, set `FIGMA_TOKEN` and figctl uses it as an implicit profile:
338
+
339
+ ```yaml
340
+ - run: npx figctl tokens export "$FIGMA_FILE" --format css --out ./src/styles
341
+ env:
342
+ FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }}
343
+ ```
344
+
345
+ ## Working across several Figma accounts
346
+
347
+ A consultant has one Figma account per client, often on different plans. A
348
+ profile is a name plus a token; tokens live in the OS keychain and the config
349
+ file holds no secrets.
350
+
351
+ ```sh
352
+ npx figctl profile add acme --team 555000111 --default
353
+ npx figctl init --profile acme --file app=KEY1 --file design-system=KEY2 --default app
354
+ ```
355
+
356
+ That writes a `.figctl.yaml` naming the profile and the Figma files this
357
+ repository uses, with no secret in it. A repository usually refers to more than
358
+ one, because a design system lives in its own file, and a configured name then
359
+ stands in for a key wherever a command takes a ref:
360
+
361
+ ```sh
362
+ npx figctl tokens export design-system --format css --out ./src/styles
363
+ npx figctl node context --node 2:2 # the default file
364
+ ```
365
+
366
+ Commands
367
+ walk up from the working directory to find it, so an agent working in the Acme
368
+ repository uses the Acme token without being told, and an agent in another
369
+ repository cannot reach Acme's files. Every envelope carries the profile it ran
370
+ as, so the agent can check the account before doing work.
371
+
372
+ The active profile is the first of these that matches: `--profile`,
373
+ `FIGCTL_PROFILE`, `FIGMA_TOKEN`, the nearest `.figctl.yaml`, then the user-level
374
+ default. `npx figctl auth status` reports which was chosen and why. The cache is
375
+ keyed by profile, so one client's data never mixes with another's.
376
+
377
+ ## Caching and rate limits
378
+
379
+ Figma's rate limits are tiered per endpoint, and the tier figctl needs most is
380
+ the tightest:
381
+
382
+ | Tier | Endpoints | Limit |
383
+ | --- | --- | --- |
384
+ | 1 | get file, get nodes, render images | 10 to 30 per minute on Dev and Full seats depending on plan; 20 per **month** on View and Collab seats |
385
+ | 2 | variables, versions, dev resources, comments, image fills, folders | 25 to 150 per minute |
386
+ | 3 | components, styles, file meta, users | 50 to 200 per minute |
387
+
388
+ Ten Tier 1 requests a minute is not enough to walk a file node by node, so
389
+ figctl fetches the whole document once per version, caches it per profile, and
390
+ answers `file tree`, `file find`, `node inspect`, and `node context` from the
391
+ local copy. Freshness is checked against the cheap Tier 3 metadata endpoint.
392
+
393
+ Very large files cannot be fetched whole at all: Figma refuses them with a 400.
394
+ figctl degrades to depth-limited and per-node requests rather than failing.
395
+
396
+ `--refresh` re-fetches, `--no-cache` bypasses the cache, and `--file-version`
397
+ pins a version so it never has to be validated. A 429 is retried up to three
398
+ times honouring `Retry-After`.
399
+
400
+ ## What this package does on install
401
+
402
+ 1. Works out the platform: `darwin`, `linux`, or `win32`, on `x64` or `arm64`.
403
+ Anything else fails with a message naming the supported platforms and the
404
+ other ways to install.
405
+ 2. Downloads the matching archive and `checksums.txt` from the GitHub release
406
+ for this package's version.
407
+ 3. Verifies the archive against its `sha256` line in `checksums.txt`. A mismatch
408
+ aborts the install and writes nothing.
409
+ 4. Unpacks the `figctl` binary next to the shim. No native modules, no
410
+ post-install compilation, no runtime dependencies.
411
+
412
+ | Variable | Effect |
413
+ | --- | --- |
414
+ | `FIGCTL_SKIP_DOWNLOAD=1` | skip the download; set `FIGCTL_BINARY` to an existing executable when running figctl |
415
+ | `FIGCTL_BINARY=/path/to/figctl` | copy an existing binary during install; also select an executable at runtime |
416
+ | `FIGCTL_DOWNLOAD_BASE=URL` | download the assets from somewhere other than GitHub releases |
417
+
418
+ Behind a proxy or on an air-gapped network, download the release archive
419
+ yourself and point `FIGCTL_BINARY` at the extracted binary.
420
+
421
+ The release workflow signs `checksums.txt` with [cosign](https://docs.sigstore.dev/).
422
+ This wrapper checks SHA-256 checksums; it does not verify the cosign signature.
423
+ For signature verification, follow the repository release instructions before
424
+ installing a downloaded binary with `FIGCTL_BINARY`.
425
+
426
+ ## Other ways to install
427
+
428
+ ```sh
429
+ curl -fsSL https://raw.githubusercontent.com/tiaanduplessis/figctl/main/install.sh | sh
430
+ go install github.com/tiaanduplessis/figctl/cmd/figctl@latest
431
+ ```
432
+
433
+ ## Documentation
434
+
435
+ - [README](https://github.com/tiaanduplessis/figctl#readme)
436
+ - [Command reference](https://github.com/tiaanduplessis/figctl/blob/main/docs/commands.md)
437
+ - [Wiring figctl into an agent](https://github.com/tiaanduplessis/figctl/blob/main/docs/agents.md)
438
+ - [Design tokens](https://github.com/tiaanduplessis/figctl/blob/main/docs/design-tokens.md)
439
+ - [Profiles](https://github.com/tiaanduplessis/figctl/blob/main/docs/profiles.md)
440
+ - [Caching and rate limits](https://github.com/tiaanduplessis/figctl/blob/main/docs/caching.md)
441
+ - [Changelog](https://github.com/tiaanduplessis/figctl/blob/main/CHANGELOG.md)
442
+ - [Issues](https://github.com/tiaanduplessis/figctl/issues)
443
+
444
+ ## License
445
+
446
+ MIT. Copyright Tiaan du Plessis.
package/bin/figctl.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // Thin shim: run the figctl binary that postinstall.js downloaded next to
3
+ // this file, forwarding arguments, stdio, signals, and the exit code.
4
+
5
+ "use strict";
6
+
7
+ const fs = require("node:fs");
8
+ const path = require("node:path");
9
+ const { spawnSync } = require("node:child_process");
10
+
11
+ const binary = process.env.FIGCTL_BINARY || path.join(
12
+ __dirname,
13
+ process.platform === "win32" ? "figctl.exe" : "figctl"
14
+ );
15
+
16
+ if (!fs.existsSync(binary)) {
17
+ process.stderr.write(
18
+ [
19
+ "",
20
+ "figctl: no executable found; install it or set FIGCTL_BINARY to its path.",
21
+ "",
22
+ "Re-run the install, or get figctl another way:",
23
+ " npm rebuild figctl",
24
+ " go install github.com/tiaanduplessis/figctl/cmd/figctl@latest",
25
+ " https://github.com/tiaanduplessis/figctl/releases",
26
+ "",
27
+ ].join("\n") + "\n"
28
+ );
29
+ process.exit(1);
30
+ }
31
+
32
+ const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" });
33
+
34
+ if (result.error) {
35
+ process.stderr.write("figctl: " + result.error.message + "\n");
36
+ process.exit(1);
37
+ }
38
+ if (result.signal) {
39
+ process.kill(process.pid, result.signal);
40
+ }
41
+ process.exit(result.status === null ? 1 : result.status);
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "figctl",
3
+ "version": "0.1.0",
4
+ "description": "Agent-first Figma CLI: read a Figma design and everything needed to implement it",
5
+ "keywords": [
6
+ "figma",
7
+ "figma-api",
8
+ "cli",
9
+ "design-tokens",
10
+ "dtcg",
11
+ "design-system",
12
+ "design-to-code",
13
+ "ai-agent",
14
+ "agent-skills",
15
+ "claude-code",
16
+ "codex",
17
+ "style-dictionary",
18
+ "tailwind",
19
+ "storybook",
20
+ "figma-export",
21
+ "screenshot"
22
+ ],
23
+ "homepage": "https://github.com/tiaanduplessis/figctl#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/tiaanduplessis/figctl/issues"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/tiaanduplessis/figctl.git",
30
+ "directory": "npm"
31
+ },
32
+ "license": "MIT",
33
+ "author": "Tiaan du Plessis",
34
+ "type": "commonjs",
35
+ "bin": {
36
+ "figctl": "bin/figctl.js"
37
+ },
38
+ "files": [
39
+ "bin/figctl.js",
40
+ "postinstall.js",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "scripts": {
45
+ "postinstall": "node postinstall.js",
46
+ "test": "node --test"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "os": [
52
+ "darwin",
53
+ "linux",
54
+ "win32"
55
+ ],
56
+ "cpu": [
57
+ "x64",
58
+ "arm64"
59
+ ]
60
+ }
package/postinstall.js ADDED
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ // Downloads the figctl release binary for this platform, verifies it against
3
+ // the release checksums.txt, and unpacks it next to this script.
4
+ //
5
+ // No dependencies: node:https, node:fs, node:zlib, and node:crypto only.
6
+ //
7
+ // Environment overrides:
8
+ // FIGCTL_SKIP_DOWNLOAD=1 skip downloading; set FIGCTL_BINARY to run an existing binary
9
+ // FIGCTL_BINARY=/path use an existing binary instead of downloading
10
+ // FIGCTL_DOWNLOAD_BASE=URL download from somewhere other than GitHub releases
11
+
12
+ "use strict";
13
+
14
+ const fs = require("node:fs");
15
+ const path = require("node:path");
16
+ const zlib = require("node:zlib");
17
+ const https = require("node:https");
18
+ const crypto = require("node:crypto");
19
+
20
+ const pkg = require("./package.json");
21
+
22
+ // Every GoReleaser target in .goreleaser.yml. Adding a target there means
23
+ // adding it here; a Go test asserts the two agree.
24
+ const PLATFORMS = {
25
+ "darwin-x64": { os: "darwin", arch: "amd64", ext: "tar.gz", bin: "figctl" },
26
+ "darwin-arm64": { os: "darwin", arch: "arm64", ext: "tar.gz", bin: "figctl" },
27
+ "linux-x64": { os: "linux", arch: "amd64", ext: "tar.gz", bin: "figctl" },
28
+ "linux-arm64": { os: "linux", arch: "arm64", ext: "tar.gz", bin: "figctl" },
29
+ "win32-x64": { os: "windows", arch: "amd64", ext: "zip", bin: "figctl.exe" },
30
+ "win32-arm64": { os: "windows", arch: "arm64", ext: "zip", bin: "figctl.exe" },
31
+ };
32
+
33
+ const REPO = "tiaanduplessis/figctl";
34
+ const VERSION = pkg.version;
35
+ const TAG = "v" + VERSION;
36
+ const BASE =
37
+ process.env.FIGCTL_DOWNLOAD_BASE ||
38
+ `https://github.com/${REPO}/releases/download/${TAG}`;
39
+
40
+ function fail(message, detail) {
41
+ const lines = ["", "figctl: " + message];
42
+ if (detail) {
43
+ lines.push("");
44
+ lines.push(detail);
45
+ }
46
+ lines.push("");
47
+ lines.push("Install it another way instead:");
48
+ lines.push(" go install github.com/tiaanduplessis/figctl/cmd/figctl@latest");
49
+ lines.push(` https://github.com/${REPO}/releases/tag/${TAG}`);
50
+ lines.push("");
51
+ throw new Error(lines.join("\n"));
52
+ }
53
+
54
+ function platformKey() {
55
+ return `${process.platform}-${process.arch}`;
56
+ }
57
+
58
+ function target() {
59
+ const key = platformKey();
60
+ const t = PLATFORMS[key];
61
+ if (!t) {
62
+ fail(
63
+ `no prebuilt binary for ${process.platform} ${process.arch}`,
64
+ "Supported platforms: " + Object.keys(PLATFORMS).sort().join(", ")
65
+ );
66
+ }
67
+ return t;
68
+ }
69
+
70
+ // get follows redirects and resolves with the response body as a Buffer.
71
+ function get(url, redirects = 0) {
72
+ return new Promise((resolve, reject) => {
73
+ if (redirects > 10) {
74
+ reject(new Error("too many redirects for " + url));
75
+ return;
76
+ }
77
+ const req = https.get(
78
+ url,
79
+ { headers: { "user-agent": `figctl-npm/${VERSION}` } },
80
+ (res) => {
81
+ const status = res.statusCode || 0;
82
+ if (status >= 300 && status < 400 && res.headers.location) {
83
+ res.resume();
84
+ resolve(get(new URL(res.headers.location, url).toString(), redirects + 1));
85
+ return;
86
+ }
87
+ if (status !== 200) {
88
+ res.resume();
89
+ reject(new Error(`HTTP ${status} for ${url}`));
90
+ return;
91
+ }
92
+ const chunks = [];
93
+ res.on("data", (c) => chunks.push(c));
94
+ res.on("end", () => resolve(Buffer.concat(chunks)));
95
+ res.on("error", reject);
96
+ }
97
+ );
98
+ req.on("error", reject);
99
+ req.setTimeout(120000, () => {
100
+ req.destroy(new Error("timed out after 120s downloading " + url));
101
+ });
102
+ });
103
+ }
104
+
105
+ // verify checks the archive against the sha256 line for it in checksums.txt.
106
+ function verify(checksums, assetName, archive) {
107
+ let want = null;
108
+ for (const line of checksums.toString("utf8").split("\n")) {
109
+ const m = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/);
110
+ if (m && m[2] === assetName) {
111
+ want = m[1];
112
+ break;
113
+ }
114
+ }
115
+ if (!want) {
116
+ fail(
117
+ `${assetName} is not listed in checksums.txt`,
118
+ "The release assets look incomplete. This may be a partially published release."
119
+ );
120
+ }
121
+ const got = crypto.createHash("sha256").update(archive).digest("hex");
122
+ if (got !== want) {
123
+ fail(
124
+ "checksum mismatch for " + assetName,
125
+ `expected sha256 ${want}\nreceived sha256 ${got}\n\nThe download was corrupted or tampered with. Nothing was installed.`
126
+ );
127
+ }
128
+ }
129
+
130
+ // extractTarGz returns the contents of one file from a gzipped tar archive.
131
+ // Only the ustar fields figctl's archives use are read.
132
+ function extractTarGz(buf, name) {
133
+ const tar = zlib.gunzipSync(buf);
134
+ let offset = 0;
135
+ while (offset + 512 <= tar.length) {
136
+ const header = tar.subarray(offset, offset + 512);
137
+ if (header.every((b) => b === 0)) {
138
+ break;
139
+ }
140
+ const entry = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
141
+ const sizeField = header.subarray(124, 136).toString("utf8").replace(/\0.*$/, "").trim();
142
+ if (!/^[0-7]+$/.test(sizeField)) {
143
+ throw new Error("invalid tar entry size");
144
+ }
145
+ const size = parseInt(sizeField, 8);
146
+ const typeFlag = String.fromCharCode(header[156]);
147
+ const start = offset + 512;
148
+ if (!Number.isSafeInteger(size) || start + size > tar.length) {
149
+ throw new Error("truncated tar entry");
150
+ }
151
+ if ((typeFlag === "0" || typeFlag === "\0") && path.posix.basename(entry) === name) {
152
+ return tar.subarray(start, start + size);
153
+ }
154
+ offset = start + Math.ceil(size / 512) * 512;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ // extractZip returns the contents of one file from a zip archive, reading the
160
+ // end-of-central-directory record and inflating the entry it points at.
161
+ function extractZip(buf, name) {
162
+ let eocd = -1;
163
+ for (let i = buf.length - 22; i >= 0 && i >= buf.length - 65557; i--) {
164
+ if (buf.readUInt32LE(i) === 0x06054b50) {
165
+ eocd = i;
166
+ break;
167
+ }
168
+ }
169
+ if (eocd < 0) {
170
+ return null;
171
+ }
172
+ const count = buf.readUInt16LE(eocd + 10);
173
+ let p = buf.readUInt32LE(eocd + 16);
174
+ for (let i = 0; i < count; i++) {
175
+ if (p + 46 > eocd || buf.readUInt32LE(p) !== 0x02014b50) {
176
+ return null;
177
+ }
178
+ const method = buf.readUInt16LE(p + 10);
179
+ const compressedSize = buf.readUInt32LE(p + 20);
180
+ const nameLen = buf.readUInt16LE(p + 28);
181
+ const extraLen = buf.readUInt16LE(p + 30);
182
+ const commentLen = buf.readUInt16LE(p + 32);
183
+ const localOffset = buf.readUInt32LE(p + 42);
184
+ if (p + 46 + nameLen + extraLen + commentLen > eocd) {
185
+ throw new Error("truncated zip directory");
186
+ }
187
+ const entry = buf.subarray(p + 46, p + 46 + nameLen).toString("utf8");
188
+ if (path.posix.basename(entry) === name) {
189
+ if (localOffset + 30 > p || buf.readUInt32LE(localOffset) !== 0x04034b50) {
190
+ throw new Error("invalid zip entry");
191
+ }
192
+ const localNameLen = buf.readUInt16LE(localOffset + 26);
193
+ const localExtraLen = buf.readUInt16LE(localOffset + 28);
194
+ const start = localOffset + 30 + localNameLen + localExtraLen;
195
+ if (start + compressedSize > p) {
196
+ throw new Error("truncated zip entry");
197
+ }
198
+ const data = buf.subarray(start, start + compressedSize);
199
+ if (method === 0) {
200
+ return data;
201
+ }
202
+ if (method === 8) {
203
+ return zlib.inflateRawSync(data);
204
+ }
205
+ return null;
206
+ }
207
+ p += 46 + nameLen + extraLen + commentLen;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ async function main() {
213
+ if (process.env.FIGCTL_SKIP_DOWNLOAD === "1") {
214
+ process.stderr.write(
215
+ "figctl: download skipped; set FIGCTL_BINARY to an existing executable when running figctl\n"
216
+ );
217
+ return;
218
+ }
219
+
220
+ const t = target();
221
+ const dest = path.join(__dirname, "bin", t.bin);
222
+
223
+ if (process.env.FIGCTL_BINARY) {
224
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
225
+ fs.copyFileSync(process.env.FIGCTL_BINARY, dest);
226
+ fs.chmodSync(dest, 0o755);
227
+ return;
228
+ }
229
+
230
+ const assetName = `figctl_${VERSION}_${t.os}_${t.arch}.${t.ext}`;
231
+ let archive;
232
+ let checksums;
233
+ try {
234
+ [archive, checksums] = await Promise.all([
235
+ get(`${BASE}/${assetName}`),
236
+ get(`${BASE}/checksums.txt`),
237
+ ]);
238
+ } catch (err) {
239
+ fail("could not download " + assetName, String(err && err.message ? err.message : err));
240
+ }
241
+
242
+ verify(checksums, assetName, archive);
243
+
244
+ let binary;
245
+ try {
246
+ binary = t.ext === "zip" ? extractZip(archive, t.bin) : extractTarGz(archive, t.bin);
247
+ } catch (err) {
248
+ fail("could not unpack " + assetName, String(err && err.message ? err.message : err));
249
+ }
250
+ if (!binary || binary.length === 0) {
251
+ fail(`${t.bin} was not found inside ${assetName}`);
252
+ }
253
+
254
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
255
+ fs.writeFileSync(dest, binary, { mode: 0o755 });
256
+ fs.chmodSync(dest, 0o755);
257
+ }
258
+
259
+ if (require.main === module) {
260
+ main().catch((err) => {
261
+ process.stderr.write("figctl: installation failed: " + err.message + "\n");
262
+ process.exitCode = 1;
263
+ });
264
+ }
265
+
266
+ module.exports = { verify, extractTarGz, extractZip };