fix(mcp): target live automation by document and page
This commit is contained in:
parent
2c69e0949b
commit
a4272a17bf
|
|
@ -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.
|
||||
|
|
|
|||
27
packages/cli/src/app-target.ts
Normal file
27
packages/cli/src/app-target.ts
Normal file
|
|
@ -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'] } : {})
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AnalyzeClustersResult>(args.file, 'analyze_clusters', {
|
||||
limit: Number(args.limit),
|
||||
minSize: Number(args['min-size']),
|
||||
minCount: Number(args['min-count'])
|
||||
})
|
||||
const data = await loadRpcData<AnalyzeClustersResult>(
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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<AnalyzeColorsResult>(args.file, 'analyze_colors', {
|
||||
threshold: Number(args.threshold),
|
||||
similar: args.similar
|
||||
})
|
||||
const data = await loadRpcData<AnalyzeColorsResult>(
|
||||
args.file,
|
||||
'analyze_colors',
|
||||
{
|
||||
threshold: Number(args.threshold),
|
||||
similar: args.similar
|
||||
},
|
||||
args
|
||||
)
|
||||
const limit = Number(args.limit)
|
||||
|
||||
if (args.json) {
|
||||
|
|
|
|||
|
|
@ -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<AnalyzeOverlapsResult>(args.file, 'analyze_overlaps', rpcArgs)
|
||||
const data = await loadRpcData<AnalyzeOverlapsResult>(
|
||||
args.file,
|
||||
'analyze_overlaps',
|
||||
rpcArgs,
|
||||
args
|
||||
)
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
|
|
|
|||
|
|
@ -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<AnalyzeSpacingResult>(args.file, 'analyze_spacing')
|
||||
const data = await loadRpcData<AnalyzeSpacingResult>(
|
||||
args.file,
|
||||
'analyze_spacing',
|
||||
undefined,
|
||||
args
|
||||
)
|
||||
const gridSize = Number(args.grid)
|
||||
|
||||
if (args.json) {
|
||||
|
|
|
|||
|
|
@ -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<AnalyzeTypographyResult>(args.file, 'analyze_typography', {})
|
||||
const data = await loadRpcData<AnalyzeTypographyResult>(
|
||||
args.file,
|
||||
'analyze_typography',
|
||||
{},
|
||||
args
|
||||
)
|
||||
const limit = Number(args.limit)
|
||||
const groupBy = args['group-by']
|
||||
|
||||
|
|
|
|||
47
packages/cli/src/commands/documents.ts
Normal file
47
packages/cli/src/commands/documents.ts
Normal file
|
|
@ -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 <id> --page-id <id>'))
|
||||
console.log('')
|
||||
} catch (error) {
|
||||
printError(error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<FindNodeResult[]>(args.file, 'find', {
|
||||
name: args.name,
|
||||
type: args.type,
|
||||
page: args.page,
|
||||
limit: args.limit ? Number(args.limit) : undefined
|
||||
})
|
||||
const results = await loadRpcData<FindNodeResult[]>(
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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<InfoResult>(args.file, 'info')
|
||||
const data = await loadRpcData<InfoResult>(args.file, 'info', undefined, args)
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
|
|
|
|||
|
|
@ -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<NodeResult | { error: string }>(args.file, 'node', {
|
||||
id: args.id
|
||||
})
|
||||
const data = await loadRpcData<NodeResult | { error: string }>(
|
||||
args.file,
|
||||
'node',
|
||||
{
|
||||
id: args.id
|
||||
},
|
||||
args
|
||||
)
|
||||
|
||||
if ('error' in data) {
|
||||
printError(data.error)
|
||||
|
|
|
|||
|
|
@ -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<PageItem[]>(args.file, 'pages')
|
||||
const pages = await loadRpcData<PageItem[]>(args.file, 'pages', undefined, args)
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(pages, null, 2))
|
||||
|
|
|
|||
|
|
@ -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<QueryNodeResult[] | { error: string }>(args.file, 'query', {
|
||||
selector: args.selector,
|
||||
page: args.page,
|
||||
limit: args.limit ? Number(args.limit) : undefined
|
||||
})
|
||||
const results = await loadRpcData<QueryNodeResult[] | { error: string }>(
|
||||
args.file,
|
||||
'query',
|
||||
{
|
||||
selector: args.selector,
|
||||
page: args.page,
|
||||
limit: args.limit ? Number(args.limit) : undefined
|
||||
},
|
||||
args
|
||||
)
|
||||
|
||||
if ('error' in results) {
|
||||
printError(results.error)
|
||||
|
|
|
|||
|
|
@ -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<SelectionNode[]>('selection')
|
||||
const nodes = await rpc<SelectionNode[]>('selection', appTargetRpcArgs(args))
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(nodes, null, 2))
|
||||
|
|
|
|||
|
|
@ -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<TreeResult | { error: string }>(args.file, 'tree', {
|
||||
page: args.page,
|
||||
depth: args.depth ? Number(args.depth) : undefined
|
||||
})
|
||||
const data = await loadRpcData<TreeResult | { error: string }>(
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<VariablesResult>(args.file, 'variables', {
|
||||
collection: args.collection,
|
||||
type: args.type
|
||||
})
|
||||
const data = await loadRpcData<VariablesResult>(
|
||||
args.file,
|
||||
'variables',
|
||||
{
|
||||
collection: args.collection,
|
||||
type: args.type
|
||||
},
|
||||
args
|
||||
)
|
||||
|
||||
if (data.totalVariables === 0) {
|
||||
console.log('No variables found.')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Result>(
|
||||
file: string | undefined,
|
||||
command: string,
|
||||
args?: unknown
|
||||
args?: unknown,
|
||||
targetArgs?: AppTargetCliArgs
|
||||
): Promise<Result> {
|
||||
if (isAppMode(file)) return rpc<Result>(command, args)
|
||||
if (isAppMode(file)) {
|
||||
return rpc<Result>(command, {
|
||||
...(isRpcArgs(args) ? args : {}),
|
||||
...(targetArgs ? appTargetRpcArgs(targetArgs) : {})
|
||||
})
|
||||
}
|
||||
const graph = await loadDocument(requireFile(file))
|
||||
return executeRpcCommand(graph, command, args) as Result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -23,5 +23,6 @@ export type {
|
|||
AnalyzeClustersResult,
|
||||
AnalyzeOverlapsArgs,
|
||||
AnalyzeOverlapsResult,
|
||||
TypographyStyle
|
||||
TypographyStyle,
|
||||
AutomationDocumentSummary
|
||||
} from './commands'
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,25 @@ import { paramToZod } from './schema'
|
|||
|
||||
export type RpcSender = (body: Record<string, unknown>) => Promise<unknown>
|
||||
|
||||
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<string, unknown>): {
|
||||
target: { document_id?: string; page_id?: string }
|
||||
args: Record<string, unknown>
|
||||
} {
|
||||
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<string, unknown>) => {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
||||
return async function handleEval(target: AutomationTarget, args: unknown): Promise<unknown> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
||||
export async function handleExport(target: AutomationTarget, args: unknown): Promise<unknown> {
|
||||
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<u
|
|||
}
|
||||
}
|
||||
|
||||
export async function handleExportJsx(store: EditorStore, args: unknown): Promise<unknown> {
|
||||
export async function handleExportJsx(target: AutomationTarget, args: unknown): Promise<unknown> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<typeof n> => n !== null)
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
||||
export async function handleSaveFile(target: AutomationTarget, args: unknown): Promise<unknown> {
|
||||
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<void> {
|
|||
await mkdir(dir, { recursive: true })
|
||||
}
|
||||
|
||||
export async function handleNewDocument(_store: EditorStore, args: unknown): Promise<unknown> {
|
||||
export async function handleNewDocument(
|
||||
_target: AutomationTarget,
|
||||
args: unknown
|
||||
): Promise<unknown> {
|
||||
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<unknown> {
|
||||
export async function handleOpenFile(_target: AutomationTarget, args: unknown): Promise<unknown> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<unknown>
|
||||
type CommandHandler = (
|
||||
target: ReturnType<typeof resolveAutomationTarget>,
|
||||
args: unknown
|
||||
) => Promise<unknown>
|
||||
|
||||
export function createAutomationCommandHandlers(makeFigma: FigmaFactory) {
|
||||
const handleEval = createAutomationEvalHandler(makeFigma)
|
||||
|
|
@ -36,9 +46,23 @@ export function createAutomationCommandHandlers(makeFigma: FigmaFactory) {
|
|||
command: string,
|
||||
args: unknown
|
||||
): Promise<unknown> {
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
||||
const result = executeRpcCommand(store.graph, command, args ?? {})
|
||||
const result = executeRpcCommand(target.store.graph, command, args ?? {})
|
||||
return { ok: true, result }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
||||
export async function handleSelection(target: AutomationTarget): Promise<unknown> {
|
||||
const store = target.store
|
||||
const ids = [...store.state.selectedIds]
|
||||
const nodes = ids
|
||||
.map((id) => store.graph.getNode(id))
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | 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<unknown> {
|
||||
return handleAutomationRequest(getStore(), command, args)
|
||||
|
|
|
|||
106
src/app/automation/bridge/target.ts
Normal file
106
src/app/automation/bridge/target.ts
Normal file
|
|
@ -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<AutomationTarget, 'store'>
|
||||
|
||||
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<string, unknown> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const store = target.store
|
||||
const tree = toolArgs.tree as Parameters<typeof renderTreeNode>[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<unknown> {
|
||||
return async function handleTool(target: AutomationTarget, args: unknown): Promise<unknown> {
|
||||
const toolName = (args as { name?: string }).name
|
||||
const toolArgs = (args as { args?: Record<string, unknown> }).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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export function createDocumentIOActions(
|
|||
downloadBlob,
|
||||
setViewportSize,
|
||||
fitCurrentPageToViewport,
|
||||
getDocumentFilePath: sourceState.getFilePath,
|
||||
setDocumentSource: sourceActions.setDocumentSource,
|
||||
setPlannedFilePath: sourceActions.setPlannedFilePath,
|
||||
startWatchingCurrentFile: sourceActions.startWatchingCurrentFile,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> }
|
||||
}> = []
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
clientWs = ws
|
||||
|
|
@ -32,6 +36,7 @@ function createMockApp() {
|
|||
args?: { name?: string; args?: Record<string, unknown> }
|
||||
}
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue