trying to integrate chainlink into opencode
1import { type Plugin, tool } from "@opencode-ai/plugin";
2
3/**
4 * Result type for chainlink command execution.
5 */
6export interface ChainlinkResult {
7 success: boolean;
8 data?: string;
9 error?: string;
10}
11
12/**
13 * Log levels for the plugin.
14 */
15export type LogLevel = "debug" | "info" | "warn" | "error";
16
17/**
18 * Extension to language mapping for dynamic rule hints.
19 * Maps file extensions to their corresponding rule file names.
20 */
21const EXTENSION_TO_LANGUAGE: Record<string, string> = {
22 // TypeScript/JavaScript
23 ts: "typescript",
24 tsx: "typescript",
25 js: "javascript",
26 jsx: "javascript",
27 mjs: "javascript",
28 cjs: "javascript",
29
30 // Go
31 go: "go",
32
33 // Nix
34 nix: "nix",
35
36 // C-family
37 c: "c",
38 h: "c",
39 cpp: "cpp",
40 cc: "cpp",
41 cxx: "cpp",
42 hpp: "cpp",
43 cs: "csharp",
44
45 // JVM languages
46 java: "java",
47 kt: "kotlin",
48 kts: "kotlin",
49 scala: "scala",
50 cls: "java",
51
52 // Functional languages
53 hs: "haskell",
54 lhs: "haskell",
55 ml: "ocaml",
56 mli: "ocaml",
57 fs: "fsharp",
58 fsi: "fsharp",
59 clj: "clojure",
60 rlib: "rust",
61
62 // Scripting languages
63 py: "python",
64 pyw: "python",
65 rb: "ruby",
66 erb: "ruby",
67 php: "php",
68 pl: "perl",
69 pm: "perl",
70 lua: "lua",
71
72 // Shell
73 sh: "shell",
74 bash: "shell",
75 zsh: "shell",
76 ps1: "powershell",
77 bat: "batch",
78 cmd: "batch",
79
80 // Systems programming
81 rs: "rust",
82 swift: "swift",
83 mm: "objectivec",
84 d: "d",
85 v: "v",
86 zig: "zig",
87 vala: "vala",
88
89 // Other compiled
90 ex: "elixir",
91 exs: "elixir",
92 erl: "erlang",
93 hrl: "erlang",
94 jl: "julia",
95 cr: "crystal",
96 nim: "nim",
97 rkt: "racket",
98
99 // Web markup/styles
100 html: "html",
101 htm: "html",
102 css: "css",
103 scss: "css",
104 sass: "css",
105 less: "css",
106
107 // Data/Config
108 json: "json",
109 yaml: "yaml",
110 yml: "yaml",
111 xml: "xml",
112 toml: "toml",
113
114 // Documentation
115 md: "markdown",
116 markdown: "markdown",
117 tex: "latex",
118 latex: "latex",
119
120 // Database
121 sql: "sql",
122 ddl: "sql",
123
124 // Build systems
125 make: "makefile",
126 cmake: "cmake",
127 dockerfile: "docker",
128
129 // Other
130 csv: "csv",
131 r: "r",
132 m: "matlab",
133 asm: "assembly",
134 s: "assembly",
135 f: "fortran",
136 f90: "fortran",
137};
138
139/**
140 * Extract file extension and map to language name.
141 */
142function getLanguageFromFilePath(filePath: string): string | null {
143 const match = filePath.match(/\.([a-zA-Z0-9]+)(?:\?.*)?$/);
144 if (!match) return null;
145
146 const ext = match[1]!.toLowerCase();
147 return EXTENSION_TO_LANGUAGE[ext] ?? null;
148}
149
150/**
151 * Registry to track which rules have been read in each session.
152 */
153const sessionRulesRead = new Map<string, Set<string>>();
154
155/**
156 * Helper class for executing chainlink commands and interacting with OpenCode client.
157 */
158class ChainlinkService {
159 constructor(
160 private readonly $: Awaited<Parameters<Plugin>[0]["$"]>,
161 private readonly client: Parameters<Plugin>[0]["client"],
162 ) {
163 this.$.throws(true);
164 }
165
166 /**
167 * Execute a chainlink command with proper error handling.
168 */
169 async run(
170 args: string[],
171 description: string = "execute chainlink command",
172 ): Promise<ChainlinkResult> {
173 try {
174 const fullCmd = this.$`chainlink ${args}`;
175
176 const text = await Promise.race([
177 fullCmd.text(),
178 new Promise<never>((_, reject) =>
179 setTimeout(
180 () => reject(new Error("Command timed out after 2 seconds")),
181 2000,
182 ),
183 ),
184 ]);
185
186 return { success: true, data: text };
187 } catch (error) {
188 return {
189 success: false,
190 error: `Failed to ${description}: ${error instanceof Error ? error.message : String(error)}`,
191 };
192 }
193 }
194
195 /**
196 * Log a message using the OpenCode logging API.
197 */
198 async log(level: LogLevel, message: string): Promise<void> {
199 await this.client.app
200 .log({
201 body: {
202 service: "chainlink-plugin",
203 level,
204 message,
205 },
206 })
207 .catch(() => {});
208 }
209
210 /**
211 * Send a prompt to the current session.
212 */
213 async prompt(sessionID: string, text: string): Promise<void> {
214 await this.client.session
215 .prompt({
216 path: { id: sessionID },
217 body: {
218 noReply: true,
219 parts: [{ type: "text", text }],
220 },
221 })
222 .catch(async (error) => {
223 await this.log(
224 "warn",
225 `Failed to send prompt to session ${sessionID}: ${error instanceof Error ? error.message : String(error)}`,
226 );
227 });
228 }
229}
230
231export const ChainlinkPlugin: Plugin = async ({ $, client, directory }) => {
232 const service = new ChainlinkService($, client);
233
234 return {
235 tool: {
236 /**
237 * Execute any chainlink command.
238 */
239 chainlink: tool({
240 description:
241 "Execute any chainlink command. Examples: 'show 42', 'create \"title\" -p high', 'comment 42 \"done\"', 'list --status open'. Note: 'delete' is interactive; use '-f' or '--force' to skip confirmation.",
242 args: {
243 command: tool.schema
244 .array(tool.schema.string())
245 .describe("The chainlink command and arguments (e.g. 'show 42')"),
246 },
247 async execute(args) {
248 const result = await service.run(args.command);
249 return result.success ? result.data || "" : `Error: ${result.error}`;
250 },
251 }),
252
253 /**
254 * Suggest the next issue to work on.
255 */
256 chainlink_next: tool({
257 description:
258 "Get smart recommendation for what to work on next based on priority, blocking relationships, and session history",
259 args: {},
260 async execute() {
261 const result = await service.run(["next"], "get next suggestion");
262 return result.success
263 ? result.data ||
264 "No issues ready. Create one with `chainlink` tool!"
265 : "Unable to get suggestions. Try creating some issues first!";
266 },
267 }),
268
269 /**
270 * Read coding rules from .chainlink/rules directory.
271 */
272 chainlink_rules: tool({
273 description: "Read coding rules from .chainlink/rules directory",
274 args: {
275 rule: tool.schema
276 .string()
277 .optional()
278 .describe(
279 "Specific rule file to read (e.g., 'typescript', 'go', 'javascript')",
280 ),
281 },
282 async execute(args, toolCtx) {
283 const ruleFile = args.rule || "global";
284 const sessionID = toolCtx?.sessionID;
285
286 if (sessionID) {
287 if (!sessionRulesRead.has(sessionID)) {
288 sessionRulesRead.set(sessionID, new Set());
289 }
290 sessionRulesRead.get(sessionID)?.add(ruleFile);
291 }
292
293 const paths = [
294 `${directory}/.chainlink/rules/${ruleFile}.md`,
295 `${directory}/.chainlink/rules/${ruleFile}`,
296 `${directory}/.chainlink/rules/global.md`,
297 `${directory}/.chainlink/rules/global`,
298 ];
299
300 const rulesDir = process.env.CHAINLINK_RULES_DIR;
301 if (rulesDir) {
302 paths.push(
303 `${rulesDir}/${ruleFile}.md`,
304 `${rulesDir}/${ruleFile}`,
305 `${rulesDir}/global.md`,
306 `${rulesDir}/global`,
307 );
308 }
309
310 for (const rulePath of paths) {
311 try {
312 const content = await $`cat "${rulePath}"`.text();
313 if (content.trim()) return content.trim();
314 } catch {}
315 }
316
317 return `No rule file found for '${ruleFile}'. Available rules: global, go, javascript, nix, typescript`;
318 },
319 }),
320
321 /**
322 * Manage chainlink sessions.
323 */
324 chainlink_session: tool({
325 description: "Manage chainlink sessions (start, end, work, status)",
326 args: {
327 action: tool.schema
328 .enum(["start", "end", "work", "status"])
329 .describe("Session action to perform"),
330 id: tool.schema
331 .string()
332 .optional()
333 .describe("Issue ID for 'work' action"),
334 notes: tool.schema
335 .string()
336 .optional()
337 .describe("Handoff notes for 'end' action"),
338 },
339 async execute(args) {
340 const { action, id, notes } = args;
341
342 switch (action) {
343 case "start": {
344 const result = await service.run(
345 ["session", "start"],
346 "start session",
347 );
348 return result.data || result.error || "Failed to start session";
349 }
350 case "end": {
351 if (!notes)
352 return "Error: Notes are required for ending a session";
353 const result = await service.run(
354 ["session", "end", "--notes", notes],
355 "end session",
356 );
357 return result.data || result.error || "Failed to end session";
358 }
359 case "work": {
360 if (!id) return "Error: Issue ID is required for 'work' action";
361 const cleanId = id.replace("#", "");
362 const result = await service.run(
363 ["session", "work", cleanId],
364 "set session work issue",
365 );
366 return result.data || result.error || "Failed to set work issue";
367 }
368 case "status": {
369 const result = await service.run(
370 ["session", "status"],
371 "get session status",
372 );
373 return (
374 result.data || result.error || "Failed to get session status"
375 );
376 }
377 default:
378 return `Error: Unknown session action '${action}'`;
379 }
380 },
381 }),
382 },
383
384 // === Lifecycle Hooks ===
385
386 "tool.execute.before": async (input) => {
387 const {
388 tool: toolName,
389 sessionID,
390 args,
391 } = input as {
392 tool: string;
393 sessionID: string;
394 callID: string;
395 args?: Record<string, unknown>;
396 };
397
398 if (!sessionID) return;
399
400 // Before any modification or read, ensure rules are being respected
401 const rulesRead = sessionRulesRead.get(sessionID);
402 const hasReadGlobal = rulesRead?.has("global");
403
404 // Always warn on modification if global rules haven't been read
405 if ((toolName === "write" || toolName === "edit") && !hasReadGlobal) {
406 await service.prompt(
407 sessionID,
408 "**ATTENTION:** You are attempting to modify code without having read the project's coding rules. Please run `chainlink_rules({ rule: 'global' })` (and any language-specific rules) BEFORE making changes to ensure quality and consistency.",
409 );
410 return;
411 }
412
413 // On read, suggest language rules if global is already read but language rules aren't
414 if (toolName === "read") {
415 const filePath = args?.filePath as string | undefined;
416 if (!filePath) return;
417
418 const languageRule = getLanguageFromFilePath(filePath);
419 if (!languageRule) return;
420 if (rulesRead?.has(languageRule)) return;
421
422 const languageDisplay =
423 languageRule.charAt(0).toUpperCase() + languageRule.slice(1);
424 const ext = filePath.split(".").pop()?.toLowerCase();
425
426 await service.prompt(
427 sessionID,
428 `**Hint:** You're reading a ${ext} file. ${!hasReadGlobal ? "Please read the `global` rules first, and then consider" : "Consider"} reading the ${languageDisplay} rules with \`chainlink_rules({ rule: '${languageRule}' }})\`.`,
429 );
430 }
431 },
432
433 "session.created": async ({ sessionID }: { sessionID: string }) => {
434 sessionRulesRead.delete(sessionID);
435
436 try {
437 const readyResult = await service.run(["ready"], "get ready issues");
438 const readyData = readyResult.data || "";
439 const lines = readyData.split("\n").filter(Boolean);
440 const topIssues = lines.slice(0, 3).join("\n");
441
442 const contextText = `## Chainlink Issues
443
444**Ready to work:** ${lines.length} issue${lines.length !== 1 ? "s" : ""}
445${topIssues ? `\n**Top priorities:**\n${topIssues}` : ""}
446
447${lines.length > 3 ? "> Use chainlink({ command: 'ready' }) for full list" : ""}
448
449---
450
451**CRITICAL:** Before writing or editing any code, you MUST read the project coding rules using the \`chainlink_rules\` tool. This ensures consistency and quality.
452
453Recommended rules to check: \`global\`, \'your-language\'.
454
455When reading code files, the system will automatically detect the language and remind you to read language-specific rules (e.g., reading \`.ts\` files → read \`typescript\` rules).`;
456
457 await service.prompt(sessionID, contextText);
458 } catch (error) {
459 await service.log(
460 "error",
461 `Failed to inject session context: ${error instanceof Error ? error.message : "Unknown error"}`,
462 );
463 }
464 },
465 };
466};