@omnitype-code/cli 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/core/ApiClient.js +15 -0
- package/dist/core/ModelDetector.js +43 -9
- package/dist/core/ToolDetector.js +249 -0
- package/dist/core/ToolHookInstallers.js +508 -48
- package/dist/core/TranscriptScanner.js +153 -6
- package/dist/daemon.js +15 -1
- package/dist/index.js +124 -27
- package/package.json +3 -2
- package/scripts/postinstall.js +94 -0
- package/src/core/ApiClient.ts +15 -0
- package/src/core/ModelDetector.ts +43 -9
- package/src/core/ToolDetector.ts +227 -0
- package/src/core/ToolHookInstallers.ts +441 -53
- package/src/core/TranscriptScanner.ts +125 -9
- package/src/daemon.ts +13 -2
- package/src/index.ts +134 -31
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Auto-installs OmniType model-detection hooks into AI tools that support
|
|
3
|
-
* a preToolUse/postToolUse hook system
|
|
3
|
+
* a preToolUse/postToolUse hook system.
|
|
4
4
|
*
|
|
5
|
-
* Hooks write {model, tool, ts, file} to
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Hooks write {model, tool, ts, file} to <project_root>/.omnitype/active-model.json.
|
|
6
|
+
* The project root is process.cwd() at hook invocation time — this isolates every
|
|
7
|
+
* workspace so concurrent sessions in different windows never bleed into each other.
|
|
8
|
+
* Falls back to ~/.omnitype/ if cwd is outside any workspace.
|
|
8
9
|
*
|
|
9
10
|
* Each installer is idempotent — safe to call on every startup.
|
|
10
11
|
* Fails silently so it never breaks the CLI for the user.
|
|
@@ -14,41 +15,52 @@ import * as fs from 'fs';
|
|
|
14
15
|
import * as os from 'os';
|
|
15
16
|
import * as path from 'path';
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
+
export const GLOBAL_OMNITYPE_DIR = path.join(os.homedir(), '.omnitype');
|
|
18
19
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
20
|
+
// Version token embedded in every hook command.
|
|
21
|
+
// Bump whenever hook logic changes so stale installs are replaced automatically.
|
|
22
|
+
export const HOOK_VERSION = 'omnitype-hook-v4';
|
|
23
|
+
|
|
24
|
+
// Shared sentinel-write snippet: resolves dir from process.cwd(), falls back to ~/.omnitype/.
|
|
25
|
+
// `modelExpr` is a JS expression that evaluates to the model string (already extracted).
|
|
26
|
+
// `tool` is the literal tool name string.
|
|
27
|
+
const WRITE_SENTINEL = (tool: string) =>
|
|
28
|
+
`const fs=require('fs'),p=require('path'),os=require('os');` +
|
|
29
|
+
`const dir=p.join(process.cwd(),'.omnitype');` +
|
|
30
|
+
`const fbDir=p.join(os.homedir(),'.omnitype');` +
|
|
31
|
+
`fs.mkdirSync(dir,{recursive:true});` +
|
|
32
|
+
`fs.mkdirSync(fbDir,{recursive:true});` +
|
|
33
|
+
`const payload=JSON.stringify(Object.assign({model:m,tool:'${tool}',ts:Date.now()},file&&{file}));` +
|
|
34
|
+
`fs.writeFileSync(p.join(dir,'active-model.json'),payload);` +
|
|
35
|
+
`fs.writeFileSync(p.join(fbDir,'active-model.json'),payload);`;
|
|
36
|
+
|
|
37
|
+
// Generic hook: reads model from stdin JSON, writes sentinel to project root + global fallback.
|
|
21
38
|
function buildHookCommand(tool: string, modelField: string): string {
|
|
22
39
|
return (
|
|
23
|
-
`node -e "let b='';process.stdin.on('data',c=>b+=c);` +
|
|
40
|
+
`node -e "/*${HOOK_VERSION}*/let b='';process.stdin.on('data',c=>b+=c);` +
|
|
24
41
|
`process.stdin.on('end',()=>{` +
|
|
25
|
-
`try{const j=JSON.parse(b)
|
|
26
|
-
`m=j['${modelField}']||j.model
|
|
27
|
-
`fs=require('fs'),p=require('path'),os=require('os'),` +
|
|
28
|
-
`dir=p.join(os.homedir(),'.omnitype');` +
|
|
42
|
+
`try{const j=JSON.parse(b);` +
|
|
43
|
+
`let m=j['${modelField}']||j.model;` +
|
|
29
44
|
`if(!m)return;` +
|
|
30
|
-
`let file;try{file=j?.tool_input?.path||j?.tool_input?.file_path;}catch{}` +
|
|
31
|
-
|
|
32
|
-
`
|
|
33
|
-
`JSON.stringify(Object.assign({model:m,tool:'${tool}',ts:Date.now()},file&&{file})))}catch{}})"`
|
|
45
|
+
`let file;try{file=j?.tool_input?.path||j?.tool_input?.file_path||j?.toolInput?.path;}catch{}` +
|
|
46
|
+
WRITE_SENTINEL(tool) +
|
|
47
|
+
`}catch{}})"`
|
|
34
48
|
);
|
|
35
49
|
}
|
|
36
50
|
|
|
37
|
-
// Claude
|
|
38
|
-
// Still reads stdin to extract the target file path for path-gated matching.
|
|
51
|
+
// Claude Code hook: reads model from stdin first, then env, then settings.json.
|
|
39
52
|
const CLAUDE_HOOK_CMD =
|
|
40
|
-
`node -e "let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{` +
|
|
41
|
-
`
|
|
42
|
-
`
|
|
43
|
-
`
|
|
44
|
-
`if(!m)
|
|
53
|
+
`node -e "/*${HOOK_VERSION}*/let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{` +
|
|
54
|
+
`try{` +
|
|
55
|
+
`let m,file;` +
|
|
56
|
+
`try{const j=JSON.parse(b);m=j.model;file=j?.tool_input?.path||j?.tool_input?.file_path||j?.toolInput?.path;}catch{}` +
|
|
57
|
+
`if(!m)m=process.env.CLAUDE_MODEL||process.env.ANTHROPIC_MODEL;` +
|
|
58
|
+
`if(!m){try{const _fs=require('fs'),_p=require('path'),_os=require('os');` +
|
|
59
|
+
`const s=JSON.parse(_fs.readFileSync(_p.join(_os.homedir(),'.claude','settings.json'),'utf8'));` +
|
|
45
60
|
`m=s.model||s.defaultModel;}catch{}}` +
|
|
46
61
|
`if(!m)return;` +
|
|
47
|
-
|
|
48
|
-
`
|
|
49
|
-
`fs.writeFileSync(p.join(dir,'active-model.json'),` +
|
|
50
|
-
`JSON.stringify(Object.assign({model:m,tool:'claude-code',ts:Date.now()},file&&{file})))})"`;
|
|
51
|
-
|
|
62
|
+
WRITE_SENTINEL('claude-code') +
|
|
63
|
+
`}catch{}})"`;
|
|
52
64
|
function readJson(filePath: string): Record<string, any> {
|
|
53
65
|
try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return {}; }
|
|
54
66
|
}
|
|
@@ -60,6 +72,20 @@ function writeJsonAtomic(filePath: string, data: unknown): void {
|
|
|
60
72
|
fs.renameSync(tmp, filePath);
|
|
61
73
|
}
|
|
62
74
|
|
|
75
|
+
/** True if a hook command string belongs to us (any version). */
|
|
76
|
+
function isOurs(cmd: string): boolean { return cmd.includes('.omnitype'); }
|
|
77
|
+
|
|
78
|
+
/** True if a hook command belongs to us AND is current (no upgrade needed). */
|
|
79
|
+
function isCurrent(cmd: string): boolean { return cmd.includes(HOOK_VERSION); }
|
|
80
|
+
|
|
81
|
+
/** Remove stale omnitype entries from an array of hook objects (any shape). */
|
|
82
|
+
function purgeStale(arr: any[], cmdExtract: (h: any) => string | undefined): any[] {
|
|
83
|
+
return arr.filter(h => {
|
|
84
|
+
const cmd = cmdExtract(h) ?? '';
|
|
85
|
+
return !(isOurs(cmd) && !isCurrent(cmd));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
63
89
|
// ── Claude Code ───────────────────────────────────────────────────────────────
|
|
64
90
|
|
|
65
91
|
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
@@ -74,10 +100,11 @@ function installClaudeHooks(): void {
|
|
|
74
100
|
const s = readJson(CLAUDE_SETTINGS);
|
|
75
101
|
if (!s.hooks) s.hooks = {};
|
|
76
102
|
if (!Array.isArray(s.hooks.PreToolUse)) s.hooks.PreToolUse = [];
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
);
|
|
80
|
-
|
|
103
|
+
// Remove stale versions, then skip if current version already present.
|
|
104
|
+
s.hooks.PreToolUse = purgeStale(s.hooks.PreToolUse, h => h?.hooks?.[0]?.command);
|
|
105
|
+
if ((s.hooks.PreToolUse as any[]).some((h: any) => isCurrent(h?.hooks?.[0]?.command ?? ''))) return;
|
|
106
|
+
s.hooks.PreToolUse.push(CLAUDE_HOOK_ENTRY);
|
|
107
|
+
writeJsonAtomic(CLAUDE_SETTINGS, s);
|
|
81
108
|
}
|
|
82
109
|
|
|
83
110
|
// ── Cursor ────────────────────────────────────────────────────────────────────
|
|
@@ -92,10 +119,10 @@ function installCursorHooks(): void {
|
|
|
92
119
|
let changed = false;
|
|
93
120
|
for (const event of ['preToolUse', 'postToolUse']) {
|
|
94
121
|
if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
122
|
+
settings.hooks[event] = purgeStale(settings.hooks[event], h => h?.command);
|
|
123
|
+
if (!(settings.hooks[event] as any[]).some((h: any) => isCurrent(h?.command ?? ''))) {
|
|
124
|
+
settings.hooks[event].push({ command: CURSOR_CMD }); changed = true;
|
|
125
|
+
}
|
|
99
126
|
}
|
|
100
127
|
if (!settings.version) settings.version = 1;
|
|
101
128
|
if (changed) writeJsonAtomic(CURSOR_HOOKS_PATH, settings);
|
|
@@ -119,10 +146,10 @@ function installWindsurfHooks(): void {
|
|
|
119
146
|
let changed = false;
|
|
120
147
|
for (const event of WINDSURF_EVENTS) {
|
|
121
148
|
if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
settings.hooks[event] = purgeStale(settings.hooks[event], h => h?.command);
|
|
150
|
+
if (!(settings.hooks[event] as any[]).some((h: any) => isCurrent(h?.command ?? ''))) {
|
|
151
|
+
settings.hooks[event].push({ command: WINDSURF_CMD }); changed = true;
|
|
152
|
+
}
|
|
126
153
|
}
|
|
127
154
|
if (changed) writeJsonAtomic(hookPath, settings);
|
|
128
155
|
}
|
|
@@ -139,10 +166,10 @@ function installCodexHooks(): void {
|
|
|
139
166
|
let changed = false;
|
|
140
167
|
for (const event of ['PreToolUse', 'PostToolUse']) {
|
|
141
168
|
if (!Array.isArray(settings[event])) settings[event] = [];
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
169
|
+
settings[event] = purgeStale(settings[event], h => h?.command);
|
|
170
|
+
if (!(settings[event] as any[]).some((h: any) => isCurrent(h?.command ?? ''))) {
|
|
171
|
+
settings[event].push({ type: 'command', command: CODEX_CMD }); changed = true;
|
|
172
|
+
}
|
|
146
173
|
}
|
|
147
174
|
if (changed) writeJsonAtomic(CODEX_HOOKS_PATH, settings);
|
|
148
175
|
}
|
|
@@ -150,10 +177,11 @@ function installCodexHooks(): void {
|
|
|
150
177
|
// ── Cline ─────────────────────────────────────────────────────────────────────
|
|
151
178
|
// Cline looks for executable scripts in ~/Documents/Cline/Hooks/ named after
|
|
152
179
|
// hook events. We drop a PreToolUse script that reads stdin JSON and writes
|
|
153
|
-
// the sentinel only for file-modifying tools.
|
|
180
|
+
// the sentinel only for file-modifying tools, including the target file path.
|
|
154
181
|
|
|
155
182
|
const CLINE_HOOKS_DIR = path.join(os.homedir(), 'Documents', 'Cline', 'Hooks');
|
|
156
183
|
const CLINE_HOOK_SCRIPT = `#!/usr/bin/env node
|
|
184
|
+
// ${HOOK_VERSION}
|
|
157
185
|
const FILE_WRITE_TOOLS = new Set(['write_to_file','apply_diff','insert_content','search_and_replace']);
|
|
158
186
|
let buf = '';
|
|
159
187
|
process.stdin.on('data', c => buf += c);
|
|
@@ -161,11 +189,16 @@ process.stdin.on('end', () => {
|
|
|
161
189
|
try {
|
|
162
190
|
const j = JSON.parse(buf);
|
|
163
191
|
if (!FILE_WRITE_TOOLS.has(j.toolName)) return;
|
|
164
|
-
const
|
|
192
|
+
const m = j.model || j.preToolUse?.model || '';
|
|
193
|
+
const file = j.toolInput?.path || j.toolInput?.file_path || j.tool_input?.path || undefined;
|
|
165
194
|
const fs = require('fs'), p = require('path'), os = require('os');
|
|
166
|
-
const dir = p.join(
|
|
195
|
+
const dir = p.join(process.cwd(), '.omnitype');
|
|
196
|
+
const fbDir = p.join(os.homedir(), '.omnitype');
|
|
167
197
|
fs.mkdirSync(dir, { recursive: true });
|
|
168
|
-
fs.
|
|
198
|
+
fs.mkdirSync(fbDir, { recursive: true });
|
|
199
|
+
const payload = JSON.stringify(Object.assign({ model: m, tool: 'cline', ts: Date.now() }, file && { file }));
|
|
200
|
+
fs.writeFileSync(p.join(dir, 'active-model.json'), payload);
|
|
201
|
+
fs.writeFileSync(p.join(fbDir, 'active-model.json'), payload);
|
|
169
202
|
} catch {}
|
|
170
203
|
});
|
|
171
204
|
`;
|
|
@@ -173,12 +206,213 @@ process.stdin.on('end', () => {
|
|
|
173
206
|
function installClineHooks(): void {
|
|
174
207
|
if (!fs.existsSync(CLINE_HOOKS_DIR)) return;
|
|
175
208
|
const scriptPath = path.join(CLINE_HOOKS_DIR, 'PreToolUse');
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
209
|
+
let existing = '';
|
|
210
|
+
try { existing = fs.readFileSync(scriptPath, 'utf8'); } catch {}
|
|
211
|
+
if (existing.includes(HOOK_VERSION)) return; // already current
|
|
212
|
+
// Write (or overwrite stale version)
|
|
213
|
+
fs.writeFileSync(scriptPath, CLINE_HOOK_SCRIPT, 'utf8');
|
|
214
|
+
fs.chmodSync(scriptPath, 0o755);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Gemini CLI ────────────────────────────────────────────────────────────────
|
|
218
|
+
// ~/.gemini/settings.json — BeforeTool / AfterTool with nested hooks array
|
|
219
|
+
|
|
220
|
+
const GEMINI_SETTINGS_PATH = path.join(os.homedir(), '.gemini', 'settings.json');
|
|
221
|
+
const GEMINI_CMD = buildHookCommand('gemini-cli', 'model');
|
|
222
|
+
const GEMINI_HOOK_ENTRY = { matcher: '*', hooks: [{ type: 'command', command: GEMINI_CMD }] };
|
|
223
|
+
|
|
224
|
+
function installGeminiHooks(): void {
|
|
225
|
+
if (!fs.existsSync(path.join(os.homedir(), '.gemini'))) return;
|
|
226
|
+
const s = readJson(GEMINI_SETTINGS_PATH);
|
|
227
|
+
let changed = false;
|
|
228
|
+
for (const ev of ['BeforeTool', 'AfterTool']) {
|
|
229
|
+
if (!Array.isArray(s[ev])) s[ev] = [];
|
|
230
|
+
s[ev] = purgeStale(s[ev], h => h?.hooks?.[0]?.command);
|
|
231
|
+
if (!(s[ev] as any[]).some((h: any) => isCurrent(h?.hooks?.[0]?.command ?? ''))) {
|
|
232
|
+
s[ev].push(GEMINI_HOOK_ENTRY); changed = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (changed) writeJsonAtomic(GEMINI_SETTINGS_PATH, s);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Droid (Factory) ───────────────────────────────────────────────────────────
|
|
239
|
+
// ~/.factory/settings.json — PreToolUse with nested hooks array
|
|
240
|
+
|
|
241
|
+
const DROID_SETTINGS_PATH = path.join(os.homedir(), '.factory', 'settings.json');
|
|
242
|
+
const DROID_CMD = buildHookCommand('droid', 'model');
|
|
243
|
+
const DROID_HOOK_ENTRY = { matcher: '*', hooks: [{ type: 'command', command: DROID_CMD }] };
|
|
244
|
+
|
|
245
|
+
function installDroidHooks(): void {
|
|
246
|
+
if (!fs.existsSync(path.join(os.homedir(), '.factory'))) return;
|
|
247
|
+
const s = readJson(DROID_SETTINGS_PATH);
|
|
248
|
+
if (!Array.isArray(s.PreToolUse)) s.PreToolUse = [];
|
|
249
|
+
s.PreToolUse = purgeStale(s.PreToolUse, h => h?.hooks?.[0]?.command);
|
|
250
|
+
if ((s.PreToolUse as any[]).some((h: any) => isCurrent(h?.hooks?.[0]?.command ?? ''))) return;
|
|
251
|
+
s.PreToolUse.push(DROID_HOOK_ENTRY);
|
|
252
|
+
writeJsonAtomic(DROID_SETTINGS_PATH, s);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Firebender ────────────────────────────────────────────────────────────────
|
|
256
|
+
// ~/.firebender/hooks.json — preToolUse / postToolUse flat command arrays
|
|
257
|
+
|
|
258
|
+
const FIREBENDER_HOOKS_PATH = path.join(os.homedir(), '.firebender', 'hooks.json');
|
|
259
|
+
const FIREBENDER_CMD = buildHookCommand('firebender', 'model');
|
|
260
|
+
|
|
261
|
+
function installFirebenderHooks(): void {
|
|
262
|
+
if (!fs.existsSync(path.join(os.homedir(), '.firebender'))) return;
|
|
263
|
+
const s = readJson(FIREBENDER_HOOKS_PATH);
|
|
264
|
+
let changed = false;
|
|
265
|
+
for (const ev of ['preToolUse', 'postToolUse']) {
|
|
266
|
+
if (!Array.isArray(s[ev])) s[ev] = [];
|
|
267
|
+
s[ev] = purgeStale(s[ev], h => h?.command);
|
|
268
|
+
if (!(s[ev] as any[]).some((h: any) => isCurrent(h?.command ?? ''))) {
|
|
269
|
+
s[ev].push({ command: FIREBENDER_CMD }); changed = true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (changed) writeJsonAtomic(FIREBENDER_HOOKS_PATH, s);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Amp ───────────────────────────────────────────────────────────────────────
|
|
276
|
+
// Amp uses a TypeScript plugin (not hooks.json). Drop a .ts file to
|
|
277
|
+
// ~/.config/amp/plugins/omnitype.ts
|
|
278
|
+
|
|
279
|
+
const AMP_PLUGINS_DIR = path.join(os.homedir(), '.config', 'amp', 'plugins');
|
|
280
|
+
const AMP_PLUGIN_PATH = path.join(AMP_PLUGINS_DIR, 'omnitype.ts');
|
|
281
|
+
const AMP_PLUGIN_SRC = `// @i-know-the-amp-plugin-api-is-wip-and-very-experimental-right-now
|
|
282
|
+
// ${HOOK_VERSION}
|
|
283
|
+
import * as amp from 'amp';
|
|
284
|
+
import * as fs from 'fs';
|
|
285
|
+
import * as path from 'path';
|
|
286
|
+
import * as os from 'os';
|
|
287
|
+
|
|
288
|
+
function writeSentinel(model: string, file?: string) {
|
|
289
|
+
const payload = JSON.stringify(Object.assign({ model, tool: 'amp', ts: Date.now() }, file && { file }));
|
|
290
|
+
for (const dir of [path.join(process.cwd(), '.omnitype'), path.join(os.homedir(), '.omnitype')]) {
|
|
291
|
+
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'active-model.json'), payload); } catch {}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
amp.on('tool.call', (event: any) => {
|
|
296
|
+
try {
|
|
297
|
+
const model = event?.model ?? event?.session?.model;
|
|
298
|
+
if (!model) return;
|
|
299
|
+
writeSentinel(model, event?.input?.path ?? event?.input?.file_path);
|
|
300
|
+
} catch {}
|
|
301
|
+
});
|
|
302
|
+
`;
|
|
303
|
+
|
|
304
|
+
function installAmpPlugin(): void {
|
|
305
|
+
const ampDataDir = process.platform === 'win32'
|
|
306
|
+
? path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'amp')
|
|
307
|
+
: path.join(process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share'), 'amp');
|
|
308
|
+
if (!fs.existsSync(ampDataDir) && !fs.existsSync(AMP_PLUGINS_DIR)) return;
|
|
309
|
+
let existing = '';
|
|
310
|
+
try { existing = fs.readFileSync(AMP_PLUGIN_PATH, 'utf8'); } catch {}
|
|
311
|
+
if (existing.includes(HOOK_VERSION)) return;
|
|
312
|
+
fs.mkdirSync(AMP_PLUGINS_DIR, { recursive: true });
|
|
313
|
+
fs.writeFileSync(AMP_PLUGIN_PATH, AMP_PLUGIN_SRC, 'utf8');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── OpenCode ──────────────────────────────────────────────────────────────────
|
|
317
|
+
// TS plugin at ~/.config/opencode/plugins/omnitype.ts
|
|
318
|
+
|
|
319
|
+
const OPENCODE_PLUGINS_DIR = path.join(os.homedir(), '.config', 'opencode', 'plugins');
|
|
320
|
+
const OPENCODE_PLUGIN_PATH = path.join(OPENCODE_PLUGINS_DIR, 'omnitype.ts');
|
|
321
|
+
const OPENCODE_PLUGIN_SRC = `// ${HOOK_VERSION}
|
|
322
|
+
import * as fs from 'fs';
|
|
323
|
+
import * as path from 'path';
|
|
324
|
+
import * as os from 'os';
|
|
325
|
+
|
|
326
|
+
function writeSentinel(model: string, file?: string) {
|
|
327
|
+
const payload = JSON.stringify(Object.assign({ model, tool: 'opencode', ts: Date.now() }, file && { file }));
|
|
328
|
+
for (const dir of [path.join(process.cwd(), '.omnitype'), path.join(os.homedir(), '.omnitype')]) {
|
|
329
|
+
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'active-model.json'), payload); } catch {}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function activate(ctx: any) {
|
|
334
|
+
ctx.on('tool.execute.before', (event: any) => {
|
|
335
|
+
try {
|
|
336
|
+
const model = event?.model ?? event?.session?.model;
|
|
337
|
+
if (!model) return;
|
|
338
|
+
writeSentinel(model, event?.input?.path ?? event?.input?.file_path);
|
|
339
|
+
} catch {}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
`;
|
|
343
|
+
|
|
344
|
+
function installOpenCodePlugin(): void {
|
|
345
|
+
if (!fs.existsSync(path.join(os.homedir(), '.config', 'opencode'))) return;
|
|
346
|
+
let existing = '';
|
|
347
|
+
try { existing = fs.readFileSync(OPENCODE_PLUGIN_PATH, 'utf8'); } catch {}
|
|
348
|
+
if (existing.includes(HOOK_VERSION)) return;
|
|
349
|
+
fs.mkdirSync(OPENCODE_PLUGINS_DIR, { recursive: true });
|
|
350
|
+
fs.writeFileSync(OPENCODE_PLUGIN_PATH, OPENCODE_PLUGIN_SRC, 'utf8');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Pi ────────────────────────────────────────────────────────────────────────
|
|
354
|
+
// TS extension at ~/.pi/agent/extensions/omnitype.ts
|
|
355
|
+
|
|
356
|
+
const PI_EXTENSIONS_DIR = path.join(os.homedir(), '.pi', 'agent', 'extensions');
|
|
357
|
+
const PI_PLUGIN_PATH = path.join(PI_EXTENSIONS_DIR, 'omnitype.ts');
|
|
358
|
+
const PI_PLUGIN_SRC = `// ${HOOK_VERSION}
|
|
359
|
+
import * as fs from 'fs';
|
|
360
|
+
import * as path from 'path';
|
|
361
|
+
import * as os from 'os';
|
|
362
|
+
|
|
363
|
+
function writeSentinel(model: string, file?: string) {
|
|
364
|
+
const payload = JSON.stringify(Object.assign({ model, tool: 'pi', ts: Date.now() }, file && { file }));
|
|
365
|
+
for (const dir of [path.join(process.cwd(), '.omnitype'), path.join(os.homedir(), '.omnitype')]) {
|
|
366
|
+
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'active-model.json'), payload); } catch {}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export default {
|
|
371
|
+
name: 'omnitype',
|
|
372
|
+
onToolCall(event: any) {
|
|
373
|
+
try {
|
|
374
|
+
const model = event?.model ?? event?.agent?.model;
|
|
375
|
+
if (!model) return;
|
|
376
|
+
writeSentinel(model, event?.input?.path ?? event?.input?.file_path);
|
|
377
|
+
} catch {}
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
`;
|
|
381
|
+
|
|
382
|
+
function installPiPlugin(): void {
|
|
383
|
+
if (!fs.existsSync(path.join(os.homedir(), '.pi'))) return;
|
|
384
|
+
let existing = '';
|
|
385
|
+
try { existing = fs.readFileSync(PI_PLUGIN_PATH, 'utf8'); } catch {}
|
|
386
|
+
if (existing.includes(HOOK_VERSION)) return;
|
|
387
|
+
fs.mkdirSync(PI_EXTENSIONS_DIR, { recursive: true });
|
|
388
|
+
fs.writeFileSync(PI_PLUGIN_PATH, PI_PLUGIN_SRC, 'utf8');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── GitHub Copilot ────────────────────────────────────────────────────────────
|
|
392
|
+
// Copilot supports a hooks file at ~/.copilot/hooks/omnitype.json with
|
|
393
|
+
// PreToolUse / PostToolUse arrays — same shape as Claude Code.
|
|
394
|
+
|
|
395
|
+
const COPILOT_HOOKS_PATH = path.join(os.homedir(), '.copilot', 'hooks', 'omnitype.json');
|
|
396
|
+
const COPILOT_CMD = buildHookCommand('copilot', 'model');
|
|
397
|
+
|
|
398
|
+
function installCopilotHooks(): void {
|
|
399
|
+
// Require either ~/.copilot or ~/.vscode or VS Code settings to exist
|
|
400
|
+
const hasVscode = fs.existsSync(path.join(os.homedir(), '.vscode')) ||
|
|
401
|
+
fs.existsSync(path.join(os.homedir(), 'Library', 'Application Support', 'Code'));
|
|
402
|
+
const hasCopilotDir = fs.existsSync(path.join(os.homedir(), '.copilot'));
|
|
403
|
+
if (!hasVscode && !hasCopilotDir) return;
|
|
404
|
+
|
|
405
|
+
const s = readJson(COPILOT_HOOKS_PATH);
|
|
406
|
+
if (!s.hooks) s.hooks = {};
|
|
407
|
+
let changed = false;
|
|
408
|
+
for (const ev of ['PreToolUse', 'PostToolUse']) {
|
|
409
|
+
if (!Array.isArray(s.hooks[ev])) s.hooks[ev] = [];
|
|
410
|
+
s.hooks[ev] = purgeStale(s.hooks[ev], h => h?.command);
|
|
411
|
+
if (!(s.hooks[ev] as any[]).some((h: any) => isCurrent(h?.command ?? ''))) {
|
|
412
|
+
s.hooks[ev].push({ type: 'command', command: COPILOT_CMD }); changed = true;
|
|
413
|
+
}
|
|
181
414
|
}
|
|
415
|
+
if (changed) writeJsonAtomic(COPILOT_HOOKS_PATH, s);
|
|
182
416
|
}
|
|
183
417
|
|
|
184
418
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
@@ -189,4 +423,158 @@ export function installAllToolHooks(): void {
|
|
|
189
423
|
try { installWindsurfHooks(); } catch {}
|
|
190
424
|
try { installCodexHooks(); } catch {}
|
|
191
425
|
try { installClineHooks(); } catch {}
|
|
426
|
+
try { installCopilotHooks(); } catch {}
|
|
427
|
+
try { installGeminiHooks(); } catch {}
|
|
428
|
+
try { installDroidHooks(); } catch {}
|
|
429
|
+
try { installFirebenderHooks(); } catch {}
|
|
430
|
+
try { installAmpPlugin(); } catch {}
|
|
431
|
+
try { installOpenCodePlugin(); } catch {}
|
|
432
|
+
try { installPiPlugin(); } catch {}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export type HookStatus = 'installed' | 'stale' | 'not-installed' | 'tool-absent';
|
|
436
|
+
|
|
437
|
+
export interface ToolHookStatus {
|
|
438
|
+
tool: string;
|
|
439
|
+
status: HookStatus;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function checkHookStatus(): ToolHookStatus[] {
|
|
443
|
+
const results: ToolHookStatus[] = [];
|
|
444
|
+
|
|
445
|
+
// Claude Code
|
|
446
|
+
if (!fs.existsSync(CLAUDE_DIR)) {
|
|
447
|
+
results.push({ tool: 'claude-code', status: 'tool-absent' });
|
|
448
|
+
} else {
|
|
449
|
+
const hooks: any[] = readJson(CLAUDE_SETTINGS)?.hooks?.PreToolUse ?? [];
|
|
450
|
+
const cmd = (h: any) => h?.hooks?.[0]?.command ?? '';
|
|
451
|
+
if (hooks.some(h => isCurrent(cmd(h)))) results.push({ tool: 'claude-code', status: 'installed' });
|
|
452
|
+
else if (hooks.some(h => isOurs(cmd(h)))) results.push({ tool: 'claude-code', status: 'stale' });
|
|
453
|
+
else results.push({ tool: 'claude-code', status: 'not-installed' });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Cursor
|
|
457
|
+
if (!fs.existsSync(path.join(os.homedir(), '.cursor'))) {
|
|
458
|
+
results.push({ tool: 'cursor', status: 'tool-absent' });
|
|
459
|
+
} else {
|
|
460
|
+
const hooks: any[] = readJson(CURSOR_HOOKS_PATH)?.hooks?.preToolUse ?? [];
|
|
461
|
+
if (hooks.some(h => isCurrent(h?.command ?? ''))) results.push({ tool: 'cursor', status: 'installed' });
|
|
462
|
+
else if (hooks.some(h => isOurs(h?.command ?? ''))) results.push({ tool: 'cursor', status: 'stale' });
|
|
463
|
+
else results.push({ tool: 'cursor', status: 'not-installed' });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Windsurf
|
|
467
|
+
if (!fs.existsSync(path.join(os.homedir(), '.codeium'))) {
|
|
468
|
+
results.push({ tool: 'windsurf', status: 'tool-absent' });
|
|
469
|
+
} else {
|
|
470
|
+
let status: HookStatus = 'not-installed';
|
|
471
|
+
for (const hp of WINDSURF_HOOK_PATHS) {
|
|
472
|
+
const hooks: any[] = readJson(hp)?.hooks?.pre_write_code ?? [];
|
|
473
|
+
if (hooks.some(h => isCurrent(h?.command ?? ''))) { status = 'installed'; break; }
|
|
474
|
+
if (hooks.some(h => isOurs(h?.command ?? ''))) { status = 'stale'; }
|
|
475
|
+
}
|
|
476
|
+
results.push({ tool: 'windsurf', status });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Codex
|
|
480
|
+
if (!fs.existsSync(path.join(os.homedir(), '.codex'))) {
|
|
481
|
+
results.push({ tool: 'codex', status: 'tool-absent' });
|
|
482
|
+
} else {
|
|
483
|
+
const hooks: any[] = readJson(CODEX_HOOKS_PATH)?.PreToolUse ?? [];
|
|
484
|
+
if (hooks.some(h => isCurrent(h?.command ?? ''))) results.push({ tool: 'codex', status: 'installed' });
|
|
485
|
+
else if (hooks.some(h => isOurs(h?.command ?? ''))) results.push({ tool: 'codex', status: 'stale' });
|
|
486
|
+
else results.push({ tool: 'codex', status: 'not-installed' });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Cline
|
|
490
|
+
if (!fs.existsSync(CLINE_HOOKS_DIR)) {
|
|
491
|
+
results.push({ tool: 'cline', status: 'tool-absent' });
|
|
492
|
+
} else {
|
|
493
|
+
let existing = '';
|
|
494
|
+
try { existing = fs.readFileSync(path.join(CLINE_HOOKS_DIR, 'PreToolUse'), 'utf8'); } catch {}
|
|
495
|
+
if (existing.includes(HOOK_VERSION)) results.push({ tool: 'cline', status: 'installed' });
|
|
496
|
+
else if (existing.includes('.omnitype')) results.push({ tool: 'cline', status: 'stale' });
|
|
497
|
+
else results.push({ tool: 'cline', status: 'not-installed' });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// GitHub Copilot
|
|
501
|
+
const hasVscode = fs.existsSync(path.join(os.homedir(), '.vscode')) ||
|
|
502
|
+
fs.existsSync(path.join(os.homedir(), 'Library', 'Application Support', 'Code'));
|
|
503
|
+
const hasCopilotDir = fs.existsSync(path.join(os.homedir(), '.copilot'));
|
|
504
|
+
if (!hasVscode && !hasCopilotDir) {
|
|
505
|
+
results.push({ tool: 'copilot', status: 'tool-absent' });
|
|
506
|
+
} else {
|
|
507
|
+
const hooks: any[] = readJson(COPILOT_HOOKS_PATH)?.hooks?.PreToolUse ?? [];
|
|
508
|
+
if (hooks.some(h => isCurrent(h?.command ?? ''))) results.push({ tool: 'copilot', status: 'installed' });
|
|
509
|
+
else if (hooks.some(h => isOurs(h?.command ?? ''))) results.push({ tool: 'copilot', status: 'stale' });
|
|
510
|
+
else results.push({ tool: 'copilot', status: 'not-installed' });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Gemini CLI
|
|
514
|
+
if (!fs.existsSync(path.join(os.homedir(), '.gemini'))) {
|
|
515
|
+
results.push({ tool: 'gemini-cli', status: 'tool-absent' });
|
|
516
|
+
} else {
|
|
517
|
+
const hooks: any[] = readJson(GEMINI_SETTINGS_PATH)?.BeforeTool ?? [];
|
|
518
|
+
if (hooks.some(h => isCurrent(h?.hooks?.[0]?.command ?? ''))) results.push({ tool: 'gemini-cli', status: 'installed' });
|
|
519
|
+
else if (hooks.some(h => isOurs(h?.hooks?.[0]?.command ?? ''))) results.push({ tool: 'gemini-cli', status: 'stale' });
|
|
520
|
+
else results.push({ tool: 'gemini-cli', status: 'not-installed' });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Droid
|
|
524
|
+
if (!fs.existsSync(path.join(os.homedir(), '.factory'))) {
|
|
525
|
+
results.push({ tool: 'droid', status: 'tool-absent' });
|
|
526
|
+
} else {
|
|
527
|
+
const hooks: any[] = readJson(DROID_SETTINGS_PATH)?.PreToolUse ?? [];
|
|
528
|
+
if (hooks.some(h => isCurrent(h?.hooks?.[0]?.command ?? ''))) results.push({ tool: 'droid', status: 'installed' });
|
|
529
|
+
else if (hooks.some(h => isOurs(h?.hooks?.[0]?.command ?? ''))) results.push({ tool: 'droid', status: 'stale' });
|
|
530
|
+
else results.push({ tool: 'droid', status: 'not-installed' });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Firebender
|
|
534
|
+
if (!fs.existsSync(path.join(os.homedir(), '.firebender'))) {
|
|
535
|
+
results.push({ tool: 'firebender', status: 'tool-absent' });
|
|
536
|
+
} else {
|
|
537
|
+
const hooks: any[] = readJson(FIREBENDER_HOOKS_PATH)?.preToolUse ?? [];
|
|
538
|
+
if (hooks.some(h => isCurrent(h?.command ?? ''))) results.push({ tool: 'firebender', status: 'installed' });
|
|
539
|
+
else if (hooks.some(h => isOurs(h?.command ?? ''))) results.push({ tool: 'firebender', status: 'stale' });
|
|
540
|
+
else results.push({ tool: 'firebender', status: 'not-installed' });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Amp plugin
|
|
544
|
+
const ampDataDir = process.platform === 'win32'
|
|
545
|
+
? path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'amp')
|
|
546
|
+
: path.join(process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share'), 'amp');
|
|
547
|
+
if (!fs.existsSync(ampDataDir) && !fs.existsSync(AMP_PLUGINS_DIR)) {
|
|
548
|
+
results.push({ tool: 'amp', status: 'tool-absent' });
|
|
549
|
+
} else {
|
|
550
|
+
let src = '';
|
|
551
|
+
try { src = fs.readFileSync(AMP_PLUGIN_PATH, 'utf8'); } catch {}
|
|
552
|
+
if (src.includes(HOOK_VERSION)) results.push({ tool: 'amp', status: 'installed' });
|
|
553
|
+
else if (src.includes('.omnitype')) results.push({ tool: 'amp', status: 'stale' });
|
|
554
|
+
else results.push({ tool: 'amp', status: 'not-installed' });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// OpenCode plugin
|
|
558
|
+
if (!fs.existsSync(path.join(os.homedir(), '.config', 'opencode'))) {
|
|
559
|
+
results.push({ tool: 'opencode', status: 'tool-absent' });
|
|
560
|
+
} else {
|
|
561
|
+
let src = '';
|
|
562
|
+
try { src = fs.readFileSync(OPENCODE_PLUGIN_PATH, 'utf8'); } catch {}
|
|
563
|
+
if (src.includes(HOOK_VERSION)) results.push({ tool: 'opencode', status: 'installed' });
|
|
564
|
+
else if (src.includes('.omnitype')) results.push({ tool: 'opencode', status: 'stale' });
|
|
565
|
+
else results.push({ tool: 'opencode', status: 'not-installed' });
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Pi plugin
|
|
569
|
+
if (!fs.existsSync(path.join(os.homedir(), '.pi'))) {
|
|
570
|
+
results.push({ tool: 'pi', status: 'tool-absent' });
|
|
571
|
+
} else {
|
|
572
|
+
let src = '';
|
|
573
|
+
try { src = fs.readFileSync(PI_PLUGIN_PATH, 'utf8'); } catch {}
|
|
574
|
+
if (src.includes(HOOK_VERSION)) results.push({ tool: 'pi', status: 'installed' });
|
|
575
|
+
else if (src.includes('.omnitype')) results.push({ tool: 'pi', status: 'stale' });
|
|
576
|
+
else results.push({ tool: 'pi', status: 'not-installed' });
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return results;
|
|
192
580
|
}
|