Skip to content
Closed
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,16 @@ People keep asking how figma-cli differs from **Figma's official MCP** and from
| **Works offline** | yes | no |
| **Setup** | one `connect` command | token + plugin/bridge + (for some) port wrangling |

### The one exception: reading comments

The Plugin API cannot see comment threads, in any connection mode, so reviewing a file's feedback was impossible from the CLI. `figma-cli comments list` fills that gap with a single, opt-in REST call: it needs a personal access token (Figma → Settings → Security, scopes *File content: Read* and *Comments: Read*) and shows each thread against the layer it is pinned to. Nothing else in figma-cli uses the token, and without one nothing is sent.

```bash
figma-cli comments token set # hidden prompt, stored in the Keychain
figma-cli comments list # unresolved threads, "screen › layer" per pin
figma-cli comments list --node <id> --all --json
```

### Why "no rate limit" is a real, structural advantage
Figma's APIs are rate-limited, and any tool built on them (Figma's official MCP included) runs into the same ceiling. The official MCP caps the tools that *read* from Figma by seat: just **6 calls per month** on a View/Collab seat, and **200 to 600 per day** (10 to 20 per minute) even on a paid Dev/Full seat ([source](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access)). AI agents read constantly, so they burn through those fast, then sit in 429 cooldowns. figma-cli sidesteps all of it because it never calls the API: it drives Figma Desktop locally over CDP. No token, no 429, works offline.

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Security review of figma-cli is mostly a question of what the three connection m
### Data and credentials

- No telemetry, no analytics, no phone-home. Outbound requests happen only where a command needs them: `<Icon>` fetches the SVG from the Iconify API, `recreate-url` / `screenshot-url` / Storybook import load the URL you passed, image props load the image URL you passed, and the background-removal command uploads the exported image to remove.bg with your own API key. Nothing leaves the machine on its own.
- **No Figma API key is involved.** The tool never asks for one and never sees your Figma token.
- **No Figma API key is involved**, with one opt-in exception: `figma-cli comments list` reads comment threads from the Figma REST API (`api.figma.com`), because the Plugin API cannot see comments in any connection mode. It needs a personal access token you create yourself (scopes *File content: Read* and *Comments: Read*), taken from `FIGMA_ACCESS_TOKEN` or stored with `figma-cli comments token set`. The token is only sent to `api.figma.com`, only when you run that command, and is never printed (`token status` shows it masked). Without a token nothing is sent.
- Credentials for optional plugins (for example a voice API key) go into the macOS Keychain, or on Linux into `~/.config/figma-cli/credentials` with mode 600.

