trying to integrate chainlink into opencode
at main 466 lines 12 kB view raw
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};