import { type Plugin, tool } from "@opencode-ai/plugin"; /** * Result type for chainlink command execution. */ export interface ChainlinkResult { success: boolean; data?: string; error?: string; } /** * Log levels for the plugin. */ export type LogLevel = "debug" | "info" | "warn" | "error"; /** * Extension to language mapping for dynamic rule hints. * Maps file extensions to their corresponding rule file names. */ const EXTENSION_TO_LANGUAGE: Record = { // TypeScript/JavaScript ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript", // Go go: "go", // Nix nix: "nix", // C-family c: "c", h: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", cs: "csharp", // JVM languages java: "java", kt: "kotlin", kts: "kotlin", scala: "scala", cls: "java", // Functional languages hs: "haskell", lhs: "haskell", ml: "ocaml", mli: "ocaml", fs: "fsharp", fsi: "fsharp", clj: "clojure", rlib: "rust", // Scripting languages py: "python", pyw: "python", rb: "ruby", erb: "ruby", php: "php", pl: "perl", pm: "perl", lua: "lua", // Shell sh: "shell", bash: "shell", zsh: "shell", ps1: "powershell", bat: "batch", cmd: "batch", // Systems programming rs: "rust", swift: "swift", mm: "objectivec", d: "d", v: "v", zig: "zig", vala: "vala", // Other compiled ex: "elixir", exs: "elixir", erl: "erlang", hrl: "erlang", jl: "julia", cr: "crystal", nim: "nim", rkt: "racket", // Web markup/styles html: "html", htm: "html", css: "css", scss: "css", sass: "css", less: "css", // Data/Config json: "json", yaml: "yaml", yml: "yaml", xml: "xml", toml: "toml", // Documentation md: "markdown", markdown: "markdown", tex: "latex", latex: "latex", // Database sql: "sql", ddl: "sql", // Build systems make: "makefile", cmake: "cmake", dockerfile: "docker", // Other csv: "csv", r: "r", m: "matlab", asm: "assembly", s: "assembly", f: "fortran", f90: "fortran", }; /** * Extract file extension and map to language name. */ function getLanguageFromFilePath(filePath: string): string | null { const match = filePath.match(/\.([a-zA-Z0-9]+)(?:\?.*)?$/); if (!match) return null; const ext = match[1]!.toLowerCase(); return EXTENSION_TO_LANGUAGE[ext] ?? null; } /** * Registry to track which rules have been read in each session. */ const sessionRulesRead = new Map>(); /** * Helper class for executing chainlink commands and interacting with OpenCode client. */ class ChainlinkService { constructor( private readonly $: Awaited[0]["$"]>, private readonly client: Parameters[0]["client"], ) { this.$.throws(true); } /** * Execute a chainlink command with proper error handling. */ async run( args: string[], description: string = "execute chainlink command", ): Promise { try { const fullCmd = this.$`chainlink ${args}`; const text = await Promise.race([ fullCmd.text(), new Promise((_, reject) => setTimeout( () => reject(new Error("Command timed out after 2 seconds")), 2000, ), ), ]); return { success: true, data: text }; } catch (error) { return { success: false, error: `Failed to ${description}: ${error instanceof Error ? error.message : String(error)}`, }; } } /** * Log a message using the OpenCode logging API. */ async log(level: LogLevel, message: string): Promise { await this.client.app .log({ body: { service: "chainlink-plugin", level, message, }, }) .catch(() => {}); } /** * Send a prompt to the current session. */ async prompt(sessionID: string, text: string): Promise { await this.client.session .prompt({ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }], }, }) .catch(async (error) => { await this.log( "warn", `Failed to send prompt to session ${sessionID}: ${error instanceof Error ? error.message : String(error)}`, ); }); } } export const ChainlinkPlugin: Plugin = async ({ $, client, directory }) => { const service = new ChainlinkService($, client); return { tool: { /** * Execute any chainlink command. */ chainlink: tool({ description: "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.", args: { command: tool.schema .array(tool.schema.string()) .describe("The chainlink command and arguments (e.g. 'show 42')"), }, async execute(args) { const result = await service.run(args.command); return result.success ? result.data || "" : `Error: ${result.error}`; }, }), /** * Suggest the next issue to work on. */ chainlink_next: tool({ description: "Get smart recommendation for what to work on next based on priority, blocking relationships, and session history", args: {}, async execute() { const result = await service.run(["next"], "get next suggestion"); return result.success ? result.data || "No issues ready. Create one with `chainlink` tool!" : "Unable to get suggestions. Try creating some issues first!"; }, }), /** * Read coding rules from .chainlink/rules directory. */ chainlink_rules: tool({ description: "Read coding rules from .chainlink/rules directory", args: { rule: tool.schema .string() .optional() .describe( "Specific rule file to read (e.g., 'typescript', 'go', 'javascript')", ), }, async execute(args, toolCtx) { const ruleFile = args.rule || "global"; const sessionID = toolCtx?.sessionID; if (sessionID) { if (!sessionRulesRead.has(sessionID)) { sessionRulesRead.set(sessionID, new Set()); } sessionRulesRead.get(sessionID)?.add(ruleFile); } const paths = [ `${directory}/.chainlink/rules/${ruleFile}.md`, `${directory}/.chainlink/rules/${ruleFile}`, `${directory}/.chainlink/rules/global.md`, `${directory}/.chainlink/rules/global`, ]; const rulesDir = process.env.CHAINLINK_RULES_DIR; if (rulesDir) { paths.push( `${rulesDir}/${ruleFile}.md`, `${rulesDir}/${ruleFile}`, `${rulesDir}/global.md`, `${rulesDir}/global`, ); } for (const rulePath of paths) { try { const content = await $`cat "${rulePath}"`.text(); if (content.trim()) return content.trim(); } catch {} } return `No rule file found for '${ruleFile}'. Available rules: global, go, javascript, nix, typescript`; }, }), /** * Manage chainlink sessions. */ chainlink_session: tool({ description: "Manage chainlink sessions (start, end, work, status)", args: { action: tool.schema .enum(["start", "end", "work", "status"]) .describe("Session action to perform"), id: tool.schema .string() .optional() .describe("Issue ID for 'work' action"), notes: tool.schema .string() .optional() .describe("Handoff notes for 'end' action"), }, async execute(args) { const { action, id, notes } = args; switch (action) { case "start": { const result = await service.run( ["session", "start"], "start session", ); return result.data || result.error || "Failed to start session"; } case "end": { if (!notes) return "Error: Notes are required for ending a session"; const result = await service.run( ["session", "end", "--notes", notes], "end session", ); return result.data || result.error || "Failed to end session"; } case "work": { if (!id) return "Error: Issue ID is required for 'work' action"; const cleanId = id.replace("#", ""); const result = await service.run( ["session", "work", cleanId], "set session work issue", ); return result.data || result.error || "Failed to set work issue"; } case "status": { const result = await service.run( ["session", "status"], "get session status", ); return ( result.data || result.error || "Failed to get session status" ); } default: return `Error: Unknown session action '${action}'`; } }, }), }, // === Lifecycle Hooks === "tool.execute.before": async (input) => { const { tool: toolName, sessionID, args, } = input as { tool: string; sessionID: string; callID: string; args?: Record; }; if (!sessionID) return; // Before any modification or read, ensure rules are being respected const rulesRead = sessionRulesRead.get(sessionID); const hasReadGlobal = rulesRead?.has("global"); // Always warn on modification if global rules haven't been read if ((toolName === "write" || toolName === "edit") && !hasReadGlobal) { await service.prompt( sessionID, "**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.", ); return; } // On read, suggest language rules if global is already read but language rules aren't if (toolName === "read") { const filePath = args?.filePath as string | undefined; if (!filePath) return; const languageRule = getLanguageFromFilePath(filePath); if (!languageRule) return; if (rulesRead?.has(languageRule)) return; const languageDisplay = languageRule.charAt(0).toUpperCase() + languageRule.slice(1); const ext = filePath.split(".").pop()?.toLowerCase(); await service.prompt( sessionID, `**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}' }})\`.`, ); } }, "session.created": async ({ sessionID }: { sessionID: string }) => { sessionRulesRead.delete(sessionID); try { const readyResult = await service.run(["ready"], "get ready issues"); const readyData = readyResult.data || ""; const lines = readyData.split("\n").filter(Boolean); const topIssues = lines.slice(0, 3).join("\n"); const contextText = `## Chainlink Issues **Ready to work:** ${lines.length} issue${lines.length !== 1 ? "s" : ""} ${topIssues ? `\n**Top priorities:**\n${topIssues}` : ""} ${lines.length > 3 ? "> Use chainlink({ command: 'ready' }) for full list" : ""} --- **CRITICAL:** Before writing or editing any code, you MUST read the project coding rules using the \`chainlink_rules\` tool. This ensures consistency and quality. Recommended rules to check: \`global\`, \'your-language\'. 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).`; await service.prompt(sessionID, contextText); } catch (error) { await service.log( "error", `Failed to inject session context: ${error instanceof Error ? error.message : "Unknown error"}`, ); } }, }; };