From ec451bc8836513b6e976ca5ac2d87ac97542faf3 Mon Sep 17 00:00:00 2001 From: Konstantinos Dimitropoulos Date: Wed, 30 Sep 2026 13:30:36 +0300 Subject: [PATCH] feat: read comment threads with `figma-cli comments` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plugin API cannot see comments, so `comments list` reads them from the REST API (GET /v1/files/:key/comments) with a personal access token, then asks the plugin to resolve each pin: the REST pin is usually the outermost frame plus an offset, so the offset is walked down to the deepest visible layer under it and shown as "screen › layer". - `comments list [--file ] [--node ] [--all] [--json]` - `comments token set|clear|status`: the token comes from FIGMA_ACCESS_TOKEN / FIGMA_TOKEN or the OS credential store, and is never printed. - plugin manifest: `enablePrivatePluginApi`, so `figma.fileKey` is readable and --file is only needed when it is not. Works in any connection mode; without a connection it still lists the threads (node ids instead of layer names) when --file is given. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++ SECURITY.md | 2 +- plugin/manifest.json | 1 + src/commands/comments.js | 146 +++++++++++++++++++++++++++++++++++++++ src/lib/command-map.js | 3 +- src/lib/comments.js | 82 ++++++++++++++++++++++ tests/comments.test.js | 61 ++++++++++++++++ 7 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 src/commands/comments.js create mode 100644 src/lib/comments.js create mode 100644 tests/comments.test.js diff --git a/README.md b/README.md index b309d484..46c7c0b3 100644 --- a/README.md +++ b/README.md @@ -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 --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. diff --git a/SECURITY.md b/SECURITY.md index 305fe22c..62ab365d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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: `` 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 diff --git a/plugin/manifest.json b/plugin/manifest.json index f46e3765..72ef0b7e 100644 --- a/plugin/manifest.json +++ b/plugin/manifest.json @@ -7,6 +7,7 @@ "capabilities": [], "permissions": ["teamlibrary"], "enableProposedApi": false, + "enablePrivatePluginApi": true, "editorType": ["figma", "figjam"], "documentAccess": "dynamic-page", "networkAccess": { diff --git a/src/commands/comments.js b/src/commands/comments.js new file mode 100644 index 00000000..6b56ef4a --- /dev/null +++ b/src/commands/comments.js @@ -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 ."); + 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 ', 'File key or figma.com URL (default: the file open in Figma)') + .option('-n, --node ', '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.')); + }); diff --git a/src/lib/command-map.js b/src/lib/command-map.js index 34e08270..76825236 100644 --- a/src/lib/command-map.js +++ b/src/lib/command-map.js @@ -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', ]; /** @@ -112,4 +112,5 @@ export const COMMAND_MODULES = { instantiate: ['instantiate'], 'init-agent': ['init'], motion: ['motion'], + comments: ['comments'], }; diff --git a/src/lib/comments.js b/src/lib/comments.js new file mode 100644 index 00000000..a5c46162 --- /dev/null +++ b/src/lib/comments.js @@ -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}//… 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; + })()`; +} diff --git a/tests/comments.test.js b/tests/comments.test.js new file mode 100644 index 00000000..b77b6050 --- /dev/null +++ b/tests/comments.test.js @@ -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"')); + }); +});