opencodekit 0.8.0 → 0.9.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,458 @@
1
+ import { type ChildProcess, spawn } from "child_process";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import { type Plugin, tool } from "@opencode-ai/plugin";
6
+
7
+ interface McpServerConfig {
8
+ command: string;
9
+ args?: string[];
10
+ env?: Record<string, string>;
11
+ }
12
+
13
+ interface McpClient {
14
+ process: ChildProcess;
15
+ config: McpServerConfig;
16
+ requestId: number;
17
+ pendingRequests: Map<
18
+ number,
19
+ { resolve: (v: any) => void; reject: (e: any) => void }
20
+ >;
21
+ capabilities?: {
22
+ tools?: any[];
23
+ resources?: any[];
24
+ prompts?: any[];
25
+ };
26
+ }
27
+
28
+ interface SkillMcpState {
29
+ clients: Map<string, McpClient>; // key: skillName:serverName
30
+ loadedSkills: Map<string, Record<string, McpServerConfig>>; // skillName -> mcp configs
31
+ }
32
+
33
+ function parseYamlFrontmatter(content: string): {
34
+ frontmatter: any;
35
+ body: string;
36
+ } {
37
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
38
+ if (!match) return { frontmatter: {}, body: content };
39
+
40
+ const yamlStr = match[1];
41
+ const body = match[2];
42
+
43
+ // Simple YAML parser for our use case
44
+ const frontmatter: any = {};
45
+ let currentKey = "";
46
+ let currentIndent = 0;
47
+ let mcpConfig: any = null;
48
+ let serverName = "";
49
+ let serverConfig: any = {};
50
+
51
+ for (const line of yamlStr.split("\n")) {
52
+ const trimmed = line.trim();
53
+ if (!trimmed || trimmed.startsWith("#")) continue;
54
+
55
+ const indent = line.search(/\S/);
56
+ const keyMatch = trimmed.match(/^(\w+):\s*(.*)$/);
57
+
58
+ if (keyMatch) {
59
+ const [, key, value] = keyMatch;
60
+
61
+ if (indent === 0) {
62
+ // Top-level key
63
+ if (key === "mcp") {
64
+ mcpConfig = {};
65
+ frontmatter.mcp = mcpConfig;
66
+ } else {
67
+ frontmatter[key] = value || undefined;
68
+ }
69
+ currentKey = key;
70
+ currentIndent = indent;
71
+ } else if (mcpConfig !== null && indent === 2) {
72
+ // Server name under mcp
73
+ serverName = key;
74
+ serverConfig = {};
75
+ mcpConfig[serverName] = serverConfig;
76
+ } else if (serverConfig && indent === 4) {
77
+ // Server config property
78
+ if (key === "command") {
79
+ serverConfig.command = value;
80
+ } else if (key === "args") {
81
+ // Parse inline array or set up for multi-line
82
+ if (value.startsWith("[")) {
83
+ try {
84
+ serverConfig.args = JSON.parse(value);
85
+ } catch {
86
+ serverConfig.args = [];
87
+ }
88
+ } else {
89
+ serverConfig.args = [];
90
+ }
91
+ }
92
+ }
93
+ } else if (trimmed.startsWith("- ") && serverConfig?.args) {
94
+ // Array item for args
95
+ const item = trimmed.slice(2).replace(/^["']|["']$/g, "");
96
+ serverConfig.args.push(item);
97
+ }
98
+ }
99
+
100
+ return { frontmatter, body };
101
+ }
102
+
103
+ function findSkillPath(skillName: string, projectDir: string): string | null {
104
+ const locations = [
105
+ join(projectDir, ".opencode", "skill", skillName, "SKILL.md"),
106
+ join(homedir(), ".config", "opencode", "skill", skillName, "SKILL.md"),
107
+ ];
108
+
109
+ for (const loc of locations) {
110
+ if (existsSync(loc)) return loc;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ export const SkillMcpPlugin: Plugin = async ({ directory }) => {
116
+ const state: SkillMcpState = {
117
+ clients: new Map(),
118
+ loadedSkills: new Map(),
119
+ };
120
+
121
+ function getClientKey(skillName: string, serverName: string): string {
122
+ return `${skillName}:${serverName}`;
123
+ }
124
+
125
+ async function sendRequest(
126
+ client: McpClient,
127
+ method: string,
128
+ params?: any,
129
+ ): Promise<any> {
130
+ return new Promise((resolve, reject) => {
131
+ const id = ++client.requestId;
132
+ const request = {
133
+ jsonrpc: "2.0",
134
+ id,
135
+ method,
136
+ params: params || {},
137
+ };
138
+
139
+ client.pendingRequests.set(id, { resolve, reject });
140
+
141
+ const timeout = setTimeout(() => {
142
+ client.pendingRequests.delete(id);
143
+ reject(new Error(`Request timeout: ${method}`));
144
+ }, 30000);
145
+
146
+ client.pendingRequests.set(id, {
147
+ resolve: (v) => {
148
+ clearTimeout(timeout);
149
+ resolve(v);
150
+ },
151
+ reject: (e) => {
152
+ clearTimeout(timeout);
153
+ reject(e);
154
+ },
155
+ });
156
+
157
+ client.process.stdin?.write(JSON.stringify(request) + "\n");
158
+ });
159
+ }
160
+
161
+ async function connectServer(
162
+ skillName: string,
163
+ serverName: string,
164
+ config: McpServerConfig,
165
+ ): Promise<McpClient> {
166
+ const key = getClientKey(skillName, serverName);
167
+
168
+ // Return existing client if connected
169
+ const existing = state.clients.get(key);
170
+ if (existing && !existing.process.killed) {
171
+ return existing;
172
+ }
173
+
174
+ // Spawn MCP server process
175
+ const proc = spawn(config.command, config.args || [], {
176
+ stdio: ["pipe", "pipe", "pipe"],
177
+ env: { ...process.env, ...config.env },
178
+ shell: true,
179
+ });
180
+
181
+ const client: McpClient = {
182
+ process: proc,
183
+ config,
184
+ requestId: 0,
185
+ pendingRequests: new Map(),
186
+ };
187
+
188
+ // Handle stdout (JSON-RPC responses)
189
+ let buffer = "";
190
+ proc.stdout?.on("data", (data) => {
191
+ buffer += data.toString();
192
+ const lines = buffer.split("\n");
193
+ buffer = lines.pop() || "";
194
+
195
+ for (const line of lines) {
196
+ if (!line.trim()) continue;
197
+ try {
198
+ const response = JSON.parse(line);
199
+ if (response.id !== undefined) {
200
+ const pending = client.pendingRequests.get(response.id);
201
+ if (pending) {
202
+ client.pendingRequests.delete(response.id);
203
+ if (response.error) {
204
+ pending.reject(new Error(response.error.message));
205
+ } else {
206
+ pending.resolve(response.result);
207
+ }
208
+ }
209
+ }
210
+ } catch {}
211
+ }
212
+ });
213
+
214
+ proc.on("error", (err) => {
215
+ console.error(`MCP server error [${key}]:`, err.message);
216
+ });
217
+
218
+ proc.on("exit", (code) => {
219
+ state.clients.delete(key);
220
+ });
221
+
222
+ state.clients.set(key, client);
223
+
224
+ // Initialize connection
225
+ try {
226
+ await sendRequest(client, "initialize", {
227
+ protocolVersion: "2024-11-05",
228
+ capabilities: {},
229
+ clientInfo: { name: "opencode-skill-mcp", version: "1.0.0" },
230
+ });
231
+
232
+ // Send initialized notification
233
+ proc.stdin?.write(
234
+ JSON.stringify({
235
+ jsonrpc: "2.0",
236
+ method: "notifications/initialized",
237
+ }) + "\n",
238
+ );
239
+
240
+ // Discover capabilities
241
+ try {
242
+ const toolsResult = await sendRequest(client, "tools/list", {});
243
+ client.capabilities = { tools: toolsResult.tools || [] };
244
+ } catch {
245
+ client.capabilities = { tools: [] };
246
+ }
247
+ } catch (e: any) {
248
+ proc.kill();
249
+ state.clients.delete(key);
250
+ throw new Error(`Failed to initialize MCP server: ${e.message}`);
251
+ }
252
+
253
+ return client;
254
+ }
255
+
256
+ function disconnectAll() {
257
+ for (const [key, client] of state.clients) {
258
+ client.process.kill();
259
+ }
260
+ state.clients.clear();
261
+ }
262
+
263
+ return {
264
+ tool: {
265
+ skill_mcp: tool({
266
+ description: `Invoke MCP tools from skill-embedded MCP servers.
267
+
268
+ When a skill declares MCP servers in its YAML frontmatter, use this tool to:
269
+ - List available tools: skill_mcp(skill_name="playwright", list_tools=true)
270
+ - Call a tool: skill_mcp(skill_name="playwright", tool_name="browser_navigate", arguments='{"url": "..."}')
271
+
272
+ The skill must be loaded first via the skill() tool to register its MCP config.`,
273
+ args: {
274
+ skill_name: tool.schema
275
+ .string()
276
+ .describe("Name of the loaded skill with MCP config"),
277
+ mcp_name: tool.schema
278
+ .string()
279
+ .optional()
280
+ .describe("Specific MCP server name (if skill has multiple)"),
281
+ list_tools: tool.schema
282
+ .boolean()
283
+ .optional()
284
+ .describe("List available tools from this MCP"),
285
+ tool_name: tool.schema
286
+ .string()
287
+ .optional()
288
+ .describe("MCP tool to invoke"),
289
+ arguments: tool.schema
290
+ .string()
291
+ .optional()
292
+ .describe("JSON string of tool arguments"),
293
+ },
294
+ async execute(args) {
295
+ const {
296
+ skill_name,
297
+ mcp_name,
298
+ list_tools,
299
+ tool_name,
300
+ arguments: argsJson,
301
+ } = args;
302
+
303
+ if (!skill_name) {
304
+ return JSON.stringify({ error: "skill_name required" });
305
+ }
306
+
307
+ // Find skill and parse its MCP config
308
+ const skillPath = findSkillPath(skill_name, directory);
309
+ if (!skillPath) {
310
+ return JSON.stringify({ error: `Skill '${skill_name}' not found` });
311
+ }
312
+
313
+ const content = readFileSync(skillPath, "utf-8");
314
+ const { frontmatter } = parseYamlFrontmatter(content);
315
+
316
+ if (!frontmatter.mcp || Object.keys(frontmatter.mcp).length === 0) {
317
+ return JSON.stringify({
318
+ error: `Skill '${skill_name}' has no MCP config`,
319
+ });
320
+ }
321
+
322
+ // Determine which MCP server to use
323
+ const mcpServers = frontmatter.mcp as Record<string, McpServerConfig>;
324
+ const serverNames = Object.keys(mcpServers);
325
+ const targetServer = mcp_name || serverNames[0];
326
+
327
+ if (!mcpServers[targetServer]) {
328
+ return JSON.stringify({
329
+ error: `MCP server '${targetServer}' not found in skill`,
330
+ available: serverNames,
331
+ });
332
+ }
333
+
334
+ const serverConfig = mcpServers[targetServer];
335
+
336
+ // Connect to MCP server
337
+ let client: McpClient;
338
+ try {
339
+ client = await connectServer(
340
+ skill_name,
341
+ targetServer,
342
+ serverConfig,
343
+ );
344
+ } catch (e: any) {
345
+ return JSON.stringify({ error: `Failed to connect: ${e.message}` });
346
+ }
347
+
348
+ // List tools
349
+ if (list_tools) {
350
+ return JSON.stringify(
351
+ {
352
+ mcp: targetServer,
353
+ tools:
354
+ client.capabilities?.tools?.map((t: any) => ({
355
+ name: t.name,
356
+ description: t.description,
357
+ schema: t.inputSchema,
358
+ })) || [],
359
+ },
360
+ null,
361
+ 2,
362
+ );
363
+ }
364
+
365
+ // Call tool
366
+ if (tool_name) {
367
+ let toolArgs = {};
368
+ if (argsJson) {
369
+ try {
370
+ toolArgs = JSON.parse(argsJson);
371
+ } catch (e) {
372
+ return JSON.stringify({ error: "Invalid JSON in arguments" });
373
+ }
374
+ }
375
+
376
+ try {
377
+ const result = await sendRequest(client, "tools/call", {
378
+ name: tool_name,
379
+ arguments: toolArgs,
380
+ });
381
+ return JSON.stringify({ result }, null, 2);
382
+ } catch (e: any) {
383
+ return JSON.stringify({
384
+ error: `Tool call failed: ${e.message}`,
385
+ });
386
+ }
387
+ }
388
+
389
+ return JSON.stringify({
390
+ error: "Specify either list_tools=true or tool_name to call",
391
+ mcp: targetServer,
392
+ available_tools:
393
+ client.capabilities?.tools?.map((t: any) => t.name) || [],
394
+ });
395
+ },
396
+ }),
397
+
398
+ skill_mcp_status: tool({
399
+ description: "Show status of connected MCP servers from skills.",
400
+ args: {},
401
+ async execute() {
402
+ const servers: any[] = [];
403
+ for (const [key, client] of state.clients) {
404
+ const [skillName, serverName] = key.split(":");
405
+ servers.push({
406
+ skill: skillName,
407
+ server: serverName,
408
+ connected: !client.process.killed,
409
+ tools: client.capabilities?.tools?.length || 0,
410
+ });
411
+ }
412
+ return JSON.stringify({
413
+ connected_servers: servers,
414
+ count: servers.length,
415
+ });
416
+ },
417
+ }),
418
+
419
+ skill_mcp_disconnect: tool({
420
+ description:
421
+ "Disconnect MCP servers. Use when done with browser automation etc.",
422
+ args: {
423
+ skill_name: tool.schema
424
+ .string()
425
+ .optional()
426
+ .describe("Specific skill to disconnect (all if omitted)"),
427
+ },
428
+ async execute(args) {
429
+ if (args.skill_name) {
430
+ const toDisconnect: string[] = [];
431
+ for (const key of state.clients.keys()) {
432
+ if (key.startsWith(args.skill_name + ":")) {
433
+ toDisconnect.push(key);
434
+ }
435
+ }
436
+ for (const key of toDisconnect) {
437
+ const client = state.clients.get(key);
438
+ client?.process.kill();
439
+ state.clients.delete(key);
440
+ }
441
+ return JSON.stringify({ disconnected: toDisconnect });
442
+ } else {
443
+ const count = state.clients.size;
444
+ disconnectAll();
445
+ return JSON.stringify({ disconnected: "all", count });
446
+ }
447
+ },
448
+ }),
449
+ },
450
+
451
+ event: async ({ event }) => {
452
+ // Cleanup on session idle (closest available event)
453
+ if (event.type === "session.idle") {
454
+ // Optional: could disconnect idle servers here
455
+ }
456
+ },
457
+ };
458
+ };
@@ -0,0 +1,214 @@
1
+ ---
2
+ name: figma
3
+ description: Access Figma design data via Framelink MCP. Fetch layout, styles, components from Figma files/frames. Use when implementing UI from Figma designs, extracting design tokens, or downloading assets.
4
+ mcp:
5
+ figma:
6
+ command: npx
7
+ args: ["-y", "figma-developer-mcp", "--stdio"]
8
+ env:
9
+ FIGMA_API_KEY: "${FIGMA_API_KEY}"
10
+ ---
11
+
12
+ # Figma Design Data (MCP)
13
+
14
+ Access Figma design data via the Framelink MCP server. When this skill is loaded, the `figma` MCP server auto-starts and exposes tools to fetch design data and download images.
15
+
16
+ ## Prerequisites
17
+
18
+ Set your Figma API key as an environment variable:
19
+
20
+ ```bash
21
+ export FIGMA_API_KEY="your-figma-personal-access-token"
22
+ ```
23
+
24
+ To create a Figma Personal Access Token:
25
+
26
+ 1. Go to Figma → Settings → Account → Personal access tokens
27
+ 2. Create a new token with read access
28
+ 3. See: https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens
29
+
30
+ ## Quick Start
31
+
32
+ After loading this skill, use `skill_mcp` to invoke Figma tools:
33
+
34
+ ```
35
+ skill_mcp(skill_name="figma", tool_name="get_figma_data", arguments='{"fileKey": "abc123", "nodeId": "1234:5678"}')
36
+ ```
37
+
38
+ ## Available Tools
39
+
40
+ ### get_figma_data
41
+
42
+ Fetch comprehensive Figma file data including layout, content, visuals, and component information.
43
+
44
+ | Parameter | Type | Required | Description |
45
+ | --------- | ------ | -------- | ------------------------------------------------------------------------------------------------- |
46
+ | `fileKey` | string | Yes | The Figma file key (from URL: `figma.com/file/<fileKey>/...` or `figma.com/design/<fileKey>/...`) |
47
+ | `nodeId` | string | No | Specific node ID (from URL param `node-id=<nodeId>`). Format: `1234:5678` or `1234-5678` |
48
+ | `depth` | number | No | Levels deep to traverse. Only use if explicitly requested by user. |
49
+
50
+ **Returns:** YAML-formatted design data with:
51
+
52
+ - `metadata` - File/node information
53
+ - `nodes` - Simplified node tree with layout, styles, text content
54
+ - `globalVars` - Shared styles and variables
55
+
56
+ ### download_figma_images
57
+
58
+ Download SVG and PNG images/icons from a Figma file.
59
+
60
+ | Parameter | Type | Required | Description |
61
+ | ----------- | ------ | -------- | --------------------------------------------- |
62
+ | `fileKey` | string | Yes | The Figma file key |
63
+ | `nodes` | array | Yes | Array of node objects to download (see below) |
64
+ | `localPath` | string | Yes | Absolute path to save images |
65
+ | `pngScale` | number | No | Export scale for PNGs (default: 2) |
66
+
67
+ **Node object structure:**
68
+
69
+ ```json
70
+ {
71
+ "nodeId": "1234:5678",
72
+ "fileName": "icon-name.svg",
73
+ "imageRef": "optional-for-image-fills"
74
+ }
75
+ ```
76
+
77
+ ## Workflow
78
+
79
+ ### 1. Extract File Key and Node ID from Figma URL
80
+
81
+ Figma URLs follow these patterns:
82
+
83
+ - `https://www.figma.com/file/<fileKey>/<fileName>?node-id=<nodeId>`
84
+ - `https://www.figma.com/design/<fileKey>/<fileName>?node-id=<nodeId>`
85
+
86
+ Example: `https://www.figma.com/design/abc123xyz/MyDesign?node-id=1234-5678`
87
+
88
+ - `fileKey`: `abc123xyz`
89
+ - `nodeId`: `1234-5678` (or `1234:5678` - both formats work)
90
+
91
+ ### 2. Fetch Design Data
92
+
93
+ ```
94
+ # Fetch specific frame/component
95
+ skill_mcp(skill_name="figma", tool_name="get_figma_data", arguments='{"fileKey": "abc123xyz", "nodeId": "1234:5678"}')
96
+
97
+ # Fetch entire file (use sparingly - can be large)
98
+ skill_mcp(skill_name="figma", tool_name="get_figma_data", arguments='{"fileKey": "abc123xyz"}')
99
+ ```
100
+
101
+ ### 3. Download Assets (Optional)
102
+
103
+ ```
104
+ skill_mcp(skill_name="figma", tool_name="download_figma_images", arguments='{
105
+ "fileKey": "abc123xyz",
106
+ "nodes": [
107
+ {"nodeId": "1234:5678", "fileName": "hero-image.png"},
108
+ {"nodeId": "5678:9012", "fileName": "icon-arrow.svg"}
109
+ ],
110
+ "localPath": "/absolute/path/to/assets"
111
+ }')
112
+ ```
113
+
114
+ ## Examples
115
+
116
+ ### Implement a Component from Figma
117
+
118
+ ```
119
+ # User provides: https://www.figma.com/design/abc123/Dashboard?node-id=100-200
120
+
121
+ # 1. Fetch the design data
122
+ skill_mcp(skill_name="figma", tool_name="get_figma_data", arguments='{"fileKey": "abc123", "nodeId": "100-200"}')
123
+
124
+ # 2. Review the returned YAML for:
125
+ # - Layout structure (flex, grid, spacing)
126
+ # - Typography (font, size, weight, line-height)
127
+ # - Colors (fills, strokes)
128
+ # - Dimensions and constraints
129
+
130
+ # 3. Implement the component using the extracted data
131
+ ```
132
+
133
+ ### Extract Design Tokens
134
+
135
+ ```
136
+ # Fetch a design system file
137
+ skill_mcp(skill_name="figma", tool_name="get_figma_data", arguments='{"fileKey": "designSystemKey"}')
138
+
139
+ # The globalVars section contains:
140
+ # - Color styles
141
+ # - Typography styles
142
+ # - Effect styles (shadows, blurs)
143
+ ```
144
+
145
+ ### Download Icons
146
+
147
+ ```
148
+ skill_mcp(skill_name="figma", tool_name="download_figma_images", arguments='{
149
+ "fileKey": "iconLibraryKey",
150
+ "nodes": [
151
+ {"nodeId": "10:20", "fileName": "icon-home.svg"},
152
+ {"nodeId": "10:30", "fileName": "icon-settings.svg"},
153
+ {"nodeId": "10:40", "fileName": "icon-user.svg"}
154
+ ],
155
+ "localPath": "/project/src/assets/icons",
156
+ "pngScale": 2
157
+ }')
158
+ ```
159
+
160
+ ## Data Structure
161
+
162
+ The `get_figma_data` tool returns simplified, AI-optimized data:
163
+
164
+ ```yaml
165
+ metadata:
166
+ name: "Component Name"
167
+ lastModified: "2024-01-15T..."
168
+
169
+ nodes:
170
+ - id: "1234:5678"
171
+ name: "Button"
172
+ type: "FRAME"
173
+ layout:
174
+ mode: "HORIZONTAL"
175
+ padding: { top: 12, right: 24, bottom: 12, left: 24 }
176
+ gap: 8
177
+ size: { width: 120, height: 48 }
178
+ fills:
179
+ - type: "SOLID"
180
+ color: { r: 0.2, g: 0.4, b: 1, a: 1 }
181
+ cornerRadius: 8
182
+ children:
183
+ - id: "1234:5679"
184
+ name: "Label"
185
+ type: "TEXT"
186
+ content: "Click me"
187
+ textStyle:
188
+ fontFamily: "Inter"
189
+ fontSize: 16
190
+ fontWeight: 600
191
+
192
+ globalVars:
193
+ styles:
194
+ "S:abc123": { name: "Primary", fills: [...] }
195
+ ```
196
+
197
+ ## Tips
198
+
199
+ - **Always use nodeId** when provided - fetching entire files is slow and context-heavy
200
+ - **Node IDs with `-` or `:`** both work - the MCP handles conversion
201
+ - **Check globalVars** for reusable styles before hardcoding values
202
+ - **Use depth parameter sparingly** - only when explicitly needed
203
+ - **For images/icons**, identify nodes with `imageRef` in the data for proper downloading
204
+ - **SVG vs PNG**: Use `.svg` for icons/vectors, `.png` for photos/complex images
205
+
206
+ ## Troubleshooting
207
+
208
+ **"Invalid API key"**: Ensure `FIGMA_API_KEY` environment variable is set correctly.
209
+
210
+ **"File not found"**: Verify the fileKey is correct and you have access to the file.
211
+
212
+ **"Node not found"**: Check the nodeId format. Try both `1234:5678` and `1234-5678`.
213
+
214
+ **Large response**: Use `nodeId` to fetch specific frames instead of entire files.