diff --git a/CLAUDE.md b/CLAUDE.md index 6a54fd36..76a722e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` only to pick a custom path; avoid `--base64` unless a script needs the inline data. @@ -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. diff --git a/src/commands/docs.js b/src/commands/docs.js new file mode 100644 index 00000000..cbeb3b5f --- /dev/null +++ b/src/commands/docs.js @@ -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 ', '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 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); + }); diff --git a/src/lib/command-map.js b/src/lib/command-map.js index 34e08270..c37e6adf 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', 'docs', ]; /** @@ -112,4 +112,5 @@ export const COMMAND_MODULES = { instantiate: ['instantiate'], 'init-agent': ['init'], motion: ['motion'], + docs: ['docs'], }; diff --git a/src/lib/doc-sections.js b/src/lib/doc-sections.js new file mode 100644 index 00000000..27999336 --- /dev/null +++ b/src/lib/doc-sections.js @@ -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 []; +} diff --git a/tests/doc-sections.test.js b/tests/doc-sections.test.js new file mode 100644 index 00000000..78ddcd6a --- /dev/null +++ b/tests/doc-sections.test.js @@ -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 . + +### 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)); +});