this repo has no description
1import { readFileSync } from "node:fs";
2import { type Plugin, tool } from "@opencode-ai/plugin";
3
4/**
5 * Extension to language mapping for rule recommendations.
6 * Only includes languages we have rule files for.
7 */
8const EXTENSION_TO_LANGUAGE: Record<string, string> = {
9 // TypeScript/JavaScript
10 ts: "typescript",
11 tsx: "typescript",
12 js: "javascript",
13 jsx: "javascript",
14 mjs: "javascript",
15 cjs: "javascript",
16
17 // Go
18 go: "go",
19
20 // Nix
21 nix: "nix",
22
23 // Rust
24 rs: "rust",
25
26 // Ruby
27 rb: "ruby",
28 erb: "ruby",
29 rake: "ruby",
30
31 // Python
32 py: "python",
33 pyw: "python",
34 pyi: "python",
35
36 // Java/C-family
37 java: "java",
38 c: "c",
39 h: "c",
40 cpp: "cpp",
41 cc: "cpp",
42 cxx: "cpp",
43 hpp: "cpp",
44 cs: "csharp",
45
46 // Shell
47 sh: "shell",
48 bash: "shell",
49 zsh: "shell",
50
51 // Web
52 html: "html",
53 htm: "html",
54 css: "css",
55 scss: "css",
56 sass: "css",
57 less: "css",
58
59 // Data/Config
60 json: "json",
61 yaml: "yaml",
62 yml: "yaml",
63 xml: "xml",
64 toml: "toml",
65
66 // Documentation
67 md: "markdown",
68 markdown: "markdown",
69
70 // Database
71 sql: "sql",
72 ddl: "sql",
73};
74
75/**
76 * Extract file extension and map to language name.
77 */
78function getLanguageFromFilePath(filePath: string): string | null {
79 const match = filePath.match(/\.([a-zA-Z0-9]+)(?:\?.*)?$/);
80 if (!match) return null;
81 const ext = match[1]!.toLowerCase();
82 return EXTENSION_TO_LANGUAGE[ext] ?? null;
83}
84
85/**
86 * Registry to track which rules have been read in each session.
87 */
88const sessionRulesRead = new Map<string, Set<string>>();
89
90export const LangRulesPlugin: Plugin = async ({ client, directory }) => {
91 return {
92 tool: {
93 langrules: tool({
94 description:
95 "Read the critical rules needed for style and necessary context",
96 args: {
97 language: tool.schema
98 .string()
99 .describe(
100 "Specific rule file to read (e.g., 'typescript', 'go', 'javascript')",
101 ),
102 },
103 async execute(args, toolCtx) {
104 const sessionID = toolCtx?.sessionID;
105 const languageFile = args.language;
106
107 if (sessionID) {
108 if (!sessionRulesRead.has(sessionID)) {
109 sessionRulesRead.set(sessionID, new Set());
110 }
111
112 sessionRulesRead.get(sessionID)?.add(languageFile);
113 }
114
115 const paths = [`${directory}/.rules/${languageFile}.md`];
116
117 const rulesDir = process.env.LANGRULES_DIR;
118 if (rulesDir) {
119 paths.push(`${rulesDir}/${languageFile}.md`);
120 }
121
122 for (const rulePath of paths) {
123 try {
124 const content = readFileSync(rulePath).toString();
125 if (content.trim()) return content.trim();
126 } catch {}
127 }
128
129 return `No rule file found for '${languageFile}'`;
130 },
131 }),
132 },
133
134 "tool.execute.before": async (input) => {
135 const { sessionID, args } = input as {
136 tool: string;
137 sessionID: string;
138 callID: string;
139 args?: Record<string, unknown>;
140 };
141
142 if (!sessionID) return;
143
144 const filePath = (args?.filePath as string) || (args?.path as string);
145 if (!filePath) return;
146
147 const language = getLanguageFromFilePath(filePath);
148 const ext = filePath.split(".").pop()?.toLowerCase() || "";
149
150 if (language && !sessionRulesRead.get(sessionID)?.has(language))
151 client.session.prompt({
152 path: {
153 id: sessionID,
154 },
155 body: {
156 parts: [
157 {
158 synthetic: true,
159 type: "text",
160 text: `**Hint:** You're about to modify a \`${ext}\` file (${language}). Consider reading the ${language} coding rules: \`langrules({ language: '${language}' }})\``,
161 },
162 ],
163 },
164 });
165 },
166 };
167};