Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ CLI that controls Figma Desktop directly. No API key needed.
| "create a rectangle/frame" | `figma-cli render '<Frame>...'` |
| "convert to component" | `figma-cli node to-component "ID"` |
| "use the existing X component" / "don't rebuild, instance it" | `figma-cli instantiate "X"` |
| "put a Button / any existing component into this frame" | `<Instance component="Button" variant="size=large" text="Save" />` inside `render` |
| "build it on page X" | `figma-cli render --page "X" '<Frame>…'` (creates the page when missing) |
| "make a component set with these variants" | `figma-cli render-batch '["<Frame name=\"size=small, state=hover\" …>", …]' --variant-set Button` |
| "use text style / effect style X" | `<Text textStyle="Body/Medium">`, `<Frame effectStyle="shadow/small">` |
| "what component already exists for X" | `figma-cli spec X` (shows the reuse handle) |
| "list variables" | `figma-cli var list` |
| "find nodes named X" | `figma-cli find "X"` |
Expand Down Expand Up @@ -75,6 +79,24 @@ CLI that controls Figma Desktop directly. No API key needed.

---

## Agent efficiency

Every tool call is a full agent turn (~21k tokens of context re-read), so the
CLI is built to finish a task in ONE call and to answer without a readback:

- `eval` has helpers that absorb Figma pitfalls: `$page`, `$var`, `$bind`,
`$style`, `$component`, `$instance`, `$fontSafe`, `$describe`
(src/lib/figma-helpers.js, shared with render).
- Fonts that are not installed are swapped to Inter and
reported as `note:` lines. Without that, Figma refuses to relabel an instance
or even appendChild it.
- `render` prints the structure it built (sizes, layout, padding, bindings,
which component each instance uses) so the agent does not read it back.
- Piped `eval` output is compact JSON, capped at 20k characters.
- The user-facing agent rules live in src/lib/agent-rules.js (hashed marker
block). `init-agent` writes them, `connect` refreshes outdated copies.
- Before a release: `FIGMA_FILE="<scratch file>" node tests/live/regression.mjs`.

## Key Rules

