ingresseflow-bridge 1.0.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.
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 20
package/dist/index.js ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ import { install, uninstall } from './install.js';
3
+ const arg = process.argv[2];
4
+ if (arg === '--install') {
5
+ install();
6
+ process.exit(0);
7
+ }
8
+ if (arg === '--uninstall') {
9
+ uninstall();
10
+ process.exit(0);
11
+ }
12
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { WebSocketServer, WebSocket } from 'ws';
15
+ import { createServer } from 'http';
16
+ import { z } from 'zod';
17
+ // ── WebSocket bridge ──────────────────────────────────────────────────────────
18
+ let pluginSocket = null;
19
+ const pending = new Map();
20
+ function sendToPlugin(type, payload) {
21
+ return new Promise((resolve, reject) => {
22
+ if (!pluginSocket || pluginSocket.readyState !== WebSocket.OPEN) {
23
+ return reject(new Error('Plugin não conectado. Abra o IngresseFlow no Figma Desktop (FigJam).'));
24
+ }
25
+ const id = crypto.randomUUID();
26
+ const timer = setTimeout(() => {
27
+ pending.delete(id);
28
+ reject(new Error(`Timeout aguardando resposta para ${type}`));
29
+ }, 15_000);
30
+ pending.set(id, {
31
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
32
+ reject: (e) => { clearTimeout(timer); reject(e); },
33
+ timer,
34
+ });
35
+ pluginSocket.send(JSON.stringify({ id, type, payload }));
36
+ });
37
+ }
38
+ async function tryPort(port) {
39
+ return new Promise(resolve => {
40
+ const srv = createServer();
41
+ srv.once('error', () => resolve(false));
42
+ srv.listen(port, () => srv.close(() => resolve(true)));
43
+ });
44
+ }
45
+ async function startBridge() {
46
+ const BASE_PORT = 9243;
47
+ const MAX_PORT = 9250;
48
+ let port = BASE_PORT;
49
+ while (port <= MAX_PORT) {
50
+ if (await tryPort(port))
51
+ break;
52
+ port++;
53
+ }
54
+ if (port > MAX_PORT)
55
+ throw new Error('Nenhuma porta livre em 9243–9250');
56
+ const wss = new WebSocketServer({ port, host: '127.0.0.1' });
57
+ wss.on('connection', (ws) => {
58
+ pluginSocket = ws;
59
+ log(`Plugin conectado`);
60
+ ws.on('message', (raw) => {
61
+ try {
62
+ const msg = JSON.parse(raw.toString());
63
+ const p = pending.get(msg.id);
64
+ if (!p)
65
+ return;
66
+ pending.delete(msg.id);
67
+ if (msg.success)
68
+ p.resolve(msg.data);
69
+ else
70
+ p.reject(new Error(msg.error ?? 'Erro desconhecido'));
71
+ }
72
+ catch (e) {
73
+ log(`Erro ao parsear mensagem: ${e}`);
74
+ }
75
+ });
76
+ ws.on('close', () => {
77
+ pluginSocket = null;
78
+ log(`Plugin desconectado`);
79
+ });
80
+ });
81
+ log(`Bridge escutando em ws://localhost:${port}`);
82
+ log(`Abra o plugin IngresseFlow no Figma Desktop para conectar`);
83
+ return port;
84
+ }
85
+ // ── MCP tools ─────────────────────────────────────────────────────────────────
86
+ const ColorEnum = z.enum([
87
+ 'YELLOW', 'BLUE', 'GREEN', 'PINK', 'ORANGE',
88
+ 'PURPLE', 'RED', 'GRAY', 'LIGHT_GRAY',
89
+ ]);
90
+ const server = new McpServer({ name: 'ingresseflow', version: '1.0.0' });
91
+ server.tool('figjam_create_sticky', 'Cria um post-it no FigJam. Requer o plugin IngresseFlow aberto no Figma Desktop.', {
92
+ text: z.string().describe('Texto do post-it'),
93
+ color: ColorEnum.default('YELLOW').describe('Cor de fundo'),
94
+ x: z.number().optional().describe('Posição X no canvas'),
95
+ y: z.number().optional().describe('Posição Y no canvas'),
96
+ }, async ({ text, color, x, y }) => {
97
+ const result = await sendToPlugin('CREATE_STICKY', { text, color, x, y });
98
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
99
+ });
100
+ server.tool('figjam_create_stickies', 'Cria múltiplos post-its de uma vez no FigJam. Máximo 50 por chamada. Use para documentar análises completas.', {
101
+ stickies: z.array(z.object({
102
+ text: z.string(),
103
+ color: ColorEnum.default('YELLOW'),
104
+ x: z.number().optional(),
105
+ y: z.number().optional(),
106
+ })).max(50).describe('Lista de post-its para criar'),
107
+ }, async ({ stickies }) => {
108
+ const result = await sendToPlugin('CREATE_STICKIES', { stickies });
109
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
110
+ });
111
+ server.tool('figjam_read_board', 'Lê todo o conteúdo do FigJam atual. Retorna nós com texto, posição e tipo.', {}, async () => {
112
+ const result = await sendToPlugin('READ_BOARD', {});
113
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
114
+ });
115
+ server.tool('figjam_get_selection', 'Retorna os nós atualmente selecionados no Figma.', {}, async () => {
116
+ const result = await sendToPlugin('GET_SELECTION', {});
117
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
118
+ });
119
+ server.tool('figjam_create_section', 'Cria uma seção nomeada no FigJam para agrupar conteúdo.', {
120
+ name: z.string().describe('Nome da seção'),
121
+ x: z.number().optional().describe('Posição X'),
122
+ y: z.number().optional().describe('Posição Y'),
123
+ width: z.number().default(900).describe('Largura em px'),
124
+ height: z.number().default(700).describe('Altura em px'),
125
+ }, async ({ name, x, y, width, height }) => {
126
+ const result = await sendToPlugin('CREATE_SECTION', { name, x, y, width, height });
127
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
128
+ });
129
+ // ── HTTP command server (port 9242) ───────────────────────────────────────────
130
+ // Accepts POST /command { type, payload } — lets any local client send operations
131
+ // without needing an MCP session. Used by scripts and tests.
132
+ function startCommandServer() {
133
+ const http = createServer(async (req, res) => {
134
+ if (req.method !== 'POST' || req.url !== '/command') {
135
+ res.writeHead(404).end();
136
+ return;
137
+ }
138
+ let body = '';
139
+ req.on('data', (c) => { body += c; });
140
+ req.on('end', async () => {
141
+ try {
142
+ const { type, payload } = JSON.parse(body);
143
+ const result = await sendToPlugin(type, payload);
144
+ res.writeHead(200, { 'Content-Type': 'application/json' });
145
+ res.end(JSON.stringify({ success: true, data: result }));
146
+ }
147
+ catch (err) {
148
+ res.writeHead(200, { 'Content-Type': 'application/json' });
149
+ res.end(JSON.stringify({ success: false, error: err.message }));
150
+ }
151
+ });
152
+ });
153
+ http.listen(9242, '127.0.0.1', () => log('Comando HTTP em http://localhost:9242/command'));
154
+ }
155
+ // ── Boot ──────────────────────────────────────────────────────────────────────
156
+ function log(msg) {
157
+ process.stderr.write(`[IngresseFlow] ${msg}\n`);
158
+ }
159
+ await startBridge();
160
+ startCommandServer();
161
+ const transport = new StdioServerTransport();
162
+ await server.connect(transport);
@@ -0,0 +1,86 @@
1
+ import { writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { execSync } from 'child_process';
5
+ const SERVICE_ID = 'com.ingresse.flow.bridge';
6
+ const PLIST_DIR = join(homedir(), 'Library', 'LaunchAgents');
7
+ const PLIST_PATH = join(PLIST_DIR, `${SERVICE_ID}.plist`);
8
+ function nodeBin() {
9
+ try {
10
+ return execSync('which node', { encoding: 'utf8' }).trim();
11
+ }
12
+ catch {
13
+ return process.execPath;
14
+ }
15
+ }
16
+ function npmBin() {
17
+ try {
18
+ return execSync('which npx', { encoding: 'utf8' }).trim();
19
+ }
20
+ catch {
21
+ return 'npx';
22
+ }
23
+ }
24
+ export function install() {
25
+ if (process.platform !== 'darwin') {
26
+ console.error('Auto-instalação de serviço só é suportada no macOS.');
27
+ process.exit(1);
28
+ }
29
+ mkdirSync(PLIST_DIR, { recursive: true });
30
+ // Resolve the bridge entry point (same file that runs the server)
31
+ const bridgeEntry = new URL('../index.js', import.meta.url).pathname;
32
+ const node = nodeBin();
33
+ const logDir = join(homedir(), 'Library', 'Logs', 'IngresseFlow');
34
+ mkdirSync(logDir, { recursive: true });
35
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
36
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
37
+ <plist version="1.0">
38
+ <dict>
39
+ <key>Label</key>
40
+ <string>${SERVICE_ID}</string>
41
+ <key>ProgramArguments</key>
42
+ <array>
43
+ <string>${node}</string>
44
+ <string>${bridgeEntry}</string>
45
+ </array>
46
+ <key>RunAtLoad</key>
47
+ <true/>
48
+ <key>KeepAlive</key>
49
+ <true/>
50
+ <key>StandardOutPath</key>
51
+ <string>${logDir}/bridge.log</string>
52
+ <key>StandardErrorPath</key>
53
+ <string>${logDir}/bridge.error.log</string>
54
+ <key>EnvironmentVariables</key>
55
+ <dict>
56
+ <key>PATH</key>
57
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
58
+ </dict>
59
+ </dict>
60
+ </plist>`;
61
+ writeFileSync(PLIST_PATH, plist, 'utf8');
62
+ console.log(`✓ Serviço criado em ${PLIST_PATH}`);
63
+ try {
64
+ execSync(`launchctl load -w "${PLIST_PATH}"`, { stdio: 'inherit' });
65
+ console.log('✓ Serviço iniciado. O bridge vai rodar automaticamente no login.');
66
+ }
67
+ catch {
68
+ console.log('⚠ Execute manualmente para iniciar agora:');
69
+ console.log(` launchctl load -w "${PLIST_PATH}"`);
70
+ }
71
+ console.log('\nAgora configure o Claude Code (uma única vez):');
72
+ console.log(` claude mcp add ingresseflow -- ${node} ${bridgeEntry}`);
73
+ console.log('\nDepois disso, só abra o plugin IngresseFlow no Figma.');
74
+ }
75
+ export function uninstall() {
76
+ if (!existsSync(PLIST_PATH)) {
77
+ console.log('Serviço não instalado.');
78
+ return;
79
+ }
80
+ try {
81
+ execSync(`launchctl unload -w "${PLIST_PATH}"`, { stdio: 'inherit' });
82
+ }
83
+ catch { /* já estava parado */ }
84
+ unlinkSync(PLIST_PATH);
85
+ console.log('✓ Serviço removido.');
86
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "ingresseflow-bridge",
3
+ "version": "1.0.0",
4
+ "description": "Bridge MCP server for IngresseFlow — connects Claude Code to FigJam via Figma Desktop plugin",
5
+ "type": "module",
6
+ "bin": {
7
+ "ingresseflow-bridge": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsx src/index.ts",
12
+ "start": "node dist/index.js"
13
+ },
14
+ "dependencies": {
15
+ "@modelcontextprotocol/sdk": "^1.12.0",
16
+ "ws": "^8.18.0",
17
+ "zod": "^3.24.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.0.0",
21
+ "@types/ws": "^8.5.13",
22
+ "tsx": "^4.19.0",
23
+ "typescript": "^5.9.3"
24
+ }
25
+ }
package/src/index.js ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { WebSocketServer, WebSocket } from 'ws';
5
+ import { createServer } from 'http';
6
+ import { z } from 'zod';
7
+ // ── WebSocket bridge ──────────────────────────────────────────────────────────
8
+ let pluginSocket = null;
9
+ const pending = new Map();
10
+ function sendToPlugin(type, payload) {
11
+ return new Promise((resolve, reject) => {
12
+ if (!pluginSocket || pluginSocket.readyState !== WebSocket.OPEN) {
13
+ return reject(new Error('Plugin não conectado. Abra o IngresseFlow no Figma Desktop (FigJam).'));
14
+ }
15
+ const id = crypto.randomUUID();
16
+ const timer = setTimeout(() => {
17
+ pending.delete(id);
18
+ reject(new Error(`Timeout aguardando resposta para ${type}`));
19
+ }, 15000);
20
+ pending.set(id, {
21
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
22
+ reject: (e) => { clearTimeout(timer); reject(e); },
23
+ timer,
24
+ });
25
+ pluginSocket.send(JSON.stringify({ id, type, payload }));
26
+ });
27
+ }
28
+ async function tryPort(port) {
29
+ return new Promise(resolve => {
30
+ const srv = createServer();
31
+ srv.once('error', () => resolve(false));
32
+ srv.listen(port, () => srv.close(() => resolve(true)));
33
+ });
34
+ }
35
+ async function startBridge() {
36
+ const BASE_PORT = 9243;
37
+ const MAX_PORT = 9250;
38
+ let port = BASE_PORT;
39
+ while (port <= MAX_PORT) {
40
+ if (await tryPort(port))
41
+ break;
42
+ port++;
43
+ }
44
+ if (port > MAX_PORT)
45
+ throw new Error('Nenhuma porta livre em 9243–9250');
46
+ const wss = new WebSocketServer({ port });
47
+ wss.on('connection', (ws) => {
48
+ pluginSocket = ws;
49
+ log(`Plugin conectado`);
50
+ ws.on('message', (raw) => {
51
+ try {
52
+ const msg = JSON.parse(raw.toString());
53
+ const p = pending.get(msg.id);
54
+ if (!p)
55
+ return;
56
+ pending.delete(msg.id);
57
+ if (msg.success)
58
+ p.resolve(msg.data);
59
+ else
60
+ p.reject(new Error(msg.error ?? 'Erro desconhecido'));
61
+ }
62
+ catch (e) {
63
+ log(`Erro ao parsear mensagem: ${e}`);
64
+ }
65
+ });
66
+ ws.on('close', () => {
67
+ pluginSocket = null;
68
+ log(`Plugin desconectado`);
69
+ });
70
+ });
71
+ log(`Bridge escutando em ws://localhost:${port}`);
72
+ log(`Abra o plugin IngresseFlow no Figma Desktop para conectar`);
73
+ return port;
74
+ }
75
+ // ── MCP tools ─────────────────────────────────────────────────────────────────
76
+ const ColorEnum = z.enum([
77
+ 'YELLOW', 'BLUE', 'GREEN', 'PINK', 'ORANGE',
78
+ 'PURPLE', 'RED', 'GRAY', 'LIGHT_GRAY',
79
+ ]);
80
+ const server = new McpServer({ name: 'ingresseflow', version: '1.0.0' });
81
+ server.tool('figjam_create_sticky', 'Cria um post-it no FigJam. Requer o plugin IngresseFlow aberto no Figma Desktop.', {
82
+ text: z.string().describe('Texto do post-it'),
83
+ color: ColorEnum.default('YELLOW').describe('Cor de fundo'),
84
+ x: z.number().optional().describe('Posição X no canvas'),
85
+ y: z.number().optional().describe('Posição Y no canvas'),
86
+ }, async ({ text, color, x, y }) => {
87
+ const result = await sendToPlugin('CREATE_STICKY', { text, color, x, y });
88
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
89
+ });
90
+ server.tool('figjam_create_stickies', 'Cria múltiplos post-its de uma vez no FigJam. Máximo 50 por chamada. Use para documentar análises completas.', {
91
+ stickies: z.array(z.object({
92
+ text: z.string(),
93
+ color: ColorEnum.default('YELLOW'),
94
+ x: z.number().optional(),
95
+ y: z.number().optional(),
96
+ })).max(50).describe('Lista de post-its para criar'),
97
+ }, async ({ stickies }) => {
98
+ const result = await sendToPlugin('CREATE_STICKIES', { stickies });
99
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
100
+ });
101
+ server.tool('figjam_read_board', 'Lê todo o conteúdo do FigJam atual. Retorna nós com texto, posição e tipo.', {}, async () => {
102
+ const result = await sendToPlugin('READ_BOARD', {});
103
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
104
+ });
105
+ server.tool('figjam_get_selection', 'Retorna os nós atualmente selecionados no Figma.', {}, async () => {
106
+ const result = await sendToPlugin('GET_SELECTION', {});
107
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
108
+ });
109
+ server.tool('figjam_create_section', 'Cria uma seção nomeada no FigJam para agrupar conteúdo.', {
110
+ name: z.string().describe('Nome da seção'),
111
+ x: z.number().optional().describe('Posição X'),
112
+ y: z.number().optional().describe('Posição Y'),
113
+ width: z.number().default(900).describe('Largura em px'),
114
+ height: z.number().default(700).describe('Altura em px'),
115
+ }, async ({ name, x, y, width, height }) => {
116
+ const result = await sendToPlugin('CREATE_SECTION', { name, x, y, width, height });
117
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
118
+ });
119
+ // ── Boot ──────────────────────────────────────────────────────────────────────
120
+ function log(msg) {
121
+ process.stderr.write(`[IngresseFlow] ${msg}\n`);
122
+ }
123
+ await startBridge();
124
+ const transport = new StdioServerTransport();
125
+ await server.connect(transport);
package/src/index.ts ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ import { install, uninstall } from './install.js';
3
+
4
+ const arg = process.argv[2];
5
+ if (arg === '--install') { install(); process.exit(0); }
6
+ if (arg === '--uninstall') { uninstall(); process.exit(0); }
7
+
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import { WebSocketServer, WebSocket } from 'ws';
11
+ import { createServer } from 'http';
12
+ import { z } from 'zod';
13
+
14
+ // ── WebSocket bridge ──────────────────────────────────────────────────────────
15
+
16
+ let pluginSocket: WebSocket | null = null;
17
+
18
+ const pending = new Map<string, {
19
+ resolve: (v: unknown) => void;
20
+ reject: (e: Error) => void;
21
+ timer: ReturnType<typeof setTimeout>;
22
+ }>();
23
+
24
+ function sendToPlugin<T>(type: string, payload: unknown): Promise<T> {
25
+ return new Promise((resolve, reject) => {
26
+ if (!pluginSocket || pluginSocket.readyState !== WebSocket.OPEN) {
27
+ return reject(new Error(
28
+ 'Plugin não conectado. Abra o IngresseFlow no Figma Desktop (FigJam).'
29
+ ));
30
+ }
31
+ const id = crypto.randomUUID();
32
+ const timer = setTimeout(() => {
33
+ pending.delete(id);
34
+ reject(new Error(`Timeout aguardando resposta para ${type}`));
35
+ }, 15_000);
36
+
37
+ pending.set(id, {
38
+ resolve: (v) => { clearTimeout(timer); resolve(v as T); },
39
+ reject: (e) => { clearTimeout(timer); reject(e); },
40
+ timer,
41
+ });
42
+
43
+ pluginSocket.send(JSON.stringify({ id, type, payload }));
44
+ });
45
+ }
46
+
47
+ async function tryPort(port: number): Promise<boolean> {
48
+ return new Promise(resolve => {
49
+ const srv = createServer();
50
+ srv.once('error', () => resolve(false));
51
+ srv.listen(port, () => srv.close(() => resolve(true)));
52
+ });
53
+ }
54
+
55
+ async function startBridge(): Promise<number> {
56
+ const BASE_PORT = 9243;
57
+ const MAX_PORT = 9250;
58
+ let port = BASE_PORT;
59
+
60
+ while (port <= MAX_PORT) {
61
+ if (await tryPort(port)) break;
62
+ port++;
63
+ }
64
+ if (port > MAX_PORT) throw new Error('Nenhuma porta livre em 9243–9250');
65
+
66
+ const wss = new WebSocketServer({ port, host: '127.0.0.1' });
67
+
68
+ wss.on('connection', (ws) => {
69
+ pluginSocket = ws;
70
+ log(`Plugin conectado`);
71
+
72
+ ws.on('message', (raw) => {
73
+ try {
74
+ const msg = JSON.parse(raw.toString()) as {
75
+ id: string; success: boolean; data?: unknown; error?: string;
76
+ };
77
+ const p = pending.get(msg.id);
78
+ if (!p) return;
79
+ pending.delete(msg.id);
80
+ if (msg.success) p.resolve(msg.data);
81
+ else p.reject(new Error(msg.error ?? 'Erro desconhecido'));
82
+ } catch (e) {
83
+ log(`Erro ao parsear mensagem: ${e}`);
84
+ }
85
+ });
86
+
87
+ ws.on('close', () => {
88
+ pluginSocket = null;
89
+ log(`Plugin desconectado`);
90
+ });
91
+ });
92
+
93
+ log(`Bridge escutando em ws://localhost:${port}`);
94
+ log(`Abra o plugin IngresseFlow no Figma Desktop para conectar`);
95
+ return port;
96
+ }
97
+
98
+ // ── MCP tools ─────────────────────────────────────────────────────────────────
99
+
100
+ const ColorEnum = z.enum([
101
+ 'YELLOW', 'BLUE', 'GREEN', 'PINK', 'ORANGE',
102
+ 'PURPLE', 'RED', 'GRAY', 'LIGHT_GRAY',
103
+ ]);
104
+
105
+ const server = new McpServer({ name: 'ingresseflow', version: '1.0.0' });
106
+
107
+ server.tool(
108
+ 'figjam_create_sticky',
109
+ 'Cria um post-it no FigJam. Requer o plugin IngresseFlow aberto no Figma Desktop.',
110
+ {
111
+ text: z.string().describe('Texto do post-it'),
112
+ color: ColorEnum.default('YELLOW').describe('Cor de fundo'),
113
+ x: z.number().optional().describe('Posição X no canvas'),
114
+ y: z.number().optional().describe('Posição Y no canvas'),
115
+ },
116
+ async ({ text, color, x, y }) => {
117
+ const result = await sendToPlugin('CREATE_STICKY', { text, color, x, y });
118
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
119
+ }
120
+ );
121
+
122
+ server.tool(
123
+ 'figjam_create_stickies',
124
+ 'Cria múltiplos post-its de uma vez no FigJam. Máximo 50 por chamada. Use para documentar análises completas.',
125
+ {
126
+ stickies: z.array(z.object({
127
+ text: z.string(),
128
+ color: ColorEnum.default('YELLOW'),
129
+ x: z.number().optional(),
130
+ y: z.number().optional(),
131
+ })).max(50).describe('Lista de post-its para criar'),
132
+ },
133
+ async ({ stickies }) => {
134
+ const result = await sendToPlugin('CREATE_STICKIES', { stickies });
135
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
136
+ }
137
+ );
138
+
139
+ server.tool(
140
+ 'figjam_read_board',
141
+ 'Lê todo o conteúdo do FigJam atual. Retorna nós com texto, posição e tipo.',
142
+ {},
143
+ async () => {
144
+ const result = await sendToPlugin('READ_BOARD', {});
145
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
146
+ }
147
+ );
148
+
149
+ server.tool(
150
+ 'figjam_get_selection',
151
+ 'Retorna os nós atualmente selecionados no Figma.',
152
+ {},
153
+ async () => {
154
+ const result = await sendToPlugin('GET_SELECTION', {});
155
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
156
+ }
157
+ );
158
+
159
+ server.tool(
160
+ 'figjam_create_section',
161
+ 'Cria uma seção nomeada no FigJam para agrupar conteúdo.',
162
+ {
163
+ name: z.string().describe('Nome da seção'),
164
+ x: z.number().optional().describe('Posição X'),
165
+ y: z.number().optional().describe('Posição Y'),
166
+ width: z.number().default(900).describe('Largura em px'),
167
+ height: z.number().default(700).describe('Altura em px'),
168
+ },
169
+ async ({ name, x, y, width, height }) => {
170
+ const result = await sendToPlugin('CREATE_SECTION', { name, x, y, width, height });
171
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
172
+ }
173
+ );
174
+
175
+ // ── HTTP command server (port 9242) ───────────────────────────────────────────
176
+ // Accepts POST /command { type, payload } — lets any local client send operations
177
+ // without needing an MCP session. Used by scripts and tests.
178
+
179
+ function startCommandServer() {
180
+ const http = createServer(async (req, res) => {
181
+ if (req.method !== 'POST' || req.url !== '/command') {
182
+ res.writeHead(404).end();
183
+ return;
184
+ }
185
+ let body = '';
186
+ req.on('data', (c: Buffer) => { body += c; });
187
+ req.on('end', async () => {
188
+ try {
189
+ const { type, payload } = JSON.parse(body) as { type: string; payload: unknown };
190
+ const result = await sendToPlugin(type, payload);
191
+ res.writeHead(200, { 'Content-Type': 'application/json' });
192
+ res.end(JSON.stringify({ success: true, data: result }));
193
+ } catch (err) {
194
+ res.writeHead(200, { 'Content-Type': 'application/json' });
195
+ res.end(JSON.stringify({ success: false, error: (err as Error).message }));
196
+ }
197
+ });
198
+ });
199
+ http.listen(9242, '127.0.0.1', () => log('Comando HTTP em http://localhost:9242/command'));
200
+ }
201
+
202
+ // ── Boot ──────────────────────────────────────────────────────────────────────
203
+
204
+ function log(msg: string) {
205
+ process.stderr.write(`[IngresseFlow] ${msg}\n`);
206
+ }
207
+
208
+ await startBridge();
209
+ startCommandServer();
210
+
211
+ const transport = new StdioServerTransport();
212
+ await server.connect(transport);
package/src/install.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { execSync } from 'child_process';
5
+
6
+ const SERVICE_ID = 'com.ingresse.flow.bridge';
7
+ const PLIST_DIR = join(homedir(), 'Library', 'LaunchAgents');
8
+ const PLIST_PATH = join(PLIST_DIR, `${SERVICE_ID}.plist`);
9
+
10
+ function nodeBin(): string {
11
+ try { return execSync('which node', { encoding: 'utf8' }).trim(); }
12
+ catch { return process.execPath; }
13
+ }
14
+
15
+ function npmBin(): string {
16
+ try { return execSync('which npx', { encoding: 'utf8' }).trim(); }
17
+ catch { return 'npx'; }
18
+ }
19
+
20
+ export function install() {
21
+ if (process.platform !== 'darwin') {
22
+ console.error('Auto-instalação de serviço só é suportada no macOS.');
23
+ process.exit(1);
24
+ }
25
+
26
+ mkdirSync(PLIST_DIR, { recursive: true });
27
+
28
+ // Resolve the bridge entry point (same file that runs the server)
29
+ const bridgeEntry = new URL('../index.js', import.meta.url).pathname;
30
+ const node = nodeBin();
31
+ const logDir = join(homedir(), 'Library', 'Logs', 'IngresseFlow');
32
+ mkdirSync(logDir, { recursive: true });
33
+
34
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
35
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
36
+ <plist version="1.0">
37
+ <dict>
38
+ <key>Label</key>
39
+ <string>${SERVICE_ID}</string>
40
+ <key>ProgramArguments</key>
41
+ <array>
42
+ <string>${node}</string>
43
+ <string>${bridgeEntry}</string>
44
+ </array>
45
+ <key>RunAtLoad</key>
46
+ <true/>
47
+ <key>KeepAlive</key>
48
+ <true/>
49
+ <key>StandardOutPath</key>
50
+ <string>${logDir}/bridge.log</string>
51
+ <key>StandardErrorPath</key>
52
+ <string>${logDir}/bridge.error.log</string>
53
+ <key>EnvironmentVariables</key>
54
+ <dict>
55
+ <key>PATH</key>
56
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
57
+ </dict>
58
+ </dict>
59
+ </plist>`;
60
+
61
+ writeFileSync(PLIST_PATH, plist, 'utf8');
62
+ console.log(`✓ Serviço criado em ${PLIST_PATH}`);
63
+
64
+ try {
65
+ execSync(`launchctl load -w "${PLIST_PATH}"`, { stdio: 'inherit' });
66
+ console.log('✓ Serviço iniciado. O bridge vai rodar automaticamente no login.');
67
+ } catch {
68
+ console.log('⚠ Execute manualmente para iniciar agora:');
69
+ console.log(` launchctl load -w "${PLIST_PATH}"`);
70
+ }
71
+
72
+ console.log('\nAgora configure o Claude Code (uma única vez):');
73
+ console.log(` claude mcp add ingresseflow -- ${node} ${bridgeEntry}`);
74
+ console.log('\nDepois disso, só abra o plugin IngresseFlow no Figma.');
75
+ }
76
+
77
+ export function uninstall() {
78
+ if (!existsSync(PLIST_PATH)) {
79
+ console.log('Serviço não instalado.');
80
+ return;
81
+ }
82
+ try {
83
+ execSync(`launchctl unload -w "${PLIST_PATH}"`, { stdio: 'inherit' });
84
+ } catch { /* já estava parado */ }
85
+ unlinkSync(PLIST_PATH);
86
+ console.log('✓ Serviço removido.');
87
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["src"]
12
+ }