import { readFileSync } from "node:fs"; import { type Plugin, tool } from "@opencode-ai/plugin"; /** * Extension to language mapping for rule recommendations. * Only includes languages we have rule files for. */ 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", // Rust rs: "rust", // Ruby rb: "ruby", erb: "ruby", rake: "ruby", // Python py: "python", pyw: "python", pyi: "python", // Java/C-family java: "java", c: "c", h: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", cs: "csharp", // Shell sh: "shell", bash: "shell", zsh: "shell", // Web 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", // Database sql: "sql", ddl: "sql", }; /** * 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>(); export const LangRulesPlugin: Plugin = async ({ client, directory }) => { return { tool: { langrules: tool({ description: "Read the critical rules needed for style and necessary context", args: { language: tool.schema .string() .describe( "Specific rule file to read (e.g., 'typescript', 'go', 'javascript')", ), }, async execute(args, toolCtx) { const sessionID = toolCtx?.sessionID; const languageFile = args.language; if (sessionID) { if (!sessionRulesRead.has(sessionID)) { sessionRulesRead.set(sessionID, new Set()); } sessionRulesRead.get(sessionID)?.add(languageFile); } const paths = [`${directory}/.rules/${languageFile}.md`]; const rulesDir = process.env.LANGRULES_DIR; if (rulesDir) { paths.push(`${rulesDir}/${languageFile}.md`); } for (const rulePath of paths) { try { const content = readFileSync(rulePath).toString(); if (content.trim()) return content.trim(); } catch {} } return `No rule file found for '${languageFile}'`; }, }), }, "tool.execute.before": async (input) => { const { sessionID, args } = input as { tool: string; sessionID: string; callID: string; args?: Record; }; if (!sessionID) return; const filePath = (args?.filePath as string) || (args?.path as string); if (!filePath) return; const language = getLanguageFromFilePath(filePath); const ext = filePath.split(".").pop()?.toLowerCase() || ""; if (language && !sessionRulesRead.get(sessionID)?.has(language)) client.session.prompt({ path: { id: sessionID, }, body: { parts: [ { synthetic: true, type: "text", text: `**Hint:** You're about to modify a \`${ext}\` file (${language}). Consider reading the ${language} coding rules: \`langrules({ language: '${language}' }})\``, }, ], }, }); }, }; };