1. **Always use `render` for frames** - has smart positioning
Expand Down
40 changes: 40 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,48 @@ node src/index.js eval --file /tmp/script.js
node src/index.js run /tmp/script.js
```

## Eval Helpers

Available inside every `figma-cli eval` (and used by `render`). They install on
first use and handle lazy pages, missing fonts and name lookups:

```js
const p = await $page('Scratch'); // switch to page, create if missing
const v = await $var('bgColor/muted'); // local variable by full name
await $bind(frame, 'bgColor/muted'); // bind to fills ('strokes' or a field as 3rd arg)
const s = await $style('Body/Medium'); // any local style by name
const set = await $component('Button'); // component or set by name/id, whole file
const i = await $instance('Button', 'variant=danger, size=large', { text: 'Delete', parent: frame });
await $fontSafe(node); // load fonts, swap missing ones to Inter
return await $describe(node); // compact one-line-per-node summary
```

Piped output (an agent calling the CLI) is compact JSON and capped at 20,000
characters; a terminal still gets indented JSON.

## Render JSX Syntax

**Pages and design-system hooks**

```bash
figma-cli render --page "Scratch" '<Frame ...>' # render-batch takes --page too
```
```jsx
<Instance component="Button" variant="variant=danger, size=large" text="Delete" /> // any page, font-safe
<Frame effectStyle="shadow/resting/small">...</Frame>
<Text textStyle="Body/Medium">...</Text> // style's font missing: size/spacing kept on the fallback font
<Frame pt={4} pr={8} pb={4} pl={8}> // per-side padding, root frames included
```

**Component sets in one call**

```bash
figma-cli render-batch '["<Frame name=\"size=small, state=default\" ...>...</Frame>", ...]' --variant-set Button
```
Frame names must be `prop=value, prop=value` with the same properties on every
frame; the command validates names before touching the file.


**Elements:** `<Frame>`, `<Rectangle>`, `<Ellipse>`, `<Text>`, `<Line>`, `<Image>`, `<SVG>`, `<Icon>`

**Size:** `w={320} h={200}`, `w="fill"`, `minW={100} maxW={500}`
Expand Down
37 changes: 34 additions & 3 deletions src/commands/export-eval.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import chalk from 'chalk';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { HELPERS_SOURCE, RESET_NOTES, READ_NOTES, usesHelpers } from '../lib/figma-helpers.js';
import {
program,
checkConnection,
Expand Down Expand Up @@ -299,6 +300,33 @@ program

// ============ EVAL ============

// Agents read eval output through a pipe and pay for every indent as tokens, so
// print compact JSON there and keep the indented form for a human terminal.
// A result that dumps a whole file (thousands of nodes) costs an agent more
// tokens than the task itself, so piped output is capped with a hint.
const PIPED_OUTPUT_CAP = 20000;
function formatResult(result) {
const text = typeof result !== 'object' ? String(result)
: process.stdout.isTTY ? JSON.stringify(result, null, 2) : JSON.stringify(result);
if (process.stdout.isTTY || text.length <= PIPED_OUTPUT_CAP) return text;
return text.slice(0, PIPED_OUTPUT_CAP) +
`\n… output cut at ${PIPED_OUTPUT_CAP} of ${text.length} characters. Return counts, a filtered list or .slice(0, N) instead.`;
}

// $page / $var / $bind / $instance / ... live on globalThis in Figma. Install
// them first when the code uses one; the install is a no-op when present.
async function ensureHelpers(code, run) {
if (!usesHelpers(code)) return;
await run(HELPERS_SOURCE + '\n' + RESET_NOTES + '\nreturn true;');
}
async function printHelperNotes(code, run) {
if (!usesHelpers(code)) return;
try {
const notes = await run(READ_NOTES);
for (const n of notes || []) console.error(chalk.yellow('note: ' + n));
} catch {}
}

program
.command('eval [code]')
.description('Execute JavaScript in Figma plugin context')
Expand All @@ -324,10 +352,12 @@ program
// Always prefer async daemon (more reliable, no shell timeout issues)
if (isDaemonRunning()) {
try {
await ensureHelpers(jsCode, c => daemonExec('eval', { code: c }));
const result = await daemonExec('eval', { code: jsCode });
if (result !== undefined && result !== null) {
console.log(typeof result === 'object' ? JSON.stringify(result, null, 2) : result);
console.log(formatResult(result));
}
await printHelperNotes(jsCode, c => daemonExec('eval', { code: c }));
return;
} catch (e) {
// Check if this is a connection/daemon error vs user code error
Expand All @@ -349,9 +379,10 @@ program

// Sync fallback (when daemon not running)
try {
await ensureHelpers(jsCode, async c => figmaEvalSync(c));
const result = figmaEvalSync(jsCode);
if (result !== undefined && result !== null) {
console.log(typeof result === 'object' ? JSON.stringify(result, null, 2) : result);
console.log(formatResult(result));
}
} catch (error) {
console.log(chalk.red('✗ ' + error.message));
Expand All @@ -374,7 +405,7 @@ program
if (isDaemonRunning()) {
const result = await daemonExec('eval', { code });
if (result !== undefined) {
console.log(typeof result === 'object' ? JSON.stringify(result, null, 2) : result);
console.log(formatResult(result));
}
} else {
// Fallback to sync path
Expand Down
90 changes: 11 additions & 79 deletions src/commands/init.js
Original file line number Diff line number Diff line change
@@ -1,79 +1,14 @@
// Command: init — scaffold agent guidance into a designer's project so figma-cli
// "just works" with whichever AI coding tool they use (Claude Code or Cursor).
// Writes the SAME condensed usage ruleset to:
// Command: init-agent — scaffold agent guidance into a designer's project so
// figma-cli "just works" with whichever AI coding tool they use.
// Writes the SAME ruleset to:
// - .cursor/rules/figma-cli.mdc (Cursor)
// - AGENTS.md (Claude Code, Cursor, Codex all read it)
// The CLI binary needs nothing else — it controls Figma Desktop directly, so it
// already runs in any terminal. This just teaches the agent HOW to drive it.
// The rules and the update logic live in lib/agent-rules.js, because `connect`
// also refreshes outdated copies.
import chalk from 'chalk';
import { writeFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { join } from 'path';
import { program } from '../lib/cli-core.js';

// The shared, designer-facing usage rules. Kept tight on purpose — an agent
// needs the operating rules, not the CLI's internals.
const RULES_BODY = `# Using figma-cli

figma-cli controls **Figma Desktop** directly (no API key). It runs in any
terminal. Open Figma Desktop, then \`figma-cli connect\` once per session.

## Golden rules
1. **Create frames with \`render\` / \`render-batch\`** — they have smart positioning.
NEVER use \`eval\` to create visual nodes (no positioning, bypasses guards).
2. **"N buttons/cards" = N separate top-level nodes**, not one wrapper frame
containing N children. Use \`render-batch '[...]'\` or \`shadcn add <c> --count N\`.
3. **Never delete the user's existing nodes.**
4. After creating, **verify**: \`figma-cli verify "<id>" --measure\` (returns a
screenshot + real w/h so you catch size bugs by numbers, not by eye).

## Design tokens / variables
- Bind colors at creation with \`var:name\`, never raw hex when a system is loaded:
\`<Frame bg="var:primary"><Text color="var:on-primary">Go</Text></Frame>\`
- Pin a named collection when the user names one: \`render-batch ... --collection figma\`.
- Import a system: \`figma-cli import tailwind.config.js | globals.css | tokens.json\`.
- Export the open file's system: \`figma-cli extract\` → DESIGN.md.

## JSX cheatsheet (render)
- Layout: \`flex="row|col" gap={16} p={24} px py pt pr pb pl justify="center|between" items="center"\`
- Size: \`w={320} h={200} w="fill" w="hug" w="60%"\` (percent resolves vs parent)
- Look: \`bg="#fff" stroke="#000" strokeWidth={2} rounded={12} shadow="..." opacity={0.8}\`
- Text: \`<Text size={14} weight="semibold" color="#000" lineHeight={20} truncate maxLines={2} w="fill">\`
- Icons (real SVG, never emojis): \`<Icon name="lucide:home" size={20} color="var:primary" />\`
- Dividers: a thin child (\`<Frame w={1} bg="var:border" />\`) auto-fills the cross axis.

## Text wrapping (most common bug)
For text to wrap, the parent AND every \`<Text>\` need \`w="fill"\`, and the parent
needs \`flex="col"\` or \`flex="row"\`.

## Recreating a component from an extracted DESIGN.md (hard rule)
Don't read the structure markdown by hand. Use:
- \`figma-cli spec <Component>\` → authoritative variant axes + sample size (compact).
Build EXACTLY to those axes (e.g. Variant × Size = a Component Set, not one node).
- \`figma-cli spec <Component> --check <nodeId>\` → enforces it (exit 1 on mismatch:
wrong structure, missing axes, wrong height). Treat non-zero as "not done".

## Handy commands
\`\`\`
figma-cli connect # connect to Figma Desktop (yolo)
figma-cli render '<Frame>...</Frame>' # one frame
figma-cli render-batch '[ "<Frame>", ... ]' --direction row
figma-cli shadcn add button --count 3 # N distinct shadcn primitives
figma-cli node to-component "<id>" # promote to a component
figma-cli verify "<id>" --measure # screenshot + dimensions
figma-cli a11y audit # contrast / touch / text checks
\`\`\`
`;

