diff --git a/CHANGELOG.md b/CHANGELOG.md index 4acb64e20..b4fa85736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - Add overlap analysis exports for automation consumers, including `computeOverlaps`, `analyzeOverlaps`, overlap result types, and parameter parsers from core subpath exports. - Add world-matrix visual bounds to overlap analysis, covering vector/stroke/text geometry, ancestor clipping, rotated clipping frames, and nested ancestor rotations. - Add the `@open-pencil/core/package.json` subpath export for package metadata consumers. +- Add explicit document/page targeting for live MCP and CLI automation, including `list_documents` / `openpencil documents` discovery and `document_id` / `page_id` target fields. ### Fixes @@ -44,7 +45,7 @@ - Fix stale variable bindings not cleaned up when fills/strokes arrays shrink — any indexed sub-path is now handled, not just `/color`. - Fix desktop "Share This File" links to use the public `https://app.openpencil.dev/share/{roomId}` URL instead of the internal `tauri://localhost` app scheme. - Fix tooltips around inspector dropdowns/popovers without breaking floating menu anchoring. -- Harden MCP calls with bounded page-tree responses, oversized-result errors, JSON HTTP responses, and stale WebSocket cleanup. +- Harden MCP calls with bounded page-tree responses, oversized-result errors, JSON HTTP responses, stale WebSocket cleanup, and live document/page target resolution to avoid active-tab drift. - Improve Figma boolean imports by preserving XOR operations as editable exclude nodes and falling back to imported fill geometry when boolean path reconstruction cannot produce a path. - Preserve rotated Figma transform origins for imported vector nodes. - Render complex text fills through vector glyph outlines so imported Figma text can use the normal fill pipeline for gradients, images, patterns, and other non-solid paints. diff --git a/packages/cli/src/app-target.ts b/packages/cli/src/app-target.ts new file mode 100644 index 000000000..ed17ed1da --- /dev/null +++ b/packages/cli/src/app-target.ts @@ -0,0 +1,27 @@ +export type AppTargetCliArgs = { + 'document-id'?: string + 'page-id'?: string +} + +export const appTargetOptions = { + 'document-id': { + type: 'string', + description: 'Target OpenPencil document/tab ID when connected to the running app', + required: false + }, + 'page-id': { + type: 'string', + description: 'Target page ID when connected to the running app', + required: false + } +} as const + +export function appTargetRpcArgs(args: AppTargetCliArgs): { + document_id?: string + page_id?: string +} { + return { + ...(args['document-id'] ? { document_id: args['document-id'] } : {}), + ...(args['page-id'] ? { page_id: args['page-id'] } : {}) + } +} diff --git a/packages/cli/src/commands/analyze/clusters.ts b/packages/cli/src/commands/analyze/clusters.ts index 68b971af6..39c4604f5 100644 --- a/packages/cli/src/commands/analyze/clusters.ts +++ b/packages/cli/src/commands/analyze/clusters.ts @@ -3,6 +3,7 @@ import { defineCommand } from 'citty' import type { AnalyzeClustersResult } from '@open-pencil/core/rpc' import { calcClusterConfidence } from '@open-pencil/core/tools' +import { appTargetOptions } from '#cli/app-target' import { bold, fmtList, fmtSummary } from '#cli/format' import { loadRpcData } from '#cli/rpc-data' @@ -34,14 +35,20 @@ export default defineCommand({ limit: { type: 'string', description: 'Max clusters to show', default: '20' }, 'min-size': { type: 'string', description: 'Min node size in px', default: '30' }, 'min-count': { type: 'string', description: 'Min instances to form cluster', default: '2' }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { - const data = await loadRpcData(args.file, 'analyze_clusters', { - limit: Number(args.limit), - minSize: Number(args['min-size']), - minCount: Number(args['min-count']) - }) + const data = await loadRpcData( + args.file, + 'analyze_clusters', + { + limit: Number(args.limit), + minSize: Number(args['min-size']), + minCount: Number(args['min-count']) + }, + args + ) if (args.json) { console.log(JSON.stringify(data, null, 2)) diff --git a/packages/cli/src/commands/analyze/colors.ts b/packages/cli/src/commands/analyze/colors.ts index 7ce206bfc..43f2589a5 100644 --- a/packages/cli/src/commands/analyze/colors.ts +++ b/packages/cli/src/commands/analyze/colors.ts @@ -2,6 +2,7 @@ import { defineCommand } from 'citty' import type { AnalyzeColorsResult } from '@open-pencil/core/rpc' +import { appTargetOptions } from '#cli/app-target' import { bold, fmtHistogram, fmtList, fmtSummary } from '#cli/format' import { loadRpcData } from '#cli/rpc-data' @@ -20,13 +21,19 @@ export default defineCommand({ default: '15' }, similar: { type: 'boolean', description: 'Show similar color clusters' }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { - const data = await loadRpcData(args.file, 'analyze_colors', { - threshold: Number(args.threshold), - similar: args.similar - }) + const data = await loadRpcData( + args.file, + 'analyze_colors', + { + threshold: Number(args.threshold), + similar: args.similar + }, + args + ) const limit = Number(args.limit) if (args.json) { diff --git a/packages/cli/src/commands/analyze/overlaps.ts b/packages/cli/src/commands/analyze/overlaps.ts index 6f5ed9c00..e9726e839 100644 --- a/packages/cli/src/commands/analyze/overlaps.ts +++ b/packages/cli/src/commands/analyze/overlaps.ts @@ -176,6 +176,11 @@ export default defineCommand({ type: 'string', description: 'Limit analysis to nodes on the page with this stable ID' }, + 'document-id': { + type: 'string', + description: 'Target OpenPencil document/tab ID when connected to the running app', + required: false + }, type: { type: 'string', description: 'Comma-separated node types to analyze, e.g. FRAME,TEXT' @@ -194,7 +199,12 @@ export default defineCommand({ process.exit(1) } - const data = await loadRpcData(args.file, 'analyze_overlaps', rpcArgs) + const data = await loadRpcData( + args.file, + 'analyze_overlaps', + rpcArgs, + args + ) if (args.json) { console.log(JSON.stringify(data, null, 2)) diff --git a/packages/cli/src/commands/analyze/spacing.ts b/packages/cli/src/commands/analyze/spacing.ts index ba3c17c71..a36ba8822 100644 --- a/packages/cli/src/commands/analyze/spacing.ts +++ b/packages/cli/src/commands/analyze/spacing.ts @@ -2,6 +2,7 @@ import { defineCommand } from 'citty' import type { AnalyzeSpacingResult } from '@open-pencil/core/rpc' +import { appTargetOptions } from '#cli/app-target' import { bold, kv, fmtHistogram, fmtSummary } from '#cli/format' import { loadRpcData } from '#cli/rpc-data' @@ -14,10 +15,16 @@ export default defineCommand({ required: false }, grid: { type: 'string', description: 'Base grid size to check against', default: '8' }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { - const data = await loadRpcData(args.file, 'analyze_spacing') + const data = await loadRpcData( + args.file, + 'analyze_spacing', + undefined, + args + ) const gridSize = Number(args.grid) if (args.json) { diff --git a/packages/cli/src/commands/analyze/typography.ts b/packages/cli/src/commands/analyze/typography.ts index ed8d90f26..e7d6374d5 100644 --- a/packages/cli/src/commands/analyze/typography.ts +++ b/packages/cli/src/commands/analyze/typography.ts @@ -2,6 +2,7 @@ import { defineCommand } from 'citty' import type { AnalyzeTypographyResult } from '@open-pencil/core/rpc' +import { appTargetOptions } from '#cli/app-target' import { bold, fmtHistogram, fmtSummary } from '#cli/format' import { loadRpcData } from '#cli/rpc-data' @@ -30,10 +31,16 @@ export default defineCommand({ description: 'Group by: family, size, weight (default: show all styles)' }, limit: { type: 'string', description: 'Max styles to show', default: '30' }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { - const data = await loadRpcData(args.file, 'analyze_typography', {}) + const data = await loadRpcData( + args.file, + 'analyze_typography', + {}, + args + ) const limit = Number(args.limit) const groupBy = args['group-by'] diff --git a/packages/cli/src/commands/documents.ts b/packages/cli/src/commands/documents.ts new file mode 100644 index 000000000..189f5ca0a --- /dev/null +++ b/packages/cli/src/commands/documents.ts @@ -0,0 +1,47 @@ +import { defineCommand } from 'citty' + +import type { AutomationDocumentSummary } from '@open-pencil/core/rpc' + +import { rpc } from '#cli/app-client' +import { bold, entity, fmtList, kv, printError } from '#cli/format' + +export default defineCommand({ + meta: { description: 'List open documents in the running app' }, + args: { + json: { type: 'boolean', description: 'Output as JSON' } + }, + async run({ args }) { + try { + const data = await rpc<{ documents: AutomationDocumentSummary[] }>('list_documents') + const documents = data.documents + + if (args.json) { + console.log(JSON.stringify(documents, null, 2)) + return + } + + console.log('') + console.log(bold(` ${documents.length} open document${documents.length !== 1 ? 's' : ''}`)) + console.log('') + console.log( + fmtList( + documents.map((doc) => ({ + header: `${entity('document', doc.name, doc.id)}${doc.active ? ' [active]' : ''}`, + details: { + ...(doc.path ? { path: doc.path } : {}), + current: `${doc.current_page_name} (${doc.current_page_id})`, + pages: doc.pages.map((page) => `${page.name} (${page.id})`).join(', ') + } + })), + { compact: true } + ) + ) + console.log('') + console.log(kv('target flags', '--document-id --page-id ')) + console.log('') + } catch (error) { + printError(error) + process.exit(1) + } + } +}) diff --git a/packages/cli/src/commands/eval.ts b/packages/cli/src/commands/eval.ts index 32fa55311..153ffecd0 100644 --- a/packages/cli/src/commands/eval.ts +++ b/packages/cli/src/commands/eval.ts @@ -5,6 +5,7 @@ import { defineCommand } from 'citty' import { FigmaAPI } from '@open-pencil/core/figma-api' import { isAppMode, requireFile, rpc } from '#cli/app-client' +import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target' import { printError } from '#cli/format' import { loadDocument } from '#cli/headless' @@ -42,6 +43,7 @@ export default defineCommand({ description: 'Write to a different file', required: false }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' }, quiet: { type: 'boolean', alias: 'q', description: 'Suppress output' } }, @@ -60,7 +62,7 @@ export default defineCommand({ } if (isAppMode(args.file)) { - const result = await rpc('eval', { code }) + const result = await rpc('eval', { code, ...appTargetRpcArgs(args) }) if (!args.quiet && result !== undefined && result !== null) { printResult(result, !!args.json) } diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 7ed1a0c16..913134838 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -7,6 +7,7 @@ import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io' import type { RasterExportFormat } from '@open-pencil/core/io' import { isAppMode, requireFile, rpc } from '#cli/app-client' +import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target' import { ok, printError } from '#cli/format' import { loadDocument } from '#cli/headless' @@ -27,6 +28,8 @@ interface ExportArgs { thumbnail?: boolean width: string height: string + 'document-id'?: string + 'page-id'?: string } async function writeAndLog(path: string, content: string | Uint8Array) { @@ -36,8 +39,10 @@ async function writeAndLog(path: string, content: string | Uint8Array) { } async function exportViaApp(format: string, args: ExportArgs) { + const targetArgs = appTargetRpcArgs(args) if (format === 'SVG') { const result = await rpc<{ svg: string }>('tool', { + ...targetArgs, name: 'export_svg', args: { ids: args.node ? [args.node] : undefined } }) @@ -51,6 +56,7 @@ async function exportViaApp(format: string, args: ExportArgs) { if (format === 'PDF') { const result = await rpc<{ base64: string }>('tool', { + ...targetArgs, name: 'export_pdf', args: { ids: args.node ? [args.node] : undefined } }) @@ -69,6 +75,7 @@ async function exportViaApp(format: string, args: ExportArgs) { } const result = await rpc<{ base64: string }>('export', { + ...targetArgs, nodeIds: args.node ? [args.node] : undefined, scale: Number(args.scale), format: format.toLowerCase() @@ -192,7 +199,8 @@ export default defineCommand({ }, thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' }, width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' }, - height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' } + height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' }, + ...appTargetOptions }, async run({ args }) { const format = args.format.toUpperCase() as RasterExportFormat | 'SVG' | 'JSX' | 'FIG' diff --git a/packages/cli/src/commands/find.ts b/packages/cli/src/commands/find.ts index 97224185d..1e9a12fa2 100644 --- a/packages/cli/src/commands/find.ts +++ b/packages/cli/src/commands/find.ts @@ -2,6 +2,7 @@ import { defineCommand } from 'citty' import type { FindNodeResult } from '@open-pencil/core/rpc' +import { appTargetOptions } from '#cli/app-target' import { printNodeResults } from '#cli/format' import { loadRpcData } from '#cli/rpc-data' @@ -17,15 +18,21 @@ export default defineCommand({ type: { type: 'string', description: 'Node type: FRAME, TEXT, RECTANGLE, INSTANCE, etc.' }, page: { type: 'string', description: 'Page name (default: all pages)' }, limit: { type: 'string', description: 'Max results (default: 100)', default: '100' }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { - const results = await loadRpcData(args.file, 'find', { - name: args.name, - type: args.type, - page: args.page, - limit: args.limit ? Number(args.limit) : undefined - }) + const results = await loadRpcData( + args.file, + 'find', + { + name: args.name, + type: args.type, + page: args.page, + limit: args.limit ? Number(args.limit) : undefined + }, + args + ) if (args.json) { console.log(JSON.stringify(results, null, 2)) diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 559b8be38..f5473754c 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -2,6 +2,7 @@ import { defineCommand } from 'citty' import type { InfoResult } from '@open-pencil/core/rpc' +import { appTargetOptions } from '#cli/app-target' import { bold, fmtHistogram, fmtSummary, kv } from '#cli/format' import { loadRpcData } from '#cli/rpc-data' @@ -13,10 +14,11 @@ export default defineCommand({ description: 'Document file path (omit to connect to running app)', required: false }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { - const data = await loadRpcData(args.file, 'info') + const data = await loadRpcData(args.file, 'info', undefined, args) if (args.json) { console.log(JSON.stringify(data, null, 2)) diff --git a/packages/cli/src/commands/node.ts b/packages/cli/src/commands/node.ts index 422e25b05..bb474699f 100644 --- a/packages/cli/src/commands/node.ts +++ b/packages/cli/src/commands/node.ts @@ -4,6 +4,7 @@ import { colorToHex } from '@open-pencil/core/color' import type { NodeResult } from '@open-pencil/core/rpc' import type { Color } from '@open-pencil/scene-graph/primitives' +import { appTargetOptions } from '#cli/app-target' import { fmtNode, printError, formatType } from '#cli/format' import { loadRpcData } from '#cli/rpc-data' @@ -16,12 +17,18 @@ export default defineCommand({ required: false }, id: { type: 'string', description: 'Node ID', required: true }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { - const data = await loadRpcData(args.file, 'node', { - id: args.id - }) + const data = await loadRpcData( + args.file, + 'node', + { + id: args.id + }, + args + ) if ('error' in data) { printError(data.error) diff --git a/packages/cli/src/commands/pages.ts b/packages/cli/src/commands/pages.ts index 9ea70394b..31bfc2b22 100644 --- a/packages/cli/src/commands/pages.ts +++ b/packages/cli/src/commands/pages.ts @@ -2,6 +2,7 @@ import { defineCommand } from 'citty' import type { PageItem } from '@open-pencil/core/rpc' +import { appTargetOptions } from '#cli/app-target' import { bold, fmtList, entity } from '#cli/format' import { loadRpcData } from '#cli/rpc-data' @@ -13,10 +14,11 @@ export default defineCommand({ description: 'Document file path (omit to connect to running app)', required: false }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { - const pages = await loadRpcData(args.file, 'pages') + const pages = await loadRpcData(args.file, 'pages', undefined, args) if (args.json) { console.log(JSON.stringify(pages, null, 2)) diff --git a/packages/cli/src/commands/query.ts b/packages/cli/src/commands/query.ts index bcf2ad653..8c94e18d7 100644 --- a/packages/cli/src/commands/query.ts +++ b/packages/cli/src/commands/query.ts @@ -2,6 +2,7 @@ import { defineCommand } from 'citty' import type { QueryNodeResult } from '@open-pencil/core/rpc' +import { appTargetOptions } from '#cli/app-target' import { printNodeResults, printError } from '#cli/format' import { loadRpcData } from '#cli/rpc-data' @@ -22,14 +23,20 @@ export default defineCommand({ }, page: { type: 'string', description: 'Page name (default: all pages)' }, limit: { type: 'string', description: 'Max results (default: 1000)', default: '1000' }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { - const results = await loadRpcData(args.file, 'query', { - selector: args.selector, - page: args.page, - limit: args.limit ? Number(args.limit) : undefined - }) + const results = await loadRpcData( + args.file, + 'query', + { + selector: args.selector, + page: args.page, + limit: args.limit ? Number(args.limit) : undefined + }, + args + ) if ('error' in results) { printError(results.error) diff --git a/packages/cli/src/commands/selection.ts b/packages/cli/src/commands/selection.ts index a6c345010..bccf2d21e 100644 --- a/packages/cli/src/commands/selection.ts +++ b/packages/cli/src/commands/selection.ts @@ -1,6 +1,7 @@ import { defineCommand } from 'citty' import { rpc } from '#cli/app-client' +import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target' import { bold, entity, fmtList, formatType, printError } from '#cli/format' interface SelectionNode { @@ -15,11 +16,12 @@ interface SelectionNode { export default defineCommand({ meta: { description: 'Get current selection from the running app' }, args: { + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { try { - const nodes = await rpc('selection') + const nodes = await rpc('selection', appTargetRpcArgs(args)) if (args.json) { console.log(JSON.stringify(nodes, null, 2)) diff --git a/packages/cli/src/commands/tree.ts b/packages/cli/src/commands/tree.ts index bf7813399..c5af64359 100644 --- a/packages/cli/src/commands/tree.ts +++ b/packages/cli/src/commands/tree.ts @@ -3,6 +3,7 @@ import { defineCommand } from 'citty' import type { TreeNodeResult, TreeResult } from '@open-pencil/core/rpc' +import { appTargetOptions } from '#cli/app-target' import { fmtTree, printError, entity, formatType } from '#cli/format' import { loadRpcData } from '#cli/rpc-data' @@ -26,13 +27,19 @@ export default defineCommand({ }, page: { type: 'string', description: 'Page name (default: first page)' }, depth: { type: 'string', description: 'Max depth (default: unlimited)' }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { - const data = await loadRpcData(args.file, 'tree', { - page: args.page, - depth: args.depth ? Number(args.depth) : undefined - }) + const data = await loadRpcData( + args.file, + 'tree', + { + page: args.page, + depth: args.depth ? Number(args.depth) : undefined + }, + args + ) const maxDepth = args.depth ? Number(args.depth) : Infinity if ('error' in data) { diff --git a/packages/cli/src/commands/variables.ts b/packages/cli/src/commands/variables.ts index d8b164432..d21b2054e 100644 --- a/packages/cli/src/commands/variables.ts +++ b/packages/cli/src/commands/variables.ts @@ -2,6 +2,7 @@ import { defineCommand } from 'citty' import type { VariablesResult } from '@open-pencil/core/rpc' +import { appTargetOptions } from '#cli/app-target' import { bold, entity, fmtList, fmtSummary } from '#cli/format' import { loadRpcData } from '#cli/rpc-data' @@ -15,13 +16,19 @@ export default defineCommand({ }, collection: { type: 'string', description: 'Filter by collection name' }, type: { type: 'string', description: 'Filter by type: COLOR, FLOAT, STRING, BOOLEAN' }, + ...appTargetOptions, json: { type: 'boolean', description: 'Output as JSON' } }, async run({ args }) { - const data = await loadRpcData(args.file, 'variables', { - collection: args.collection, - type: args.type - }) + const data = await loadRpcData( + args.file, + 'variables', + { + collection: args.collection, + type: args.type + }, + args + ) if (data.totalVariables === 0) { console.log('No variables found.') diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d57b14eff..0e90daf7a 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ import { defineCommand, runMain } from 'citty' import analyze from './commands/analyze' import convert from './commands/convert' +import documents from './commands/documents' import dom from './commands/dom' import evalCmd from './commands/eval' import exportCmd from './commands/export' @@ -28,6 +29,7 @@ const main = defineCommand({ subCommands: { analyze, convert, + documents, dom, eval: evalCmd, export: exportCmd, diff --git a/packages/cli/src/rpc-data.ts b/packages/cli/src/rpc-data.ts index f4dd510cd..ed4482b45 100644 --- a/packages/cli/src/rpc-data.ts +++ b/packages/cli/src/rpc-data.ts @@ -1,14 +1,27 @@ import { executeRpcCommand } from '@open-pencil/core/rpc' import { isAppMode, requireFile, rpc } from '#cli/app-client' +import { appTargetRpcArgs, type AppTargetCliArgs } from '#cli/app-target' import { loadDocument } from '#cli/headless' +type RpcArgs = { [key: string]: unknown } + +function isRpcArgs(value: unknown): value is RpcArgs { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + export async function loadRpcData( file: string | undefined, command: string, - args?: unknown + args?: unknown, + targetArgs?: AppTargetCliArgs ): Promise { - if (isAppMode(file)) return rpc(command, args) + if (isAppMode(file)) { + return rpc(command, { + ...(isRpcArgs(args) ? args : {}), + ...(targetArgs ? appTargetRpcArgs(targetArgs) : {}) + }) + } const graph = await loadDocument(requireFile(file)) return executeRpcCommand(graph, command, args) as Result } diff --git a/packages/core/src/rpc/commands.ts b/packages/core/src/rpc/commands.ts index 59a77d642..1ef8dcade 100644 --- a/packages/core/src/rpc/commands.ts +++ b/packages/core/src/rpc/commands.ts @@ -18,6 +18,16 @@ import { import type { RpcCommand } from './types' import { variablesCommand } from './variables-command' +export type AutomationDocumentSummary = { + id: string + name: string + path?: string + active: boolean + current_page_id: string + current_page_name: string + pages: Array<{ id: string; name: string }> +} + export type { RpcCommand } from './types' export * from './read-commands' export * from './variables-command' diff --git a/packages/core/src/rpc/index.ts b/packages/core/src/rpc/index.ts index ba8d0e7f2..081fb2440 100644 --- a/packages/core/src/rpc/index.ts +++ b/packages/core/src/rpc/index.ts @@ -23,5 +23,6 @@ export type { AnalyzeClustersResult, AnalyzeOverlapsArgs, AnalyzeOverlapsResult, - TypographyStyle + TypographyStyle, + AutomationDocumentSummary } from './commands' diff --git a/packages/docs/programmable/cli/inspecting.md b/packages/docs/programmable/cli/inspecting.md index df42920eb..12098a9a8 100644 --- a/packages/docs/programmable/cli/inspecting.md +++ b/packages/docs/programmable/cli/inspecting.md @@ -144,10 +144,14 @@ openpencil variables design.fig When the desktop app is running, omit the file argument — the CLI connects via RPC and operates on the live canvas: ```sh -openpencil tree # inspect the live document -openpencil eval -c "..." # query the editor +openpencil documents # list open document/page IDs +openpencil tree # inspect the active live document +openpencil tree --document-id tab-123 --page-id 0:1 +openpencil eval --document-id tab-123 --page-id 0:1 -c "..." ``` +Use `openpencil documents --json` in agent workflows, then pass `--document-id` and `--page-id` explicitly instead of relying on the visible active tab/page. + ## Lint Designs Check documents for naming, layout, structure, and accessibility issues: diff --git a/packages/docs/programmable/mcp-server.md b/packages/docs/programmable/mcp-server.md index 536e91bd6..7f26eb550 100644 --- a/packages/docs/programmable/mcp-server.md +++ b/packages/docs/programmable/mcp-server.md @@ -116,12 +116,15 @@ Server starts on port 7600 (override with `PORT` env var). Endpoints: ## Workflow -1. **Open** — `open_file` to load an existing `.fig`, or `new_document` for a blank canvas -2. **Read** — `get_page_tree`, `find_nodes`, `get_node`, `list_pages` -3. **Create** — `create_shape`, `render` (JSX) -4. **Modify** — `set_fill`, `set_stroke`, `set_layout`, `update_node`, `set_effects` -5. **Structure** — `reparent_node`, `group_nodes`, `clone_node`, `delete_node` -6. **Save** — `save_file` to write back to `.fig` +1. **Discover targets** — call `list_documents` first when more than one document or page may be open. It returns stable `document_id` and page IDs. +2. **Open** — `open_file` to load an existing `.fig`, or `new_document` for a blank canvas. These return target metadata for the opened or created document. +3. **Read** — `get_page_tree`, `find_nodes`, `get_node`, `list_pages` +4. **Create** — `create_shape`, `render` (JSX) +5. **Modify** — `set_fill`, `set_stroke`, `set_layout`, `update_node`, `set_effects` +6. **Structure** — `reparent_node`, `group_nodes`, `clone_node`, `delete_node` +7. **Save** — `save_file` to write back to `.fig` + +Most tools accept optional `document_id` and `page_id` fields. Pass them explicitly for agent workflows instead of relying on the visible active tab/page. `create_page` only creates a page; call `switch_page` separately when the workflow should change the active page. ## AI Agent Skill @@ -133,7 +136,7 @@ npx skills add open-pencil/skills@open-pencil Works with Claude Code, Cursor, Windsurf, Codex, and any agent that supports [skills](https://skills.sh). The skill covers the CLI, MCP tools, JSX rendering, eval, and the running app's automation bridge. -## Tools (90) +## Tools (91) ### Document @@ -142,6 +145,7 @@ Works with Claude Code, Cursor, Windsurf, Codex, and any agent that supports [sk | `open_file` | Open a `.fig` file for editing | | `save_file` | Save the current document to a `.fig` file | | `new_document` | Create a new empty document | +| `list_documents` | List open app documents/tabs and their pages | ### Read diff --git a/packages/mcp/src/tool/registration.ts b/packages/mcp/src/tool/registration.ts index 0702b2363..0c7f3091b 100644 --- a/packages/mcp/src/tool/registration.ts +++ b/packages/mcp/src/tool/registration.ts @@ -14,6 +14,25 @@ import { paramToZod } from './schema' export type RpcSender = (body: Record) => Promise +const automationTargetSchema = { + document_id: z.string().describe('Optional OpenPencil document/tab ID to target').optional(), + page_id: z.string().describe('Optional page ID to target within the document').optional() +} + +function splitAutomationTarget(args: Record): { + target: { document_id?: string; page_id?: string } + args: Record +} { + const { document_id, page_id, ...rest } = args + return { + target: { + ...(typeof document_id === 'string' ? { document_id } : {}), + ...(typeof page_id === 'string' ? { page_id } : {}) + }, + args: rest + } +} + export interface RegisterToolsOptions { enableEval: boolean mcpRoot?: string | null @@ -33,14 +52,21 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption } register( def.name, - { description: def.description, inputSchema: z.object(shape) }, + { + description: def.description, + inputSchema: z.object({ ...shape, ...automationTargetSchema }) + }, async (args: Record) => { try { - const result = await sendRpc({ command: 'tool', args: { name: def.name, args } }) + const { target, args: toolArgs } = splitAutomationTarget(args) + const result = await sendRpc({ + command: 'tool', + args: { ...target, name: def.name, args: toolArgs } + }) const res = result as { ok?: boolean; result?: unknown; error?: string } if (res.ok === false) return fail(new Error(res.error)) const r = res.result as RpcJsonObject | undefined - const filePath = typeof args.path === 'string' ? args.path : null + const filePath = typeof toolArgs.path === 'string' ? toolArgs.path : null if (r && filePath && resolvedRoot) { const written = await writeToolOutput(def.name, r, filePath, resolvedRoot) if (written) return written @@ -77,6 +103,25 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption ) } + register( + 'list_documents', + { + description: + 'List open OpenPencil documents/tabs with their IDs, file paths, current pages, and pages.', + inputSchema: z.object({}) + }, + async () => { + try { + const result = await sendRpc({ command: 'list_documents', args: {} }) + const res = result as { ok?: boolean; result?: unknown; error?: string } + if (res.ok === false) return fail(new Error(res.error)) + return ok(res.result ?? {}) + } catch (e) { + return fail(e) + } + } + ) + register( 'save_file', { @@ -85,18 +130,24 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption : 'Save the current document to disk. Uses the existing file path if available, otherwise prompts for a location.', inputSchema: resolvedRoot ? z.object({ - path: z.string().describe('Optional absolute path for the .fig file').optional() + path: z.string().describe('Optional absolute path for the .fig file').optional(), + ...automationTargetSchema }) - : z.object({}) + : z.object({ ...automationTargetSchema }) }, - async (args: { path?: string }) => { + async (args: { path?: string; document_id?: string; page_id?: string }) => { try { const safePath = args.path && resolvedRoot ? resolveSafePath(args.path, resolvedRoot) : undefined - const result = await sendRpc({ command: 'save_file', args: { path: safePath } }) - const res = result as { ok?: boolean; error?: string } + const { target } = splitAutomationTarget(args) + const result = await sendRpc({ command: 'save_file', args: { ...target, path: safePath } }) + const res = result as { ok?: boolean; result?: unknown; target?: unknown; error?: string } if (res.ok === false) return fail(new Error(res.error)) - return ok({ saved: true, ...(safePath ? { path: safePath } : {}) }) + return ok({ + saved: true, + ...(safePath ? { path: safePath } : {}), + ...(res.target ? { target: res.target } : {}) + }) } catch (e) { return fail(e) } @@ -109,16 +160,18 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption { description: `Open a .fig or .pen file from disk into a new tab. Path must be inside ${resolvedRoot}.`, inputSchema: z.object({ - path: z.string().describe('Absolute path to the design file') + path: z.string().describe('Absolute path to the design file'), + ...automationTargetSchema }) }, - async (args: { path: string }) => { + async (args: { path: string; document_id?: string; page_id?: string }) => { try { const safe = resolveSafePath(args.path, resolvedRoot) - const result = await sendRpc({ command: 'open_file', args: { path: safe } }) - const res = result as { ok?: boolean; error?: string } + const { target } = splitAutomationTarget(args) + const result = await sendRpc({ command: 'open_file', args: { ...target, path: safe } }) + const res = result as { ok?: boolean; result?: unknown; target?: unknown; error?: string } if (res.ok === false) return fail(new Error(res.error)) - return ok({ opened: true }) + return ok({ opened: true, ...(res.target ? { target: res.target } : {}) }) } catch (e) { return fail(e) } @@ -130,16 +183,21 @@ export function registerTools(mcpServer: McpServer, options: RegisterToolsOption { description: `Create a new empty document. Optionally set a save path inside ${resolvedRoot}.`, inputSchema: z.object({ - path: z.string().describe('Optional absolute path for the new file').optional() + path: z.string().describe('Optional absolute path for the new file').optional(), + ...automationTargetSchema }) }, - async (args: { path?: string }) => { + async (args: { path?: string; document_id?: string; page_id?: string }) => { try { const safePath = args.path ? resolveSafePath(args.path, resolvedRoot) : undefined - const result = await sendRpc({ command: 'new_document', args: { path: safePath } }) - const res = result as { ok?: boolean; error?: string } + const { target } = splitAutomationTarget(args) + const result = await sendRpc({ + command: 'new_document', + args: { ...target, path: safePath } + }) + const res = result as { ok?: boolean; result?: unknown; target?: unknown; error?: string } if (res.ok === false) return fail(new Error(res.error)) - return ok({ created: true }) + return ok({ created: true, ...(res.target ? { target: res.target } : {}) }) } catch (e) { return fail(e) } diff --git a/src/app/automation/bridge/eval-handler.ts b/src/app/automation/bridge/eval-handler.ts index 9162abd17..bf579e905 100644 --- a/src/app/automation/bridge/eval-handler.ts +++ b/src/app/automation/bridge/eval-handler.ts @@ -1,21 +1,21 @@ import type { FigmaAPI } from '@open-pencil/core/figma-api' import { wrapEvalCode } from '@open-pencil/core/tools' -import type { EditorStore } from '@/app/editor/active-store' +import type { AutomationTarget } from '@/app/automation/bridge/target' -type FigmaFactory = () => FigmaAPI +type FigmaFactory = (store: AutomationTarget['store'], pageId?: string) => FigmaAPI export function createAutomationEvalHandler(makeFigma: FigmaFactory) { - return async function handleEval(store: EditorStore, args: unknown): Promise { + return async function handleEval(target: AutomationTarget, args: unknown): Promise { const code = (args as { code?: string }).code if (!code) throw new Error('Missing "code" in args') - const figma = makeFigma() + const figma = makeFigma(target.store, target.pageId) const AsyncFunction = Object.getPrototypeOf(async function () { /* noop */ }).constructor const fn = new AsyncFunction('figma', wrapEvalCode(code)) const result = await fn(figma) - store.requestRender() + target.store.requestRender() return { ok: true, result: result ?? null } } } diff --git a/src/app/automation/bridge/export-handlers.ts b/src/app/automation/bridge/export-handlers.ts index 9a26c775c..39eb98f88 100644 --- a/src/app/automation/bridge/export-handlers.ts +++ b/src/app/automation/bridge/export-handlers.ts @@ -1,8 +1,9 @@ import { selectionToJSX, sceneNodeToJSX, type RasterExportFormat } from '@open-pencil/core/io' -import type { EditorStore } from '@/app/editor/active-store' +import type { AutomationTarget } from '@/app/automation/bridge/target' -export async function handleExport(store: EditorStore, args: unknown): Promise { +export async function handleExport(target: AutomationTarget, args: unknown): Promise { + const store = target.store const exportArgs = args as { nodeIds?: string[]; scale?: number; format?: string } | undefined const nodeIds = exportArgs?.nodeIds ?? [...store.state.selectedIds] if (nodeIds.length === 0) throw new Error('No nodes to export') @@ -21,10 +22,11 @@ export async function handleExport(store: EditorStore, args: unknown): Promise { +export async function handleExportJsx(target: AutomationTarget, args: unknown): Promise { + const store = target.store const jsxArgs = args as { nodeIds?: string[]; style?: string } | undefined const style = (jsxArgs?.style ?? 'openpencil') as 'openpencil' | 'tailwind' - const currentPage = store.graph.getNode(store.state.currentPageId) + const currentPage = store.graph.getNode(target.pageId) const nodeIds = jsxArgs?.nodeIds ?? currentPage?.childIds ?? [] const jsx = nodeIds.length === 1 diff --git a/src/app/automation/bridge/figma-factory.ts b/src/app/automation/bridge/figma-factory.ts index d27212537..35df7656a 100644 --- a/src/app/automation/bridge/figma-factory.ts +++ b/src/app/automation/bridge/figma-factory.ts @@ -3,10 +3,13 @@ import { FigmaAPI } from '@open-pencil/core/figma-api' import type { EditorStore } from '@/app/editor/active-store' import { listFamilies, listFonts } from '@/app/editor/fonts' -export function makeFigmaFromStore(store: EditorStore): FigmaAPI { +export function makeFigmaFromStore( + store: EditorStore, + pageId = store.state.currentPageId +): FigmaAPI { const api = new FigmaAPI(store.graph) api.setRenderer(store.renderer ?? null) - api.currentPage = api.wrapNode(store.state.currentPageId) + api.currentPage = api.wrapNode(pageId) api.currentPage.selection = [...store.state.selectedIds] .map((id) => api.getNodeById(id)) .filter((n): n is NonNullable => n !== null) diff --git a/src/app/automation/bridge/file-handlers.ts b/src/app/automation/bridge/file-handlers.ts index a198ae898..6b0921463 100644 --- a/src/app/automation/bridge/file-handlers.ts +++ b/src/app/automation/bridge/file-handlers.ts @@ -1,9 +1,14 @@ -import type { EditorStore } from '@/app/editor/active-store' +import { + resolveAutomationTarget, + responseWithTarget, + type AutomationTarget +} from '@/app/automation/bridge/target' import { openFileFromPath } from '@/app/shell/menu/use' -import { createTab, openFileInNewTab } from '@/app/tabs' +import { createTab, getActiveStore, openFileInNewTab } from '@/app/tabs' import { isTauri } from '@/app/tauri/env' -export async function handleSaveFile(store: EditorStore, args: unknown): Promise { +export async function handleSaveFile(target: AutomationTarget, args: unknown): Promise { + const store = target.store const path = (args as { path?: string }).path if (path) { store.setPlannedFilePath(path) @@ -25,7 +30,10 @@ export async function ensureTauriParentDirectory(path: string): Promise { await mkdir(dir, { recursive: true }) } -export async function handleNewDocument(_store: EditorStore, args: unknown): Promise { +export async function handleNewDocument( + _target: AutomationTarget, + args: unknown +): Promise { const path = (args as { path?: string }).path const tab = createTab() if (path) { @@ -34,10 +42,11 @@ export async function handleNewDocument(_store: EditorStore, args: unknown): Pro await tab.store.saveFigFile() tab.store.startWatchingCurrentFile() } - return { ok: true } + const target = resolveAutomationTarget(tab.store, { document_id: tab.id }) + return responseWithTarget({ ok: true, result: { created: true } }, target) } -export async function handleOpenFile(_store: EditorStore, args: unknown): Promise { +export async function handleOpenFile(_target: AutomationTarget, args: unknown): Promise { const path = (args as { path?: string }).path if (!path) throw new Error('Missing "path" in args') if (isTauri()) { @@ -49,5 +58,6 @@ export async function handleOpenFile(_store: EditorStore, args: unknown): Promis const file = new File([await response.blob()], name) await openFileInNewTab(file, undefined, path) } - return { ok: true } + const target = resolveAutomationTarget(getActiveStore(), undefined) + return responseWithTarget({ ok: true, result: { opened: true } }, target) } diff --git a/src/app/automation/bridge/handlers.ts b/src/app/automation/bridge/handlers.ts index eb98e4c12..bff5aaf17 100644 --- a/src/app/automation/bridge/handlers.ts +++ b/src/app/automation/bridge/handlers.ts @@ -9,12 +9,22 @@ import { } from '@/app/automation/bridge/file-handlers' import { handleRpcFallback } from '@/app/automation/bridge/rpc-handler' import { handleSelection } from '@/app/automation/bridge/selection-handler' +import { + isUnknownRecord, + listAutomationDocuments, + resolveAutomationTarget, + responseWithTarget, + stripAutomationTargetArgs +} from '@/app/automation/bridge/target' import { createAutomationToolHandler } from '@/app/automation/bridge/tool-handlers' import type { EditorStore } from '@/app/editor/active-store' -type FigmaFactory = () => FigmaAPI +type FigmaFactory = (store: EditorStore, pageId?: string) => FigmaAPI -type CommandHandler = (store: EditorStore, args: unknown) => Promise +type CommandHandler = ( + target: ReturnType, + args: unknown +) => Promise export function createAutomationCommandHandlers(makeFigma: FigmaFactory) { const handleEval = createAutomationEvalHandler(makeFigma) @@ -36,9 +46,23 @@ export function createAutomationCommandHandlers(makeFigma: FigmaFactory) { command: string, args: unknown ): Promise { + if (command === 'list_documents') { + return { ok: true, result: { documents: listAutomationDocuments(store) } } + } + + if (command === 'open_file' || command === 'new_document') { + const handler = commandHandlers[command] + if (handler) return handler(resolveAutomationTarget(store, undefined), args) + } + + const rawArgs = isUnknownRecord(args) ? args : {} + const target = resolveAutomationTarget(store, rawArgs) + const targetArgs = stripAutomationTargetArgs(rawArgs) const handler = commandHandlers[command] - if (handler) return handler(store, args) - return handleRpcFallback(store, command, args) + const result = handler + ? await handler(target, targetArgs) + : await handleRpcFallback(target, command, targetArgs) + return responseWithTarget(result, target) } return { handleRequest } diff --git a/src/app/automation/bridge/rpc-handler.ts b/src/app/automation/bridge/rpc-handler.ts index 6dde621a6..427a9c9e4 100644 --- a/src/app/automation/bridge/rpc-handler.ts +++ b/src/app/automation/bridge/rpc-handler.ts @@ -1,12 +1,12 @@ import { executeRpcCommand } from '@open-pencil/core/rpc' -import type { EditorStore } from '@/app/editor/active-store' +import type { AutomationTarget } from '@/app/automation/bridge/target' export async function handleRpcFallback( - store: EditorStore, + target: AutomationTarget, command: string, args: unknown ): Promise { - const result = executeRpcCommand(store.graph, command, args ?? {}) + const result = executeRpcCommand(target.store.graph, command, args ?? {}) return { ok: true, result } } diff --git a/src/app/automation/bridge/selection-handler.ts b/src/app/automation/bridge/selection-handler.ts index 8e446a5f7..164fdf111 100644 --- a/src/app/automation/bridge/selection-handler.ts +++ b/src/app/automation/bridge/selection-handler.ts @@ -1,8 +1,9 @@ import { nodeToXPath } from '@open-pencil/core/xpath' -import type { EditorStore } from '@/app/editor/active-store' +import type { AutomationTarget } from '@/app/automation/bridge/target' -export async function handleSelection(store: EditorStore): Promise { +export async function handleSelection(target: AutomationTarget): Promise { + const store = target.store const ids = [...store.state.selectedIds] const nodes = ids .map((id) => store.graph.getNode(id)) diff --git a/src/app/automation/bridge/server.ts b/src/app/automation/bridge/server.ts index b2f1fe340..ff7e6c5eb 100644 --- a/src/app/automation/bridge/server.ts +++ b/src/app/automation/bridge/server.ts @@ -10,17 +10,15 @@ import { randomHex } from '@open-pencil/core/random' import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory' import { createAutomationCommandHandlers } from '@/app/automation/bridge/handlers' import type { EditorStore } from '@/app/editor/active-store' + export function connectAutomation(getStore: () => EditorStore, authToken: string | null = null) { const token = authToken ?? randomHex(32) let ws: WebSocket | null = null let reconnectTimer: ReturnType | undefined let intentionalDisconnect = false - function makeFigma() { - return makeFigmaFromStore(getStore()) - } - - const { handleRequest: handleAutomationRequest } = createAutomationCommandHandlers(makeFigma) + const { handleRequest: handleAutomationRequest } = + createAutomationCommandHandlers(makeFigmaFromStore) async function handleRequest(_id: string, command: string, args: unknown): Promise { return handleAutomationRequest(getStore(), command, args) diff --git a/src/app/automation/bridge/target.ts b/src/app/automation/bridge/target.ts new file mode 100644 index 000000000..f47e2bb54 --- /dev/null +++ b/src/app/automation/bridge/target.ts @@ -0,0 +1,106 @@ +import type { AutomationDocumentSummary } from '@open-pencil/core/rpc' + +import type { EditorStore } from '@/app/editor/active-store' +import { getTabById, getTabForStore, getTabsSnapshot } from '@/app/tabs' + +export type UnknownRecord = { [key: string]: unknown } + +export type AutomationTargetArgs = { + document_id?: unknown + page_id?: unknown +} + +export function isUnknownRecord(value: unknown): value is UnknownRecord { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + +export type AutomationTarget = { + store: EditorStore + documentId: string + documentName: string + path?: string + pageId: string + pageName: string +} + +export type AutomationTargetResult = Omit + +export function stripAutomationTargetArgs(args: UnknownRecord): UnknownRecord { + const { document_id: _documentId, page_id: _pageId, ...rest } = args + return rest +} + +export function targetToResult(target: AutomationTarget): AutomationTargetResult { + return { + documentId: target.documentId, + documentName: target.documentName, + ...(target.path ? { path: target.path } : {}), + pageId: target.pageId, + pageName: target.pageName + } +} + +export function responseWithTarget( + body: unknown, + target: AutomationTarget +): Record { + const targetResult = targetToResult(target) + if (isUnknownRecord(body)) { + return { ...body, target: targetResult } + } + return { ok: true, result: body, target: targetResult } +} + +export function listAutomationDocuments(activeStore: EditorStore): AutomationDocumentSummary[] { + const activeTab = getTabForStore(activeStore) + return getTabsSnapshot().map((tab) => { + const pages = tab.store.graph.getPages().map((page) => ({ id: page.id, name: page.name })) + const currentPage = tab.store.graph.getNode(tab.store.state.currentPageId) + const path = tab.store.getDocumentFilePath() + return { + id: tab.id, + name: tab.store.state.documentName, + ...(path ? { path } : {}), + active: tab.id === activeTab?.id, + current_page_id: tab.store.state.currentPageId, + current_page_name: currentPage?.name ?? '', + pages + } + }) +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +export function resolveAutomationTarget( + activeStore: EditorStore, + args: AutomationTargetArgs | undefined +): AutomationTarget { + const requestedDocumentId = readString(args?.document_id) + const tab = requestedDocumentId ? getTabById(requestedDocumentId) : getTabForStore(activeStore) + if (!tab) { + throw new Error( + requestedDocumentId + ? `Document "${requestedDocumentId}" not found` + : 'No active OpenPencil document' + ) + } + + const requestedPageId = readString(args?.page_id) + const pageId = requestedPageId ?? tab.store.state.currentPageId + const page = tab.store.graph.getNode(pageId) + if (page?.type !== 'CANVAS') { + throw new Error(`Page "${pageId}" not found in document "${tab.id}"`) + } + + const path = tab.store.getDocumentFilePath() + return { + store: tab.store, + documentId: tab.id, + documentName: tab.store.state.documentName, + ...(path ? { path } : {}), + pageId, + pageName: page.name + } +} diff --git a/src/app/automation/bridge/tool-handlers.ts b/src/app/automation/bridge/tool-handlers.ts index d5ca12889..b202fc621 100644 --- a/src/app/automation/bridge/tool-handlers.ts +++ b/src/app/automation/bridge/tool-handlers.ts @@ -4,24 +4,25 @@ import { computeAllLayouts } from '@open-pencil/core/layout' import { ALL_TOOLS } from '@open-pencil/core/tools' import type { JsonObject } from '@open-pencil/scene-graph/primitives' -import type { EditorStore } from '@/app/editor/active-store' +import type { AutomationTarget } from '@/app/automation/bridge/target' import { ensureGraphFonts } from '@/app/editor/fonts' -type FigmaFactory = () => FigmaAPI +type FigmaFactory = (store: AutomationTarget['store'], pageId?: string) => FigmaAPI export function createAutomationToolHandler(makeFigma: FigmaFactory) { async function handleToolRender( - store: EditorStore, + target: AutomationTarget, toolArgs: Record ): Promise { + const store = target.store const tree = toolArgs.tree as Parameters[1] const result = await renderTreeNode(store.graph, tree, { - parentId: (toolArgs.parent_id as string | undefined) ?? store.state.currentPageId, + parentId: (toolArgs.parent_id as string | undefined) ?? target.pageId, x: toolArgs.x as number | undefined, y: toolArgs.y as number | undefined }) await ensureGraphFonts(store.graph, [result.id]) - computeAllLayouts(store.graph, store.state.currentPageId) + computeAllLayouts(store.graph, target.pageId) store.requestRender() store.flashNodes([result.id]) return { @@ -30,28 +31,25 @@ export function createAutomationToolHandler(makeFigma: FigmaFactory) { } } - return async function handleTool(store: EditorStore, args: unknown): Promise { + return async function handleTool(target: AutomationTarget, args: unknown): Promise { const toolName = (args as { name?: string }).name const toolArgs = (args as { args?: Record }).args ?? {} if (!toolName) throw new Error('Missing "name" in args') if (toolName === 'render' && toolArgs.tree) { - return handleToolRender(store, toolArgs) + return handleToolRender(target, toolArgs) } const def = ALL_TOOLS.find((t) => t.name === toolName) if (!def) throw new Error(`Unknown tool: ${toolName}`) - const figma = makeFigma() + const store = target.store + const figma = makeFigma(store, target.pageId) const result = await def.execute(figma, toolArgs) - if (figma.currentPageId !== store.state.currentPageId) { - void store.switchPage(figma.currentPageId) - } - if (def.mutates) { - const pageNode = store.graph.getNode(store.state.currentPageId) + const pageNode = store.graph.getNode(figma.currentPageId) if (pageNode) await ensureGraphFonts(store.graph, pageNode.childIds) - computeAllLayouts(store.graph, store.state.currentPageId) + computeAllLayouts(store.graph, figma.currentPageId) store.requestRender() store.flashNodes(extractNodeIds(result)) } diff --git a/src/app/document/io/create.ts b/src/app/document/io/create.ts index 76ae51621..50aa5d096 100644 --- a/src/app/document/io/create.ts +++ b/src/app/document/io/create.ts @@ -67,6 +67,7 @@ export function createDocumentIOActions( downloadBlob, setViewportSize, fitCurrentPageToViewport, + getDocumentFilePath: sourceState.getFilePath, setDocumentSource: sourceActions.setDocumentSource, setPlannedFilePath: sourceActions.setPlannedFilePath, startWatchingCurrentFile: sourceActions.startWatchingCurrentFile, diff --git a/src/app/editor/session/modules.ts b/src/app/editor/session/modules.ts index 8226b1d22..c5be2b90a 100644 --- a/src/app/editor/session/modules.ts +++ b/src/app/editor/session/modules.ts @@ -75,6 +75,7 @@ export function createEditorStoreModules( fitCurrentPageToViewport: documentIO.fitCurrentPageToViewport, saveFigFile: documentIO.saveFigFile, saveFigFileAs: documentIO.saveFigFileAs, + getDocumentFilePath: documentIO.getDocumentFilePath, setDocumentSource: documentIO.setDocumentSource, setPlannedFilePath: documentIO.setPlannedFilePath, startWatchingCurrentFile: documentIO.startWatchingCurrentFile, diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index 5c9ef809d..7b93ed9e7 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -42,6 +42,22 @@ export function getActiveStore(): EditorStore { return tab.store } +export function getActiveTabId(): string { + return activeTabId.value +} + +export function getTabById(tabId: string): Tab | undefined { + return tabsRef.value.find((tab) => tab.id === tabId) +} + +export function getTabForStore(store: EditorStore): Tab | undefined { + return tabsRef.value.find((tab) => tab.store === store) +} + +export function getTabsSnapshot(): Tab[] { + return [...tabsRef.value] +} + export function createTab(store?: EditorStore, initialGraph?: SceneGraph): Tab { const s = store ?? createEditorStore(initialGraph) const tab: Tab = { id: generateTabId(), store: s } @@ -150,6 +166,10 @@ export function useTabsStore() { createTab, switchTab, closeTab, + getActiveTabId, + getTabById, + getTabForStore, + getTabsSnapshot, openFileInNewTab, getActiveStore, tabCount diff --git a/tests/engine/mcp/stdio.test.ts b/tests/engine/mcp/stdio.test.ts index 338ddffaa..ed9b7fd6d 100644 --- a/tests/engine/mcp/stdio.test.ts +++ b/tests/engine/mcp/stdio.test.ts @@ -19,6 +19,10 @@ function createMockApp() { const graph = new SceneGraph() const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' }) let clientWs: WebSocket | null = null + const requests: Array<{ + command: string + args?: { name?: string; document_id?: string; page_id?: string; args?: Record } + }> = [] wss.on('connection', (ws) => { clientWs = ws @@ -32,6 +36,7 @@ function createMockApp() { args?: { name?: string; args?: Record } } if (msg.type !== 'request') return + requests.push({ command: msg.command, args: msg.args }) try { let result: unknown @@ -45,6 +50,19 @@ function createMockApp() { if (def.mutates) computeAllLayouts(graph) } else if (msg.command === 'save_file') { result = { ok: true } + } else if (msg.command === 'list_documents') { + result = { + documents: [ + { + id: 'doc-1', + name: 'Mock document', + active: true, + current_page_id: graph.getPages()[0].id, + current_page_name: graph.getPages()[0].name, + pages: graph.getPages().map((page) => ({ id: page.id, name: page.name })) + } + ] + } } else { result = executeRpcCommand(graph, msg.command, msg.args ?? {}) } @@ -69,6 +87,7 @@ function createMockApp() { return { graph, + requests, wss, port, close: () => { @@ -144,7 +163,14 @@ describe('MCP stdio transport', () => { expect(names).toContain('create_shape') expect(names).toContain('get_page_tree') expect(names).toContain('save_file') + expect(names).toContain('list_documents') expect(names).toContain('get_codegen_prompt') + const createShape = expectDefined( + tools.find((tool) => tool.name === 'create_shape'), + 'create_shape tool' + ) + expect(JSON.stringify(createShape.inputSchema)).toContain('document_id') + expect(JSON.stringify(createShape.inputSchema)).toContain('page_id') expect(tools.length).toBeGreaterThan(30) }) @@ -165,6 +191,41 @@ describe('MCP stdio transport', () => { expect(getNodeOrThrow(app.graph, data.id).width).toBe(200) }) + test('tool target fields are sent in the app RPC envelope', async () => { + const result = await client.callTool({ + name: 'create_shape', + arguments: { + document_id: 'doc-1', + page_id: 'page-1', + type: 'FRAME', + x: 10, + y: 20, + width: 200, + height: 100, + name: 'TargetedFrame' + } + }) + expect(result.isError).not.toBe(true) + const request = expectDefined( + app.requests.find((item) => item.command === 'tool' && item.args?.name === 'create_shape'), + 'tool request' + ) + expect(request.args?.document_id).toBe('doc-1') + expect(request.args?.page_id).toBe('page-1') + expect(request.args?.args?.document_id).toBeUndefined() + expect(request.args?.args?.page_id).toBeUndefined() + }) + + test('list_documents via stdio returns open documents', async () => { + const result = await client.callTool({ name: 'list_documents', arguments: {} }) + expect(result.isError).not.toBe(true) + const data = JSON.parse(textContent(result.content)) as { + documents: Array<{ id: string; current_page_id: string }> + } + expect(data.documents[0].id).toBe('doc-1') + expect(data.documents[0].current_page_id).toBe(app.graph.getPages()[0].id) + }) + test('save_file via stdio succeeds', async () => { const result = await client.callTool({ name: 'save_file', arguments: {} }) expect(result.isError).not.toBe(true)