## Scope
Expand Down
1 change: 1 addition & 0 deletions plugin/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"capabilities": [],
"permissions": ["teamlibrary"],
"enableProposedApi": false,
"enablePrivatePluginApi": true,
"editorType": ["figma", "figjam"],
"documentAccess": "dynamic-page",
"networkAccess": {
Expand Down
146 changes: 146 additions & 0 deletions src/commands/comments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import chalk from 'chalk';
import { program, checkConnection, daemonExec } from '../lib/cli-core.js';
import { getKey, saveKey, deleteKey, maskKey, promptKeySecure } from '../credentials.js';
import { parseFileKey, toThreads, pinsOf, pinLabel, resolvePinsCode } from '../lib/comments.js';

// ============ COMMENTS ============
// The one command that talks to Figma's cloud API. The Plugin API has no access
// to comment threads in any connection mode, so `comments list` reads them from
// the REST API (GET /v1/files/:key/comments) with a personal access token. The
// plugin is still used for what REST cannot say: the open file's key and the
// layer each comment is pinned to. Nothing else in figma-cli needs the token,
// and without one nothing here is ever sent.

const TOKEN_NAME = 'FIGMA_ACCESS_TOKEN';
const API = 'https://api.figma.com/v1';

function tokenSource() {
if (process.env.FIGMA_ACCESS_TOKEN) return { token: process.env.FIGMA_ACCESS_TOKEN, from: 'FIGMA_ACCESS_TOKEN env var' };
const stored = getKey(TOKEN_NAME);
return stored ? { token: stored, from: 'credential store' } : null;
}

function requireToken() {
const source = tokenSource();
if (source) return source.token;

console.error(chalk.red('✗ Reading comments needs a Figma personal access token.'));
console.error(chalk.gray(' The Plugin API cannot see comment threads, so this is the one command'));
console.error(chalk.gray(' that uses the REST API. Create a token in Figma → Settings → Security →'));
console.error(chalk.gray(' Personal access tokens, scopes "File content: Read" and "Comments: Read",'));
console.error(chalk.gray(' then run:'));
console.error(chalk.cyan(' figma-cli comments token set'));
console.error(chalk.gray(' (or set FIGMA_ACCESS_TOKEN in your shell).'));
process.exit(1);
}

async function openFileKey() {
await checkConnection();
const key = await daemonExec('eval', { code: 'figma.fileKey' });
if (!key) throw new Error("Could not read the open file's key. Pass --file <key or figma.com URL>.");
return key;
}

async function fetchComments(fileKey, token) {
const res = await fetch(`${API}/files/${fileKey}/comments?as_md=true`, {
headers: { 'X-Figma-Token': token }
});
if (res.status === 403) throw new Error('403 from Figma: the token lacks the "Comments: Read" scope, or has no access to this file.');
if (res.status === 404) throw new Error(`404 from Figma: file ${fileKey} not found for this token.`);
if (!res.ok) throw new Error(`Figma API ${res.status}: ${await res.text()}`);
const body = await res.json();
return body.comments || [];
}

const commentsCmd = program
.command('comments')
.description('Read comment threads on a file (REST API; needs a personal access token)');

commentsCmd
.command('list')
.description('List comment threads (unresolved only unless --all)')
.option('-f, --file <key|url>', 'File key or figma.com URL (default: the file open in Figma)')
.option('-n, --node <id>', 'Only comments pinned inside this node (any depth)')
.option('-a, --all', 'Include resolved threads')
.option('--json', 'Print raw JSON (threads with the resolved layer)')
.action(async (options) => {
const token = requireToken();
try {
const fileKey = options.file ? parseFileKey(options.file) : await openFileKey();
const all = toThreads(await fetchComments(fileKey, token));
const open = options.all ? all : all.filter((t) => !t.resolved_at);

let info = {};
try {
info = (await daemonExec('eval', { code: resolvePinsCode(pinsOf(open)) }, 120000)) || {};
} catch {
// Not connected: still list the threads, with node ids instead of layer names.
}

const inScope = options.node
? open.filter((t) => info[t.id]?.chain.some((c) => c.id === options.node))
: open;

if (options.json) {
console.log(JSON.stringify(inScope.map((t) => ({ ...t, node: info[t.id] || null })), null, 2));
return;
}

if (inScope.length === 0) {
console.log(chalk.gray(`No ${options.all ? '' : 'unresolved '}comments${options.node ? ` inside ${options.node}` : ''}.`));
return;
}

for (const t of inScope) {
const pin = info[t.id];
const where = t.client_meta?.node_id ? `${pinLabel(pin)} (${pin ? pin.id : t.client_meta.node_id})` : 'canvas';
const state = t.resolved_at ? chalk.green(' [resolved]') : '';
console.log(chalk.bold(`#${t.order_id || t.id}`) + chalk.gray(` ${where}`) + state);
console.log(` ${chalk.cyan(t.user?.handle || '?')} ${chalk.gray(t.created_at.slice(0, 16).replace('T', ' '))}`);
console.log(` ${t.message.split('\n').join('\n ')}`);
for (const r of t.replies) {
console.log(` ↳ ${chalk.cyan(r.user?.handle || '?')}: ${r.message.split('\n').join('\n ')}`);
}
console.log('');
}
console.log(chalk.gray(`${inScope.length} thread(s)`));
} catch (e) {
console.error(chalk.red('✗'), e.message);
process.exit(1);
}
});

const tokenCmd = commentsCmd
.command('token')
.description('Store, check or remove the personal access token (OS credential store)');

tokenCmd
.command('set')
.description('Prompt for the token (hidden input) and store it securely')
.action(async () => {
const token = (await promptKeySecure(' Figma personal access token: ')).trim();
if (!token) {
console.error(chalk.red('✗ Empty token, nothing stored.'));
process.exit(1);
}
saveKey(TOKEN_NAME, token);
console.log(chalk.green(`✓ Stored ${maskKey(token)}`));
});

tokenCmd
.command('status')
.description('Say whether a token is available (masked)')
.action(() => {
const source = tokenSource();
console.log(source
? chalk.green(`✓ ${maskKey(source.token)} (${source.from})`)
: chalk.yellow('No token set. Run: figma-cli comments token set'));
});

tokenCmd
.command('clear')
.description('Remove the stored token')
.action(() => {
deleteKey(TOKEN_NAME);
console.log(chalk.green('✓ Token removed from the credential store.'));
});
3 changes: 2 additions & 1 deletion src/lib/command-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const ALL = [
'setup', 'variables', 'daemon', 'tokens', 'gradient', 'create', 'url-tools',
'config', 'canvas-ops', 'render', 'export-eval', 'analyze', 'a11y',
'node-ops', 'slots', 'figjam', 'variants', 'misc', 'extract', 'rules',
'snapshot', 'spec', 'instantiate', 'init', 'motion',
'snapshot', 'spec', 'instantiate', 'init', 'motion', 'comments',
];

/**
Expand Down Expand Up @@ -112,4 +112,5 @@ export const COMMAND_MODULES = {
instantiate: ['instantiate'],
'init-agent': ['init'],
motion: ['motion'],
comments: ['comments'],
};
82 changes: 82 additions & 0 deletions src/lib/comments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Pure helpers for `figma-cli comments` — no network, no Figma, so they are
* unit-testable on their own (tests/comments.test.js).
*/

/**
* A bare file key, or the key out of any figma.com/{design,file,proto,board}/<key>/… URL.
*/
export function parseFileKey(value) {
const m = String(value).match(/figma\.com\/(?:design|file|proto|board)\/([A-Za-z0-9]+)/);
return m ? m[1] : value;
}

/**
* Group the flat REST comment list into threads (root + replies), oldest first.
*/
export function toThreads(comments) {
const roots = comments.filter((c) => !c.parent_id);
const replies = comments.filter((c) => c.parent_id);
return roots
.map((root) => ({
...root,
replies: replies
.filter((r) => r.parent_id === root.id)
.sort((a, b) => a.created_at.localeCompare(b.created_at))
}))
.sort((a, b) => a.created_at.localeCompare(b.created_at));
}

/**
* What the plugin needs to resolve each pin. REST pins a comment to a node plus
* an offset inside it (usually the outermost frame), so both travel together.
*/
export function pinsOf(threads) {
return threads
.filter((t) => t.client_meta?.node_id)
.map((t) => ({ key: t.id, node_id: t.client_meta.node_id, offset: t.client_meta.node_offset || null }));
}

/**
* "outer › … › layer" from a resolved pin's ancestor chain (innermost first),
* trimmed to the three innermost names so deep boards stay readable.
*/
export function pinLabel(info) {
if (!info) return 'node not found (deleted?)';
return info.chain.map((c) => c.name).slice(0, 3).reverse().join(' › ');
}

/**
* Plugin-side code that walks each pin's offset down to the deepest visible
* layer under it and returns { [pin.key]: { id, name, chain } }.
*/
export function resolvePinsCode(pins) {
return `(async () => {
const pins = ${JSON.stringify(pins)};
const inside = (n, x, y) => {
const b = n.absoluteBoundingBox;
return b && x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
};
const deepest = (n, x, y) => {
if (!('children' in n)) return n;
for (let i = n.children.length - 1; i >= 0; i--) {
const c = n.children[i];
if (c.visible !== false && inside(c, x, y)) return deepest(c, x, y);
}
return n;
};
const out = {};
for (const pin of pins) {
const base = await figma.getNodeByIdAsync(pin.node_id);
if (!base) { out[pin.key] = null; continue; }
let n = base;
if (pin.offset && base.absoluteBoundingBox) {
n = deepest(base, base.absoluteBoundingBox.x + pin.offset.x, base.absoluteBoundingBox.y + pin.offset.y);
}
const chain = [];
for (let p = n; p && p.type !== 'PAGE' && p.type !== 'DOCUMENT'; p = p.parent) chain.push({ id: p.id, name: p.name });
out[pin.key] = { id: n.id, name: n.name, chain };
}
return out;
})()`;
}
61 changes: 61 additions & 0 deletions tests/comments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { parseFileKey, toThreads, pinsOf, pinLabel, resolvePinsCode } from '../src/lib/comments.js';

describe('parseFileKey', () => {
it('returns a bare key unchanged', () => {
assert.strictEqual(parseFileKey('AbC123'), 'AbC123');
});

it('extracts the key from design, file, proto and board URLs', () => {
for (const kind of ['design', 'file', 'proto', 'board']) {
assert.strictEqual(parseFileKey(`https://www.figma.com/${kind}/AbC123/Name?node-id=1-2`), 'AbC123');
}
});
});

describe('toThreads', () => {
const comments = [
{ id: '3', parent_id: '1', created_at: '2026-01-01T10:05:00Z', message: 'second reply' },
{ id: '1', parent_id: '', created_at: '2026-01-01T10:00:00Z', message: 'root A' },
{ id: '2', parent_id: '1', created_at: '2026-01-01T10:01:00Z', message: 'first reply' },
{ id: '4', parent_id: '', created_at: '2025-12-31T09:00:00Z', message: 'root B' }
];

it('groups replies under their root, both oldest first', () => {
const threads = toThreads(comments);
assert.deepStrictEqual(threads.map((t) => t.id), ['4', '1']);
assert.deepStrictEqual(threads[1].replies.map((r) => r.id), ['2', '3']);
assert.deepStrictEqual(threads[0].replies, []);
});
});

describe('pinsOf', () => {
it('keeps only pinned threads and carries the offset', () => {
const pins = pinsOf([
{ id: 'a', client_meta: { node_id: '1:2', node_offset: { x: 5, y: 6 } } },
{ id: 'b', client_meta: { x: 10, y: 10 } },
{ id: 'c' }
]);
assert.deepStrictEqual(pins, [{ key: 'a', node_id: '1:2', offset: { x: 5, y: 6 } }]);
});
});

describe('pinLabel', () => {
it('shows the three innermost ancestors, outer to inner', () => {
const info = { chain: [{ name: 'Layer' }, { name: 'Card' }, { name: 'Screen' }, { name: 'Board' }] };
assert.strictEqual(pinLabel(info), 'Screen › Card › Layer');
});

it('says so when the pinned node is gone', () => {
assert.match(pinLabel(null), /not found/);
});
});

describe('resolvePinsCode', () => {
it('generates valid JavaScript with the pins inlined as data', () => {
const code = resolvePinsCode([{ key: 'a', node_id: '1:2', offset: { x: 1, y: 2 } }]);
assert.doesNotThrow(() => new Function(`return ${code}`));
assert.ok(code.includes('"node_id":"1:2"'));
});
});