function writeFile(path, content, force) {
if (existsSync(path) && !force) {
const existing = readFileSync(path, 'utf8');
if (existing.includes('# Using figma-cli')) return { path, status: 'up-to-date' };
return { path, status: 'exists' }; // don't clobber unrelated content
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
return { path, status: 'written' };
}
import { RULE_FILES, syncRulesFile } from '../lib/agent-rules.js';

program
.command('init-agent')
Expand All @@ -85,18 +20,15 @@ program
const cwd = process.cwd();
const results = [];

if (tool === 'cursor' || tool === 'both') {
const mdc = `---\ndescription: How to drive figma-cli (controls Figma Desktop) from this project\nalwaysApply: true\n---\n\n${RULES_BODY}`;
results.push(writeFile(join(cwd, '.cursor', 'rules', 'figma-cli.mdc'), mdc, options.force));
}
if (tool === 'claude' || tool === 'both') {
// AGENTS.md is read by Claude Code, Cursor and Codex — one file, all tools.
results.push(writeFile(join(cwd, 'AGENTS.md'), RULES_BODY, options.force));
for (const f of RULE_FILES) {
if (tool !== 'both' && tool !== f.tool) continue;
results.push(syncRulesFile(join(cwd, f.rel), f.fresh, { force: options.force }));
}

for (const r of results) {
const rel = r.path.replace(cwd + '/', '');
if (r.status === 'written') console.log(chalk.green('✓ wrote'), rel);
else if (r.status === 'updated') console.log(chalk.green('✓ updated'), rel);
else if (r.status === 'up-to-date') console.log(chalk.gray('• up-to-date'), rel);
else console.log(chalk.yellow('• exists (use --force to overwrite)'), rel);
}
Expand Down
21 changes: 18 additions & 3 deletions src/commands/instantiate.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,30 @@ import { resolveInstancePlan } from '../lib/instance-plan.js';
// Build the async, dynamic-page-safe eval that tries each plan step in order.
// Exported for unit testing. A COMPONENT_SET resolves to its default variant
// (a set has no createInstance). First success wins; failures are collected.
export function instantiateCode(plan) {
//
// Every step is time-bounded. `importComponentByKeyAsync` on a key that was
// never published NEVER SETTLES — it neither resolves nor rejects (measured:
// still pending after 2 minutes). Unbounded, the whole command hangs and the
// daemon wedges behind it, when the correct behaviour is to fall through to the
// node-id step, which is exactly the case for a component in the OPEN file that
// is not published to a library.
export const STEP_TIMEOUT_MS = 5000;

export function instantiateCode(plan, { timeoutMs = STEP_TIMEOUT_MS } = {}) {
return `(async () => {
const plan = ${JSON.stringify(plan)};
const TIMEOUT = ${Number(timeoutMs)};
const bounded = (p, what) => Promise.race([
p,
new Promise((_, reject) => setTimeout(
() => reject(new Error(what + ' did not answer within ' + TIMEOUT + 'ms')), TIMEOUT)),
]);
const tried = [];
for (const step of plan) {
try {
let comp;
if (step.via === 'key') comp = await figma.importComponentByKeyAsync(step.key);
else comp = await figma.getNodeByIdAsync(step.id);
if (step.via === 'key') comp = await bounded(figma.importComponentByKeyAsync(step.key), 'library import');
else comp = await bounded(figma.getNodeByIdAsync(step.id), 'node lookup');
if (!comp) { tried.push(step.via + ': not found'); continue; }
if (comp.type === 'COMPONENT_SET') comp = comp.defaultVariant || comp.children[0];
if (!comp || comp.type !== 'COMPONENT') { tried.push(step.via + ': not a component'); continue; }
Expand Down
Loading
Loading