@open-pencil/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { FigmaAPI } from '@open-pencil/core'
4
+
5
+ import { loadDocument } from '../headless'
6
+ import { printError } from '../format'
7
+
8
+ function serializeResult(value: unknown): unknown {
9
+ if (value === undefined || value === null) return value
10
+ if (typeof value === 'object' && value !== null && 'toJSON' in value && typeof value.toJSON === 'function') {
11
+ return value.toJSON()
12
+ }
13
+ if (Array.isArray(value)) return value.map(serializeResult)
14
+ return value
15
+ }
16
+
17
+ export default defineCommand({
18
+ meta: { description: 'Execute JavaScript with Figma plugin API' },
19
+ args: {
20
+ file: { type: 'positional', description: '.fig file path', required: true },
21
+ code: { type: 'string', alias: 'c', description: 'JavaScript code to execute' },
22
+ stdin: { type: 'boolean', description: 'Read code from stdin' },
23
+ write: { type: 'boolean', alias: 'w', description: 'Write changes back to the input file' },
24
+ output: { type: 'string', alias: 'o', description: 'Write to a different file' },
25
+ json: { type: 'boolean', description: 'Output as JSON' },
26
+ quiet: { type: 'boolean', alias: 'q', description: 'Suppress output' },
27
+ },
28
+ async run({ args }) {
29
+ let code = args.code
30
+
31
+ if (args.stdin) {
32
+ const chunks: Buffer[] = []
33
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer)
34
+ code = Buffer.concat(chunks).toString('utf-8')
35
+ }
36
+
37
+ if (!code) {
38
+ printError('Provide code via --code or --stdin')
39
+ process.exit(1)
40
+ }
41
+
42
+ const graph = await loadDocument(args.file)
43
+ const figma = new FigmaAPI(graph)
44
+
45
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
46
+ const wrappedCode = code.trim().startsWith('return')
47
+ ? code
48
+ : `return (async () => { ${code} })()`
49
+
50
+ let result: unknown
51
+ try {
52
+ const fn = new AsyncFunction('figma', wrappedCode)
53
+ result = await fn(figma)
54
+ } catch (err) {
55
+ printError(err instanceof Error ? err.message : String(err))
56
+ process.exit(1)
57
+ }
58
+
59
+ if (!args.quiet && result !== undefined) {
60
+ const serialized = serializeResult(result)
61
+ if (args.json || !process.stdout.isTTY) {
62
+ console.log(JSON.stringify(serialized, null, 2))
63
+ } else {
64
+ console.log(serialized)
65
+ }
66
+ }
67
+
68
+ if (args.write || args.output) {
69
+ const { exportFigFile } = await import('@open-pencil/core')
70
+ const outPath = args.output ?? args.file
71
+ const data = exportFigFile(graph)
72
+ await Bun.write(outPath, data)
73
+ if (!args.quiet) {
74
+ console.error(`Written to ${outPath}`)
75
+ }
76
+ }
77
+ },
78
+ })
@@ -0,0 +1,67 @@
1
+ import { defineCommand } from 'citty'
2
+ import { basename, extname, resolve } from 'node:path'
3
+
4
+ import { loadDocument, loadFonts, exportNodes, exportThumbnail } from '../headless'
5
+ import { ok, printError } from '../format'
6
+ import type { ExportFormat } from '@open-pencil/core'
7
+
8
+ export default defineCommand({
9
+ meta: { description: 'Export a .fig file to PNG, JPG, or WEBP' },
10
+ args: {
11
+ file: { type: 'positional', description: '.fig file path', required: true },
12
+ output: { type: 'string', alias: 'o', description: 'Output file path (default: <name>.<format>)' },
13
+ format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp (default: png)', default: 'png' },
14
+ scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
15
+ quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)' },
16
+ page: { type: 'string', description: 'Page name (default: first page)' },
17
+ node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)' },
18
+ thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
19
+ width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
20
+ height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' }
21
+ },
22
+ async run({ args }) {
23
+ const format = args.format.toUpperCase() as ExportFormat
24
+ if (!['PNG', 'JPG', 'WEBP'].includes(format)) {
25
+ printError(`Invalid format "${args.format}". Use png, jpg, or webp.`)
26
+ process.exit(1)
27
+ }
28
+
29
+ const graph = await loadDocument(args.file)
30
+ await loadFonts(graph)
31
+
32
+ const pages = graph.getPages()
33
+ const page = args.page
34
+ ? pages.find((p) => p.name === args.page)
35
+ : pages[0]
36
+
37
+ if (!page) {
38
+ printError(`Page "${args.page}" not found.`)
39
+ process.exit(1)
40
+ }
41
+
42
+ const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
43
+ const defaultName = basename(args.file, extname(args.file))
44
+ const output = resolve(args.output ?? `${defaultName}.${ext}`)
45
+
46
+ let data: Uint8Array | null
47
+
48
+ if (args.thumbnail) {
49
+ data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height))
50
+ } else {
51
+ const nodeIds = args.node ? [args.node] : page.childIds
52
+ data = await exportNodes(graph, page.id, nodeIds, {
53
+ scale: Number(args.scale),
54
+ format,
55
+ quality: args.quality ? Number(args.quality) : undefined
56
+ })
57
+ }
58
+
59
+ if (!data) {
60
+ printError('Nothing to export (empty page or no visible nodes).')
61
+ process.exit(1)
62
+ }
63
+
64
+ await Bun.write(output, data)
65
+ console.log(ok(`Exported ${output} (${(data.length / 1024).toFixed(1)} KB)`))
66
+ }
67
+ })
@@ -0,0 +1,69 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { loadDocument } from '../headless'
4
+ import { fmtList, nodeToListItem, printError, bold } from '../format'
5
+ import type { SceneNode } from '@open-pencil/core'
6
+
7
+ export default defineCommand({
8
+ meta: { description: 'Find nodes by name or type' },
9
+ args: {
10
+ file: { type: 'positional', description: '.fig file path', required: true },
11
+ name: { type: 'string', description: 'Node name (partial match, case-insensitive)' },
12
+ type: { type: 'string', description: 'Node type: FRAME, TEXT, RECTANGLE, INSTANCE, etc.' },
13
+ page: { type: 'string', description: 'Page name (default: all pages)' },
14
+ limit: { type: 'string', description: 'Max results (default: 100)', default: '100' },
15
+ json: { type: 'boolean', description: 'Output as JSON' }
16
+ },
17
+ async run({ args }) {
18
+ const graph = await loadDocument(args.file)
19
+ const pages = graph.getPages()
20
+ const max = Number(args.limit)
21
+ const namePattern = args.name?.toLowerCase()
22
+ const typeFilter = args.type?.toUpperCase()
23
+
24
+ const results: SceneNode[] = []
25
+
26
+ const searchPage = (pageNode: SceneNode) => {
27
+ const walk = (id: string) => {
28
+ if (results.length >= max) return
29
+ const node = graph.getNode(id)
30
+ if (!node) return
31
+ const matchesName = !namePattern || node.name.toLowerCase().includes(namePattern)
32
+ const matchesType = !typeFilter || node.type === typeFilter
33
+ if (matchesName && matchesType) results.push(node)
34
+ for (const childId of node.childIds) walk(childId)
35
+ }
36
+ for (const childId of pageNode.childIds) walk(childId)
37
+ }
38
+
39
+ if (args.page) {
40
+ const page = pages.find((p) => p.name === args.page)
41
+ if (!page) {
42
+ printError(`Page "${args.page}" not found.`)
43
+ process.exit(1)
44
+ }
45
+ searchPage(page)
46
+ } else {
47
+ for (const page of pages) searchPage(page)
48
+ }
49
+
50
+ if (args.json) {
51
+ console.log(JSON.stringify(results.map((n) => ({
52
+ id: n.id, name: n.name, type: n.type,
53
+ width: Math.round(n.width), height: Math.round(n.height)
54
+ })), null, 2))
55
+ return
56
+ }
57
+
58
+ if (results.length === 0) {
59
+ console.log('No nodes found.')
60
+ return
61
+ }
62
+
63
+ console.log('')
64
+ console.log(bold(` Found ${results.length} node${results.length > 1 ? 's' : ''}`))
65
+ console.log('')
66
+ console.log(fmtList(results.map(nodeToListItem)))
67
+ console.log('')
68
+ }
69
+ })
@@ -0,0 +1,58 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { loadDocument } from '../headless'
4
+ import { bold, fmtHistogram, fmtSummary, kv } from '../format'
5
+ import type { SceneNode } from '@open-pencil/core'
6
+
7
+ export default defineCommand({
8
+ meta: { description: 'Show document info (pages, node counts, fonts)' },
9
+ args: {
10
+ file: { type: 'positional', description: '.fig file path', required: true },
11
+ json: { type: 'boolean', description: 'Output as JSON' }
12
+ },
13
+ async run({ args }) {
14
+ const graph = await loadDocument(args.file)
15
+ const pages = graph.getPages()
16
+
17
+ let totalNodes = 0
18
+ const types: Record<string, number> = {}
19
+ const fonts = new Set<string>()
20
+ const pageCounts: Record<string, number> = {}
21
+
22
+ for (const page of pages) {
23
+ let pageCount = 0
24
+ const walk = (id: string) => {
25
+ const node = graph.getNode(id) as SceneNode | undefined
26
+ if (!node) return
27
+ totalNodes++
28
+ pageCount++
29
+ types[node.type] = (types[node.type] ?? 0) + 1
30
+ if (node.fontFamily) fonts.add(node.fontFamily)
31
+ for (const childId of node.childIds) walk(childId)
32
+ }
33
+ for (const childId of page.childIds) walk(childId)
34
+ pageCounts[page.name] = pageCount
35
+ }
36
+
37
+ if (args.json) {
38
+ console.log(JSON.stringify({ pages: pages.length, totalNodes, types, fonts: [...fonts].sort(), pageCounts }, null, 2))
39
+ return
40
+ }
41
+
42
+ console.log('')
43
+ console.log(bold(` ${pages.length} pages, ${totalNodes} nodes`))
44
+ console.log('')
45
+
46
+ const pageItems = Object.entries(pageCounts).map(([label, value]) => ({ label, value }))
47
+ console.log(fmtHistogram(pageItems, { unit: 'nodes' }))
48
+
49
+ console.log('')
50
+ console.log(fmtSummary(types))
51
+
52
+ if (fonts.size > 0) {
53
+ console.log('')
54
+ console.log(kv('Fonts', [...fonts].sort().join(', ')))
55
+ }
56
+ console.log('')
57
+ }
58
+ })
@@ -0,0 +1,83 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { loadDocument } from '../headless'
4
+ import { fmtNode, fmtList, nodeToData, nodeDetails, formatType, printError } from '../format'
5
+ import type { SceneNode, SceneGraph } from '@open-pencil/core'
6
+
7
+ function fullNodeDetails(graph: SceneGraph, node: SceneNode): Record<string, unknown> {
8
+ const details = nodeDetails(node)
9
+
10
+ const parent = node.parentId ? graph.getNode(node.parentId) : undefined
11
+ if (parent) details.parent = `${parent.name} (${parent.id})`
12
+
13
+ if (node.text) {
14
+ details.text = node.text.length > 80 ? node.text.slice(0, 80) + '…' : node.text
15
+ }
16
+
17
+ if (node.childIds.length > 0) details.children = node.childIds.length
18
+
19
+ for (const [field, varId] of Object.entries(node.boundVariables)) {
20
+ const variable = graph.variables.get(varId)
21
+ details[`var:${field}`] = variable?.name ?? varId
22
+ }
23
+
24
+ return details
25
+ }
26
+
27
+ export default defineCommand({
28
+ meta: { description: 'Show detailed node properties by ID' },
29
+ args: {
30
+ file: { type: 'positional', description: '.fig file path', required: true },
31
+ id: { type: 'string', description: 'Node ID', required: true },
32
+ json: { type: 'boolean', description: 'Output as JSON' }
33
+ },
34
+ async run({ args }) {
35
+ const graph = await loadDocument(args.file)
36
+ const node = graph.getNode(args.id)
37
+
38
+ if (!node) {
39
+ printError(`Node "${args.id}" not found.`)
40
+ process.exit(1)
41
+ }
42
+
43
+ if (args.json) {
44
+ const { childIds, parentId, ...rest } = node
45
+ const children = childIds.length
46
+ const parent = parentId ? graph.getNode(parentId) : undefined
47
+ console.log(
48
+ JSON.stringify(
49
+ {
50
+ ...rest,
51
+ parent: parent ? { id: parent.id, name: parent.name, type: parent.type } : null,
52
+ children
53
+ },
54
+ null,
55
+ 2
56
+ )
57
+ )
58
+ return
59
+ }
60
+
61
+ console.log('')
62
+ console.log(fmtNode(nodeToData(node), fullNodeDetails(graph, node)))
63
+
64
+ if (node.childIds.length > 0) {
65
+ const children = node.childIds
66
+ .map((id) => graph.getNode(id))
67
+ .filter((n): n is SceneNode => n !== undefined)
68
+ .slice(0, 10)
69
+ .map((child) => ({
70
+ header: `[${formatType(child.type)}] "${child.name}" (${child.id})`
71
+ }))
72
+
73
+ if (node.childIds.length > 10) {
74
+ children.push({ header: `… and ${node.childIds.length - 10} more` })
75
+ }
76
+
77
+ console.log('')
78
+ console.log(fmtList(children, { compact: true }))
79
+ }
80
+
81
+ console.log('')
82
+ }
83
+ })
@@ -0,0 +1,53 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { loadDocument } from '../headless'
4
+ import { bold, fmtList, entity, formatType } from '../format'
5
+
6
+ export default defineCommand({
7
+ meta: { description: 'List pages in a .fig file' },
8
+ args: {
9
+ file: { type: 'positional', description: '.fig file path', required: true },
10
+ json: { type: 'boolean', description: 'Output as JSON' }
11
+ },
12
+ async run({ args }) {
13
+ const graph = await loadDocument(args.file)
14
+ const pages = graph.getPages()
15
+
16
+ const countNodes = (pageId: string): number => {
17
+ let count = 0
18
+ const walk = (id: string) => {
19
+ count++
20
+ const n = graph.getNode(id)
21
+ if (n) for (const cid of n.childIds) walk(cid)
22
+ }
23
+ const page = graph.getNode(pageId)
24
+ if (page) for (const cid of page.childIds) walk(cid)
25
+ return count
26
+ }
27
+
28
+ if (args.json) {
29
+ console.log(
30
+ JSON.stringify(
31
+ pages.map((p) => ({ id: p.id, name: p.name, nodes: countNodes(p.id) })),
32
+ null,
33
+ 2
34
+ )
35
+ )
36
+ return
37
+ }
38
+
39
+ console.log('')
40
+ console.log(bold(` ${pages.length} page${pages.length !== 1 ? 's' : ''}`))
41
+ console.log('')
42
+ console.log(
43
+ fmtList(
44
+ pages.map((page) => ({
45
+ header: entity(formatType(page.type), page.name, page.id),
46
+ details: { nodes: countNodes(page.id) }
47
+ })),
48
+ { compact: true }
49
+ )
50
+ )
51
+ console.log('')
52
+ }
53
+ })
@@ -0,0 +1,63 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { loadDocument } from '../headless'
4
+ import { fmtTree, nodeToTreeNode, printError, entity, formatType } from '../format'
5
+ import type { SceneNode } from '@open-pencil/core'
6
+
7
+ export default defineCommand({
8
+ meta: { description: 'Print the node tree' },
9
+ args: {
10
+ file: { type: 'positional', description: '.fig file path', required: true },
11
+ page: { type: 'string', description: 'Page name (default: first page)' },
12
+ depth: { type: 'string', description: 'Max depth (default: unlimited)' },
13
+ json: { type: 'boolean', description: 'Output as JSON' }
14
+ },
15
+ async run({ args }) {
16
+ const graph = await loadDocument(args.file)
17
+ const pages = graph.getPages()
18
+ const maxDepth = args.depth ? Number(args.depth) : Infinity
19
+
20
+ const page = args.page
21
+ ? pages.find((p) => p.name === args.page)
22
+ : pages[0]
23
+
24
+ if (!page) {
25
+ printError(`Page "${args.page}" not found. Available: ${pages.map((p) => p.name).join(', ')}`)
26
+ process.exit(1)
27
+ }
28
+
29
+ if (args.json) {
30
+ const buildJson = (id: string, depth: number): unknown => {
31
+ const node = graph.getNode(id)
32
+ if (!node) return null
33
+ const result: Record<string, unknown> = {
34
+ id: node.id,
35
+ name: node.name,
36
+ type: node.type,
37
+ x: Math.round(node.x),
38
+ y: Math.round(node.y),
39
+ width: Math.round(node.width),
40
+ height: Math.round(node.height)
41
+ }
42
+ if (node.childIds.length > 0 && depth < maxDepth) {
43
+ result.children = node.childIds.map((cid) => buildJson(cid, depth + 1)).filter(Boolean)
44
+ }
45
+ return result
46
+ }
47
+ console.log(JSON.stringify(page.childIds.map((id) => buildJson(id, 0)), null, 2))
48
+ return
49
+ }
50
+
51
+ const root = {
52
+ header: entity(formatType(page.type), page.name, page.id),
53
+ children: page.childIds
54
+ .map((id) => graph.getNode(id))
55
+ .filter((n): n is SceneNode => n !== undefined)
56
+ .map((child) => nodeToTreeNode(graph, child, maxDepth))
57
+ }
58
+
59
+ console.log('')
60
+ console.log(fmtTree(root, { maxDepth }))
61
+ console.log('')
62
+ }
63
+ })
@@ -0,0 +1,95 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { loadDocument } from '../headless'
4
+ import { bold, fmtList, fmtSummary } from '../format'
5
+ import type { SceneGraph, Variable } from '@open-pencil/core'
6
+
7
+ function formatValue(variable: Variable, graph: SceneGraph): string {
8
+ const modeId = graph.getActiveModeId(variable.collectionId)
9
+ const raw = variable.valuesByMode[modeId]
10
+ if (raw === undefined) return '–'
11
+
12
+ if (typeof raw === 'object' && raw !== null && 'aliasId' in raw) {
13
+ const alias = graph.variables.get(raw.aliasId)
14
+ return alias ? `→ ${alias.name}` : `→ ${raw.aliasId}`
15
+ }
16
+
17
+ if (typeof raw === 'object' && 'r' in raw) {
18
+ const { r, g, b } = raw as { r: number; g: number; b: number }
19
+ return (
20
+ '#' +
21
+ [r, g, b]
22
+ .map((c) =>
23
+ Math.round(c * 255)
24
+ .toString(16)
25
+ .padStart(2, '0')
26
+ )
27
+ .join('')
28
+ )
29
+ }
30
+
31
+ return String(raw)
32
+ }
33
+
34
+ export default defineCommand({
35
+ meta: { description: 'List design variables and collections' },
36
+ args: {
37
+ file: { type: 'positional', description: '.fig file path', required: true },
38
+ collection: { type: 'string', description: 'Filter by collection name' },
39
+ type: { type: 'string', description: 'Filter by type: COLOR, FLOAT, STRING, BOOLEAN' },
40
+ json: { type: 'boolean', description: 'Output as JSON' }
41
+ },
42
+ async run({ args }) {
43
+ const graph = await loadDocument(args.file)
44
+
45
+ const collections = [...graph.variableCollections.values()]
46
+ const variables = [...graph.variables.values()]
47
+
48
+ if (variables.length === 0) {
49
+ console.log('No variables found.')
50
+ return
51
+ }
52
+
53
+ if (args.json) {
54
+ console.log(JSON.stringify({ collections, variables }, null, 2))
55
+ return
56
+ }
57
+
58
+ const typeFilter = args.type?.toUpperCase()
59
+ const collFilter = args.collection?.toLowerCase()
60
+
61
+ console.log('')
62
+
63
+ for (const coll of collections) {
64
+ if (collFilter && !coll.name.toLowerCase().includes(collFilter)) continue
65
+
66
+ const collVars = graph
67
+ .getVariablesForCollection(coll.id)
68
+ .filter((v) => !typeFilter || v.type === typeFilter)
69
+
70
+ if (collVars.length === 0) continue
71
+
72
+ const modes = coll.modes.map((m) => m.name).join(', ')
73
+ console.log(bold(` ${coll.name}`) + ` (${modes})`)
74
+ console.log('')
75
+ console.log(
76
+ fmtList(
77
+ collVars.map((v) => ({
78
+ header: v.name,
79
+ details: { value: formatValue(v, graph), type: v.type.toLowerCase() }
80
+ })),
81
+ { compact: true }
82
+ )
83
+ )
84
+ console.log('')
85
+ }
86
+
87
+ console.log(
88
+ fmtSummary({
89
+ variables: variables.length,
90
+ collections: collections.length
91
+ })
92
+ )
93
+ console.log('')
94
+ }
95
+ })