Skip to content
Open
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
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ Big tool output accumulates in the conversation; when context fills, Claude Code
compacts it and DETAILS get lost (exact node IDs, values, what was tried), which
shows up as confidently-wrong recall ("hallucinated" IDs). Keep context lean:

- **Pull ONE section of this guide instead of reading all of it.** The whole file is
~10,200 tokens; `figma-cli docs jsx-syntax` is ~930. `figma-cli docs` lists the
topics with their token cost. Read the file whole only when you truly need it all.
- **`verify` saves the PNG to disk by default** and returns just dimensions, instead
of dumping a base64 image (thousands of tokens) into context. Use `--save <path>`
only to pick a custom path; avoid `--base64` unless a script needs the inline data.
Expand Down Expand Up @@ -752,6 +755,8 @@ figma-cli recreate-url "URL" # Recreate webpage in Figma
figma-cli screenshot-url "URL" # Screenshot webpage
figma-cli daemon status # Check daemon
figma-cli daemon restart # Restart daemon
figma-cli docs # List this guide's topics + token cost
figma-cli docs jsx-syntax # Print ONE section (~930 tok, not ~10,200)
```

For eval patterns, layout examples, and Safe Mode templates, see REFERENCE.md.
67 changes: 67 additions & 0 deletions src/commands/docs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Commands: docs — read the usage guide one section at a time.
import chalk from 'chalk';
import { existsSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { program } from '../lib/cli-core.js';
import { parseSections, matchSections } from '../lib/doc-sections.js';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');

// The guide normally sits at the repo root as CLAUDE.md. A checkout that wants
// a short CLAUDE.md for repo instructions moves the guide to
// docs/FIGMA-USAGE.md — that one is checked FIRST, because where it exists it
// is the guide and the root CLAUDE.md is something else.
const GUIDE_CANDIDATES = ['docs/FIGMA-USAGE.md', 'CLAUDE.md'];

function loadGuide(explicitPath) {
const candidates = explicitPath ? [explicitPath] : GUIDE_CANDIDATES;
for (const rel of candidates) {
const path = rel.startsWith('/') ? rel : join(ROOT, rel);
if (existsSync(path)) return { path, rel, markdown: readFileSync(path, 'utf8') };
}
return null;
}

program
.command('docs [topic]')
.description('Print ONE section of the usage guide (no topic = list them). Cheaper than reading the whole guide.')
.option('-f, --file <path>', 'read a different markdown file')
.action((topic, options) => {
const guide = loadGuide(options.file);
if (!guide) {
console.error(chalk.red(`Usage guide not found (looked for ${(options.file ? [options.file] : GUIDE_CANDIDATES).join(', ')})`));
process.exit(1);
}

const sections = parseSections(guide.markdown);

if (!topic) {
const total = sections.reduce((sum, s) => sum + s.tokens, 0);
console.log(chalk.bold(`\n ${guide.rel} — ${sections.length} topics, ~${total} tokens in full\n`));
for (const s of sections) {
console.log(` ${chalk.cyan(s.slug.padEnd(28))} ${chalk.gray(String(s.tokens).padStart(5) + ' tok')} ${s.heading}`);
}
console.log(chalk.gray(`\n figma-cli docs <topic> prints one section instead of all ~${total} tokens\n`));
return;
}

const hits = matchSections(sections, topic);

if (hits.length === 0) {
console.error(chalk.yellow(`No topic matches "${topic}".`));
console.error(chalk.gray(' Available: ') + sections.map((s) => s.slug).join(', '));
process.exit(1);
}

// Several matches: name them instead of guessing which one was meant.
if (hits.length > 1) {
console.error(chalk.yellow(`"${topic}" matches ${hits.length} topics — pick one:`));
for (const s of hits) {
console.error(` ${chalk.cyan(s.slug.padEnd(28))} ${chalk.gray(s.tokens + ' tok')} ${s.heading}`);
}
process.exit(1);
}

process.stdout.write(hits[0].body);
});
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', 'docs',
];

/**
Expand Down Expand Up @@ -112,4 +112,5 @@ export const COMMAND_MODULES = {
instantiate: ['instantiate'],
'init-agent': ['init'],
motion: ['motion'],
docs: ['docs'],
};
89 changes: 89 additions & 0 deletions src/lib/doc-sections.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Reading ONE section out of the usage guide instead of the whole file.
*
* The guide is ~10k tokens. An agent told to "read the docs first" pays that
* on every session, even when the task needs one 900-token section. Splitting
* it by `##` heading lets a caller pull just what it needs.
*
* Pure string work, so the matching is unit-tested without touching disk.
*/

/** Heading text → a stable slug: lowercase words, everything else a dash. */
export function slugify(heading) {
return heading
.toLowerCase()
.replace(/\([^)]*\)/g, ' ') // drop parenthetical asides
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

/**
* Split a markdown document at its `##` headings.
*
* `###` and deeper stay inside their parent section — they are subsections of
* the topic, not topics of their own. Text before the first `##` (the title) is
* dropped: it carries no topic.
*
* @returns {{heading: string, slug: string, body: string, tokens: number}[]}
*/
export function parseSections(markdown) {
const lines = markdown.split('\n');
const sections = [];
let current = null;

for (const line of lines) {
const isTopLevel = /^##\s+/.test(line) && !/^###/.test(line);
if (isTopLevel) {
if (current) sections.push(current);
const heading = line.replace(/^##\s+/, '').trim();
current = { heading, slug: slugify(heading), body: line + '\n' };
continue;
}
if (current) current.body += line + '\n';
}
if (current) sections.push(current);

// ≈ tokens, for the listing. Bytes/4 is the usual rough rule; it only has to
// be good enough to tell a 900-token section from a 2000-token one.
for (const s of sections) {
s.body = s.body.replace(/\n+$/, '\n');
s.tokens = Math.round(s.body.length / 4);
}
return sections;
}

/**
* Find the sections a query asks for.
*
* Ranked, because a query should not have to be exact:
* 1. slug is exactly the query "jsx-syntax" → JSX Syntax
* 2. a slug word is exactly the query "jsx" → JSX Syntax
* 3. slug contains the query "pitfall" → Critical Pitfalls
* 4. heading contains the query "render" → JSX Syntax (render command)
*
* Returns every section at the best tier that matched, so an ambiguous query
* ("token" → Design Tokens + Token Hygiene) can be reported rather than
* silently resolved to whichever came first.
*/
export function matchSections(sections, query) {
const q = slugify(String(query || ''));
if (!q) return [];

// "token" should find "Design Tokens" as readily as "Token Hygiene", so a
// trailing plural s is ignored on both sides of a word comparison.
const singular = (w) => w.replace(/s$/, '');
const qs = singular(q);

const tiers = [
(s) => s.slug === q,
(s) => s.slug.split('-').some((w) => singular(w) === qs),
(s) => s.slug.includes(q),
(s) => slugify(s.heading).includes(q) || s.heading.toLowerCase().includes(query.toLowerCase()),
];

for (const test of tiers) {
const hits = sections.filter(test);
if (hits.length) return hits;
}
return [];
}
94 changes: 94 additions & 0 deletions tests/doc-sections.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Unit tests for the usage-guide section splitter (pure, no disk access).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseSections, matchSections, slugify } from '../src/lib/doc-sections.js';

const DOC = `# figma-ds-cli

Intro text that belongs to no topic.

## Quick Reference

| a | b |

## JSX Syntax (render command)

Use <Frame>.

### Nested heading stays here

More JSX detail.

## Critical Pitfalls

Don't do that.

## Design Tokens

tokens

## Token Hygiene (keep context lean)

hygiene
`;

test('splits on ## and keeps ### inside its parent section', () => {
const sections = parseSections(DOC);
assert.deepEqual(sections.map((s) => s.heading), [
'Quick Reference',
'JSX Syntax (render command)',
'Critical Pitfalls',
'Design Tokens',
'Token Hygiene (keep context lean)',
]);
const jsx = sections.find((s) => s.slug === 'jsx-syntax');
assert.ok(jsx.body.includes('### Nested heading stays here'));
assert.ok(jsx.body.includes('More JSX detail.'));
});

test('text before the first ## is dropped', () => {
const joined = parseSections(DOC).map((s) => s.body).join('');
assert.ok(!joined.includes('Intro text that belongs to no topic.'));
});

test('slugify drops parenthetical asides', () => {
assert.equal(slugify('JSX Syntax (render command)'), 'jsx-syntax');
assert.equal(slugify('Motion (Figma Animation, Config 2026 Beta)'), 'motion');
});

test('a single word matches the section it names', () => {
const hits = matchSections(parseSections(DOC), 'jsx');
assert.equal(hits.length, 1);
assert.equal(hits[0].heading, 'JSX Syntax (render command)');
});

test('a partial word still matches', () => {
const hits = matchSections(parseSections(DOC), 'pitfall');
assert.equal(hits.length, 1);
assert.equal(hits[0].heading, 'Critical Pitfalls');
});

test('an ambiguous query returns every candidate rather than guessing', () => {
const hits = matchSections(parseSections(DOC), 'token');
assert.deepEqual(hits.map((s) => s.heading), ['Design Tokens', 'Token Hygiene (keep context lean)']);
});

test('an exact slug beats the sections that merely contain it', () => {
const hits = matchSections(parseSections(DOC), 'design-tokens');
assert.equal(hits.length, 1);
assert.equal(hits[0].heading, 'Design Tokens');
});

test('an unknown query matches nothing', () => {
assert.deepEqual(matchSections(parseSections(DOC), 'kubernetes'), []);
});

test('an empty query matches nothing', () => {
assert.deepEqual(matchSections(parseSections(DOC), ''), []);
});

test('each section carries a token estimate', () => {
const quick = parseSections(DOC).find((s) => s.slug === 'quick-reference');
assert.ok(quick.tokens > 0);
assert.equal(quick.tokens, Math.round(quick.body.length / 4));
});