trying to integrate chainlink into opencode

initial commit

add a before hook to tell the agent to read the rules

try to improve code quality

dynamic rules dir & better error handling

+1107
+1
.gitignore
··· 1 + node_modules
+25
README.md
··· 1 + # Chainlink OpenCode Plugin 2 + 3 + Integrates [Chainlink](https://github.com/dollspace-gay/chainlink) issue tracking into OpenCode. 4 + 5 + ## Installation 6 + 7 + ```jsonc 8 + // opencode.json 9 + { 10 + "plugin": ["git@tangled.org:karitham.dev/chainlink-opencode"], 11 + } 12 + ``` 13 + 14 + ## Tools 15 + 16 + - `chainlink({ command })`: Run any chainlink command (e.g., `show 42`, `create "fix bug"`) 17 + - `chainlink_session({ action, id?, notes? })`: Manage session lifecycle (`start`, `work`, `end`, `status`) 18 + - `chainlink_rules({ rule? })`: Read rules from `.chainlink/rules/` 19 + - `chainlink_next()`: Get next issue recommendation 20 + 21 + ## Features 22 + 23 + - **Rule Enforcement**: Reminds agents to read rules before editing code. 24 + - **Auto Context**: Injects priority issues at session start. 25 + - **Dynamic Language Hints**: Automatically detects file language and prompts to read language-specific rules when reading code files.
+35
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "chainlink-opencode", 7 + "dependencies": { 8 + "@opencode-ai/plugin": "^1.1.47", 9 + }, 10 + "devDependencies": { 11 + "@types/bun": "latest", 12 + }, 13 + "peerDependencies": { 14 + "typescript": "^5", 15 + }, 16 + }, 17 + }, 18 + "packages": { 19 + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.51", "", { "dependencies": { "@opencode-ai/sdk": "1.1.51", "zod": "4.1.8" } }, "sha512-FMtwIEG1HdXaQ4qtzRelF++qjKL4QKtJOB5Atud0Xu5c9T48TGCDDQJONTAjgVleyq3bb73tUt+ACBK0QSEOyw=="], 20 + 21 + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.51", "", {}, "sha512-UL66X46AGgui5xURGEenXsIsgNVmgfkmJJeRtdeOMLhi/RcbTBikfPjjtmym3VLnqp855Wt7dZ/vAjOXqiUKXA=="], 22 + 23 + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], 24 + 25 + "@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="], 26 + 27 + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], 28 + 29 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 30 + 31 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 32 + 33 + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], 34 + } 35 + }
+462
docs/opencode-plugin-architecture.md
··· 1 + # OpenCode Plugin Architecture 2 + 3 + Documentation for agents implementing OpenCode plugins. Based on analysis of [kdco/opencode-workspace](https://github.com/kdcokenny/opencode-workspace). 4 + 5 + ## Quick Reference 6 + 7 + ```typescript 8 + import { type Plugin, type Event, tool } from "@opencode-ai/plugin"; 9 + 10 + export const MyPlugin: Plugin = async ({ $, client, directory }) => { 11 + return { 12 + tool: { 13 + myTool: tool({ 14 + description: "What this tool does", 15 + args: { 16 + param: tool.schema.string().describe("Parameter description"), 17 + }, 18 + async execute(args) { 19 + // Tools return string output 20 + return "result"; 21 + }, 22 + }), 23 + }, 24 + "session.created": async ({ sessionID }: { sessionID: string }) => { ... }, 25 + event: async ({ event }: { event: Event }) => { ... }, 26 + }; 27 + }; 28 + ``` 29 + 30 + ## Plugin Structure 31 + 32 + ### Plugin Function Signature 33 + 34 + ```typescript 35 + export const MyPlugin: Plugin = async (ctx) => { 36 + const { $, client, directory, worktree } = ctx; 37 + // ctx includes all plugin context 38 + }; 39 + ``` 40 + 41 + **Context properties:** 42 + 43 + - `$`: Bun shell API for executing commands 44 + - `client`: OpenCode SDK client for API interactions 45 + - `directory`: Current working directory 46 + - `worktree`: Git worktree path 47 + 48 + ### Tool Definition 49 + 50 + ```typescript 51 + tool({ 52 + description: "Human-readable description of the tool", 53 + args: { 54 + paramName: tool.schema 55 + .string() // string, number, boolean 56 + .describe("What this parameter means"), 57 + optionalParam: tool.schema 58 + .string() 59 + .optional() 60 + .describe("Optional parameter"), 61 + enumParam: tool.schema 62 + .enum(["option1", "option2"]) 63 + .describe("Enumerated value"), 64 + recordParam: tool.schema 65 + .record(tool.schema.string(), tool.schema.unknown()) 66 + .optional() 67 + .describe("Key-value pairs"), 68 + }, 69 + async execute(args) { 70 + const { paramName, optionalParam } = args; 71 + return "tool output as string"; 72 + }, 73 + }); 74 + ``` 75 + 76 + **Schema types:** 77 + 78 + - `tool.schema.string()` 79 + - `tool.schema.number()` 80 + - `tool.schema.boolean()` 81 + - `tool.schema.enum(["a", "b"])` 82 + - `tool.schema.record(keyType, valueType)` 83 + - `.optional()` 84 + - `.describe()` 85 + - `.default(value)` 86 + 87 + ### Returning Values from Tools 88 + 89 + Tools must return a **string**. For structured data, return JSON: 90 + 91 + ```typescript 92 + async execute(args) { 93 + const result = { status: "success", data: {...} }; 94 + return JSON.stringify(result); 95 + } 96 + ``` 97 + 98 + ## Lifecycle Hooks 99 + 100 + ### session.created 101 + 102 + Called when a new session starts. Use to inject context. 103 + 104 + ```typescript 105 + "session.created": async ({ sessionID }: { sessionID: string }) => { 106 + await client.session.prompt({ 107 + path: { id: sessionID }, 108 + body: { 109 + noReply: true, 110 + parts: [{ type: "text", text: "Injected context" }], 111 + }, 112 + }); 113 + } 114 + ``` 115 + 116 + ### event 117 + 118 + All other events are dispatched through the generic `event` hook. Filter by `event.type`. 119 + 120 + ```typescript 121 + event: async ({ event }: { event: Event }) => { 122 + if (event.type !== "session.idle") return; 123 + if (!event.sessionID) return; 124 + 125 + // Handle session.idle 126 + }; 127 + ``` 128 + 129 + **Common event types:** 130 + 131 + - `session.created` 132 + - `session.idle` 133 + - `session.compacted` 134 + - `session.deleted` 135 + - `session.diff` 136 + - `session.error` 137 + - `session.status` 138 + - `session.updated` 139 + - `tool.execute.before` 140 + - `tool.execute.after` 141 + - `chat.message` 142 + - `command.execute.before` 143 + 144 + ## Client API 145 + 146 + ### Logging 147 + 148 + ```typescript 149 + // Structured logging 150 + await client.app 151 + .log({ 152 + body: { 153 + service: "my-plugin", 154 + level: "debug" | "info" | "warn" | "error", 155 + message: "log message", 156 + }, 157 + }) 158 + .catch(() => {}); // Always handle errors 159 + ``` 160 + 161 + ### Session Operations 162 + 163 + ```typescript 164 + // Inject context without triggering AI response 165 + await client.session.prompt({ 166 + path: { id: sessionID }, 167 + body: { 168 + noReply: true, 169 + parts: [{ type: "text", text: "context" }], 170 + }, 171 + }); 172 + 173 + // Fork a session 174 + const forked = await client.session.fork({ 175 + path: { id: sessionID }, 176 + body: {}, 177 + }); 178 + 179 + // Get session info 180 + const session = await client.session.get({ 181 + path: { id: sessionID }, 182 + }); 183 + 184 + // Delete a session 185 + await client.session.delete({ path: { id: sessionID } }); 186 + ``` 187 + 188 + ### Tool Context in Hooks 189 + 190 + Tool hooks receive a `toolCtx` parameter: 191 + 192 + ```typescript 193 + async execute(args, toolCtx) { 194 + // toolCtx includes: 195 + // - sessionID: string | undefined 196 + // - directory: string 197 + // - worktree: string 198 + const sessionID = toolCtx?.sessionID; 199 + } 200 + ``` 201 + 202 + ## Shell Execution 203 + 204 + Use Bun's `$` template literal syntax: 205 + 206 + ```typescript 207 + // Simple command 208 + const output = await $`echo hello`.text(); 209 + 210 + // With arguments (properly escaped) 211 + const output = await $`command "${arg.replace(/"/g, '\\"')}"`.text(); 212 + 213 + // Capture output 214 + const result = await $`ls -la`.text(); 215 + 216 + // Error handling 217 + try { 218 + await $`command`.text(); 219 + } catch (error) { 220 + // Command failed 221 + } 222 + ``` 223 + 224 + ## TypeScript Patterns 225 + 226 + ### Result Types 227 + 228 + ```typescript 229 + type Result<T> = 230 + | { ok: true; value: T } 231 + | { ok: false; error: string }; 232 + 233 + // Usage 234 + const result: Result<string> = ...; 235 + if (result.ok) { 236 + console.log(result.value); 237 + } else { 238 + console.error(result.error); 239 + } 240 + ``` 241 + 242 + ### Zod Schema Validation 243 + 244 + Example from workspace-plugin: 245 + 246 + ```typescript 247 + import { z } from "zod"; 248 + 249 + const PlanSchema = z.object({ 250 + frontmatter: z.object({ 251 + status: z.enum(["not-started", "in-progress", "complete", "blocked"]), 252 + phase: z.number().int().positive(), 253 + }), 254 + goal: z.string().min(10), 255 + }); 256 + 257 + // Validate at boundary 258 + const result = PlanSchema.safeParse(candidate); 259 + if (!result.success) { 260 + return `Error: ${result.error.message}`; 261 + } 262 + ``` 263 + 264 + ### Error Handling 265 + 266 + ```typescript 267 + // Type guard for Node.js errors 268 + function isNodeError(error: unknown): error is NodeJS.ErrnoException { 269 + return error instanceof Error && "code" in error; 270 + } 271 + 272 + // Usage 273 + try { 274 + await fs.readFile(path); 275 + } catch (error) { 276 + if (isNodeError(error) && error.code === "ENOENT") { 277 + return "File not found"; 278 + } 279 + throw error; // Re-throw unexpected errors 280 + } 281 + ``` 282 + 283 + ## Common Operations 284 + 285 + ### Reading Files 286 + 287 + ```typescript 288 + import * as fs from "node:fs/promises"; 289 + 290 + async function readFile(filePath: string): Promise<string> { 291 + try { 292 + return await fs.readFile(filePath, "utf8"); 293 + } catch (error) { 294 + if (isNodeError(error) && error.code === "ENOENT") { 295 + return ""; // File doesn't exist 296 + } 297 + throw error; 298 + } 299 + } 300 + ``` 301 + 302 + ### Writing Files 303 + 304 + ```typescript 305 + import * as fs from "node:fs/promises"; 306 + 307 + async function writeFile(filePath: string, content: string): Promise<void> { 308 + await fs.mkdir(path.dirname(filePath), { recursive: true }); 309 + await fs.writeFile(filePath, content, "utf8"); 310 + } 311 + ``` 312 + 313 + ### Path Handling 314 + 315 + ```typescript 316 + import * as path from "node:path"; 317 + 318 + // Join paths 319 + const fullPath = path.join(directory, "subdir", "file.txt"); 320 + 321 + // Get directory name 322 + const dir = path.dirname(filePath); 323 + 324 + // Get base name 325 + const base = path.basename(filePath); 326 + 327 + // Get extension 328 + const ext = path.extname(filePath); 329 + ``` 330 + 331 + ### Git Operations 332 + 333 + ```typescript 334 + async function git( 335 + args: string[], 336 + cwd: string, 337 + ): Promise<Result<string, string>> { 338 + try { 339 + const proc = Bun.spawn(["git", ...args], { 340 + cwd, 341 + stdout: "pipe", 342 + stderr: "pipe", 343 + }); 344 + const [stdout, stderr, exitCode] = await Promise.all([ 345 + new Response(proc.stdout).text(), 346 + new Response(proc.stderr).text(), 347 + proc.exited, 348 + ]); 349 + if (exitCode !== 0) { 350 + return { ok: false, error: stderr.trim() }; 351 + } 352 + return { ok: true, value: stdout.trim() }; 353 + } catch (error) { 354 + return { ok: false, error: String(error) }; 355 + } 356 + } 357 + ``` 358 + 359 + ## Best Practices 360 + 361 + ### 1. Early Exit Pattern 362 + 363 + ```typescript 364 + async function process(args: Args): Promise<Result> { 365 + // Validate first 366 + if (!args.required) { 367 + return { ok: false, error: "Required parameter missing" }; 368 + } 369 + 370 + // Guard against null/undefined 371 + if (!value) return defaultValue; 372 + 373 + // Happy path 374 + return { ok: true, value: computed }; 375 + } 376 + ``` 377 + 378 + ### 2. Parse Don't Validate 379 + 380 + Extract data first, validate once: 381 + 382 + ```typescript 383 + function extractMarkdownParts(content: string): RawParts { 384 + // Extraction only, no validation 385 + const match = content.match(/pattern/); 386 + return { data: match?.[1] || null }; 387 + } 388 + 389 + function parsePlan(content: string): ValidPlan { 390 + const parts = extractMarkdownParts(content); 391 + const result = schema.safeParse(parts); 392 + if (!result.success) { 393 + throw new Error("Invalid format"); 394 + } 395 + return result.data; 396 + } 397 + ``` 398 + 399 + ### 3. Fail Loud 400 + 401 + Provide actionable error messages: 402 + 403 + ```typescript 404 + async function execute(args) { 405 + const result = await riskyOperation(); 406 + if (!result.ok) { 407 + return `❌ Operation failed: ${result.error}\n\nHint: Check that X is configured correctly.`; 408 + } 409 + return "✅ Success!"; 410 + } 411 + ``` 412 + 413 + ### 4. Log with Context 414 + 415 + ```typescript 416 + async function log(level: "info" | "error", message: string) { 417 + await client.app 418 + .log({ 419 + body: { 420 + service: "my-plugin", 421 + level, 422 + message: `${message} (session: ${sessionID})`, 423 + }, 424 + }) 425 + .catch(() => {}); 426 + } 427 + ``` 428 + 429 + ### 5. Handle Promise Rejection 430 + 431 + Logging and async operations can reject: 432 + 433 + ```typescript 434 + await client.app.log({...}).catch(() => {}); 435 + 436 + // Or handle gracefully 437 + try { 438 + await client.session.prompt({...}); 439 + } catch (error) { 440 + await log("error", `Failed: ${error}`); 441 + } 442 + ``` 443 + 444 + ## File Structure 445 + 446 + ``` 447 + my-plugin/ 448 + ├── src/ 449 + │ └── index.ts # Main plugin file 450 + ├── package.json # Dependencies 451 + ├── tsconfig.json # TypeScript config 452 + └── docs/ 453 + └── opencode-plugin-architecture.md # This file 454 + ``` 455 + 456 + ## References 457 + 458 + - [OpenCode Plugins Documentation](https://opencode.ai/docs/plugins) 459 + - [OpenCode SDK](https://opencode.ai/docs/sdk) 460 + - [kdco/opencode-workspace](https://github.com/kdcokenny/opencode-workspace) 461 + - [Bun Shell API](https://bun.com/docs/runtime/shell) 462 + - [Zod Documentation](https://zod.dev/)
+48
flake.lock
··· 1 + { 2 + "nodes": { 3 + "flake-parts": { 4 + "inputs": { 5 + "nixpkgs-lib": [ 6 + "nixpkgs" 7 + ] 8 + }, 9 + "locked": { 10 + "lastModified": 1769996383, 11 + "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=", 12 + "owner": "hercules-ci", 13 + "repo": "flake-parts", 14 + "rev": "57928607ea566b5db3ad13af0e57e921e6b12381", 15 + "type": "github" 16 + }, 17 + "original": { 18 + "owner": "hercules-ci", 19 + "repo": "flake-parts", 20 + "type": "github" 21 + } 22 + }, 23 + "nixpkgs": { 24 + "locked": { 25 + "lastModified": 1770115704, 26 + "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", 27 + "owner": "NixOS", 28 + "repo": "nixpkgs", 29 + "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", 30 + "type": "github" 31 + }, 32 + "original": { 33 + "owner": "NixOS", 34 + "ref": "nixos-unstable", 35 + "repo": "nixpkgs", 36 + "type": "github" 37 + } 38 + }, 39 + "root": { 40 + "inputs": { 41 + "flake-parts": "flake-parts", 42 + "nixpkgs": "nixpkgs" 43 + } 44 + } 45 + }, 46 + "root": "root", 47 + "version": 7 48 + }
+27
flake.nix
··· 1 + { 2 + inputs = { 3 + flake-parts.url = "github:hercules-ci/flake-parts"; 4 + flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 + }; 7 + outputs = 8 + inputs@{ flake-parts, ... }: 9 + flake-parts.lib.mkFlake { inherit inputs; } { 10 + systems = [ 11 + "x86_64-linux" 12 + "aarch64-linux" 13 + "aarch64-darwin" 14 + "x86_64-darwin" 15 + ]; 16 + perSystem = 17 + { pkgs, ... }: 18 + { 19 + devShells.default = pkgs.mkShell { 20 + buildInputs = [ 21 + pkgs.bun 22 + pkgs.biome 23 + ]; 24 + }; 25 + }; 26 + }; 27 + }
+14
package.json
··· 1 + { 2 + "name": "chainlink-opencode", 3 + "private": true, 4 + "main": "src/index.ts", 5 + "devDependencies": { 6 + "@types/bun": "latest" 7 + }, 8 + "peerDependencies": { 9 + "typescript": "^5" 10 + }, 11 + "dependencies": { 12 + "@opencode-ai/plugin": "^1.1.47" 13 + } 14 + }
+466
src/index.ts
··· 1 + import { type Plugin, tool } from "@opencode-ai/plugin"; 2 + 3 + /** 4 + * Result type for chainlink command execution. 5 + */ 6 + export interface ChainlinkResult { 7 + success: boolean; 8 + data?: string; 9 + error?: string; 10 + } 11 + 12 + /** 13 + * Log levels for the plugin. 14 + */ 15 + export 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 + */ 21 + const 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 + */ 142 + function 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 + */ 153 + const sessionRulesRead = new Map<string, Set<string>>(); 154 + 155 + /** 156 + * Helper class for executing chainlink commands and interacting with OpenCode client. 157 + */ 158 + class 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 + 231 + export 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 + 453 + Recommended rules to check: \`global\`, \'your-language\'. 454 + 455 + When 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 + };
+29
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Environment setup & latest features 4 + "lib": ["ESNext"], 5 + "target": "ESNext", 6 + "module": "Preserve", 7 + "moduleDetection": "force", 8 + "jsx": "react-jsx", 9 + "allowJs": true, 10 + 11 + // Bundler mode 12 + "moduleResolution": "bundler", 13 + "allowImportingTsExtensions": true, 14 + "verbatimModuleSyntax": true, 15 + "noEmit": true, 16 + 17 + // Best practices 18 + "strict": true, 19 + "skipLibCheck": true, 20 + "noFallthroughCasesInSwitch": true, 21 + "noUncheckedIndexedAccess": true, 22 + "noImplicitOverride": true, 23 + 24 + // Some stricter flags (disabled by default) 25 + "noUnusedLocals": false, 26 + "noUnusedParameters": false, 27 + "noPropertyAccessFromIndexSignature": false 28 + } 29 + }