#!/usr/bin/env node /** * Figma Tools CLI */ import { FigmaClient, parseFigmaUrl, downloadImage } from './figma-client.ts'; import { simplifyDesign } from './simplify.ts'; import type { FigmaNode, FigmaFileResponse, FigmaNodeResponse } from './types.ts'; const API_KEY = process.env.FIGMA_API_KEY; function showHelp(): void { console.log(` Figma Tools - Fetch and parse Figma design files Usage: figma-tools get [options] Fetch and simplify Figma file/node figma-tools download Download images/assets figma-tools parse [options] Parse local JSON file Commands: get Fetch a Figma file or specific node. Outputs simplified JSON. Options: --depth N Limit tree depth (1-4) --raw Output raw API response --yaml Output as YAML Examples: figma-tools get https://www.figma.com/file/ABC123/My-Design figma-tools get "https://www.figma.com/file/ABC123/My-Design?node-id=123%3A456" --depth 2 figma-tools get https://www.figma.com/file/ABC123/My-Design --raw > raw.json download Download images and assets from a Figma file. Options: --scale N PNG scale factor (1-4, default: 1) --format fmt Image format: png, svg, or both (default: png) Examples: figma-tools download https://www.figma.com/file/ABC123/My-Design ./assets figma-tools download https://www.figma.com/file/ABC123/My-Design ./assets --scale 2 parse Simplify a raw Figma API JSON file. Options: --yaml Output as YAML Examples: figma-tools parse ./raw-figma-response.json figma-tools parse ./raw.json --yaml > design.yaml Setup: export FIGMA_API_KEY="your-token-here" Get a token from Figma Settings → Personal Access Tokens `); } async function output(data: unknown, useYaml: boolean): Promise { if (useYaml) { const yaml = await import('js-yaml'); console.log(yaml.default.dump(data, { indent: 2, lineWidth: 120 })); } else { console.log(JSON.stringify(data, null, 2)); } } async function getCommand(args: string[]): Promise { if (!API_KEY) { console.error('Error: FIGMA_API_KEY environment variable required'); console.error('Get a token from: https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens'); process.exit(1); } const url = args.find((a) => !a.startsWith('--')); if (!url) { console.error('Error: No URL provided'); console.error('Usage: figma-tools get [--depth N] [--raw] [--yaml]'); process.exit(1); } const depthArg = args.find((a, i) => a === '--depth' && args[i + 1]); const depth = depthArg ? parseInt(args[args.indexOf(depthArg) + 1]) : null; const raw = args.includes('--raw'); const yaml = args.includes('--yaml'); const parsed = parseFigmaUrl(url); if (!parsed) { console.error('Error: Invalid Figma URL'); console.error('Expected: https://www.figma.com/file/{fileKey}/{title} or with ?node-id='); process.exit(1); } const client = new FigmaClient(API_KEY); let data: FigmaFileResponse | FigmaNodeResponse; if (parsed.nodeId) { console.error(`Fetching node ${parsed.nodeId} from file ${parsed.fileKey}...`); data = await client.getNode(parsed.fileKey, parsed.nodeId, depth); } else { console.error(`Fetching file ${parsed.fileKey}...`); data = await client.getFile(parsed.fileKey, depth); } if (raw) { await output(data, yaml); } else { console.error('Simplifying response...'); const simplified = simplifyDesign(data); await output(simplified, yaml); } } async function downloadCommand(args: string[]): Promise { if (!API_KEY) { console.error('Error: FIGMA_API_KEY environment variable required'); process.exit(1); } const url = args.find((a) => !a.startsWith('--')); const outputDir = args.filter((a) => !a.startsWith('--'))[1]; if (!url || !outputDir) { console.error('Error: URL and output directory required'); console.error('Usage: figma-tools download [--scale N] [--format png|svg]'); process.exit(1); } const scaleArg = args.find((a, i) => a === '--scale' && args[i + 1]); const scale = scaleArg ? parseInt(args[args.indexOf(scaleArg) + 1]) : 1; const formatArg = args.find((a, i) => a === '--format' && args[i + 1]); const format = formatArg ? args[args.indexOf(formatArg) + 1] : 'png'; const parsed = parseFigmaUrl(url); if (!parsed) { console.error('Error: Invalid Figma URL'); process.exit(1); } const client = new FigmaClient(API_KEY); console.error('Fetching file structure...'); const fileData = await client.getFile(parsed.fileKey, 3); const imageNodes: Array<{ id: string; name: string; imageRef?: string }> = []; const svgNodes: Array<{ id: string; name: string }> = []; function sanitizeFilename(name: string): string { return name .replace(/[^a-zA-Z0-9\-_./\s]/g, '') .replace(/\s+/g, '_') .substring(0, 100); } function collectNodes(nodes: FigmaNode[], parentPath = ''): void { for (const node of nodes) { const currentPath = parentPath ? `${parentPath}/${node.name}` : node.name; if (node.fills) { const fills = Array.isArray(node.fills) ? node.fills : [node.fills]; for (const fill of fills) { if (fill?.type === 'IMAGE' && fill.imageRef) { imageNodes.push({ id: node.id, name: sanitizeFilename(currentPath) + '.png', imageRef: fill.imageRef, }); } } } if ( ['VECTOR', 'BOOLEAN_OPERATION', 'STAR', 'POLYGON', 'LINE', 'ELLIPSE', 'RECTANGLE'].includes( node.type ) ) { svgNodes.push({ id: node.id, name: sanitizeFilename(currentPath) + '.svg', }); } if (node.children) { collectNodes(node.children, currentPath); } } } let nodesToProcess: FigmaNode[]; if (parsed.nodeId) { // fileData is FigmaNodeResponse when we requested a specific node const nodeResponse = fileData as unknown as { nodes?: Record }; nodesToProcess = nodeResponse.nodes?.[parsed.nodeId]?.document?.children || []; } else { nodesToProcess = fileData.document?.children || []; } collectNodes(nodesToProcess); console.error(`Found ${imageNodes.length} image fills, ${svgNodes.length} exportable nodes`); const results: string[] = []; if ((format === 'png' || format === 'both') && (imageNodes.length > 0 || svgNodes.length > 0)) { const pngIds = [ ...imageNodes.map((n) => n.id), ...svgNodes.map((n) => n.id), ]; console.error(`Downloading ${pngIds.length} PNGs at ${scale}x...`); const images = await client.getImages(parsed.fileKey, pngIds, 'png', scale); for (const [nodeId, imageUrl] of Object.entries(images)) { if (imageUrl) { const node = [...imageNodes, ...svgNodes].find((n) => n.id === nodeId); if (node) { const fileName = node.name.endsWith('.png') ? node.name : `${node.name}.png`; const path = await downloadImage(imageUrl, outputDir, fileName); results.push(path); } } } } if ((format === 'svg' || format === 'both') && svgNodes.length > 0) { console.error(`Downloading ${svgNodes.length} SVGs...`); const images = await client.getImages( parsed.fileKey, svgNodes.map((n) => n.id), 'svg' ); for (const [nodeId, imageUrl] of Object.entries(images)) { if (imageUrl) { const node = svgNodes.find((n) => n.id === nodeId); if (node) { const path = await downloadImage(imageUrl, outputDir, node.name); results.push(path); } } } } console.error(`\nDownloaded ${results.length} files to: ${outputDir}`); for (const path of results) { console.log(path); } } async function parseCommand(args: string[]): Promise { const fs = await import('fs'); const filePath = args.find((a) => !a.startsWith('--')); if (!filePath) { console.error('Error: No file path provided'); console.error('Usage: figma-tools parse [--yaml]'); process.exit(1); } const yaml = args.includes('--yaml'); if (!fs.existsSync(filePath)) { console.error(`Error: File not found: ${filePath}`); process.exit(1); } const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')); console.error('Simplifying...'); const simplified = simplifyDesign(raw); await output(simplified, yaml); } async function main(): Promise { const args = process.argv.slice(2); const command = args[0]; if (!command || command === '--help' || command === '-h') { showHelp(); process.exit(0); } const commandArgs = args.slice(1); try { switch (command) { case 'get': await getCommand(commandArgs); break; case 'download': await downloadCommand(commandArgs); break; case 'parse': await parseCommand(commandArgs); break; default: console.error(`Unknown command: ${command}`); showHelp(); process.exit(1); } } catch (err) { console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } } main();