@omnitype-code/cli 0.1.2 → 0.1.4

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.
Files changed (32) hide show
  1. package/.omnitype/active-model.json +1 -0
  2. package/CHANGELOG.md +18 -0
  3. package/dist/__tests__/HookIntegration.test.js +255 -0
  4. package/dist/__tests__/ModelDetector.test.js +240 -0
  5. package/dist/__tests__/ModelDetectorCoverage.test.js +167 -0
  6. package/dist/__tests__/ToolDetector.test.js +251 -0
  7. package/dist/__tests__/ToolHookInstallers.test.js +272 -0
  8. package/dist/__tests__/ToolHookInstallersCoverage.test.js +262 -0
  9. package/dist/__tests__/TranscriptScanner.test.js +201 -0
  10. package/dist/core/ApiClient.js +15 -0
  11. package/dist/core/ModelDetector.js +43 -9
  12. package/dist/core/ToolDetector.js +249 -0
  13. package/dist/core/ToolHookInstallers.js +557 -55
  14. package/dist/core/TranscriptScanner.js +153 -6
  15. package/dist/daemon.js +15 -1
  16. package/dist/index.js +124 -27
  17. package/package.json +31 -2
  18. package/scripts/postinstall.js +94 -0
  19. package/src/__tests__/HookIntegration.test.ts +261 -0
  20. package/src/__tests__/ModelDetector.test.ts +252 -0
  21. package/src/__tests__/ModelDetectorCoverage.test.ts +154 -0
  22. package/src/__tests__/ToolDetector.test.ts +238 -0
  23. package/src/__tests__/ToolHookInstallers.test.ts +281 -0
  24. package/src/__tests__/ToolHookInstallersCoverage.test.ts +237 -0
  25. package/src/__tests__/TranscriptScanner.test.ts +201 -0
  26. package/src/core/ApiClient.ts +15 -0
  27. package/src/core/ModelDetector.ts +43 -9
  28. package/src/core/ToolDetector.ts +227 -0
  29. package/src/core/ToolHookInstallers.ts +492 -61
  30. package/src/core/TranscriptScanner.ts +125 -9
  31. package/src/daemon.ts +13 -2
  32. 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: Claude Code, Cursor, Windsurf, Codex, Cline.
3
+ * a preToolUse/postToolUse hook system.
4
4
  *
5
- * Hooks write {model, tool, ts, file} to ~/.omnitype/active-model.json.
6
- * Including the target file path lets the detector use path-matching instead
7
- * of a short TTL eliminating the race between human and AI edits.
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,71 @@ import * as fs from 'fs';
14
15
  import * as os from 'os';
15
16
  import * as path from 'path';
16
17
 
17
- const OMNITYPE_DIR = path.join(os.homedir(), '.omnitype');
18
+ export const GLOBAL_OMNITYPE_DIR = path.join(os.homedir(), '.omnitype');
18
19
 
19
- // Reads stdin JSON, extracts model + file path, writes sentinel.
20
- // `modelField` is the tool-specific key for model in the hook payload.
21
- function buildHookCommand(tool: string, modelField: string): string {
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-v5';
23
+
24
+ // Shared sentinel-write snippet: resolves dir from process.cwd(), falls back to ~/.omnitype/.
25
+ const WRITE_SENTINEL = (tool: string) =>
26
+ `const fs=require('fs'),p=require('path'),os=require('os');` +
27
+ `const dir=p.join(process.cwd(),'.omnitype');` +
28
+ `const fbDir=p.join(os.homedir(),'.omnitype');` +
29
+ `fs.mkdirSync(dir,{recursive:true});` +
30
+ `fs.mkdirSync(fbDir,{recursive:true});` +
31
+ `const payload=JSON.stringify(Object.assign({model:m,tool:'${tool}',ts:Date.now()},file&&{file}));` +
32
+ `fs.writeFileSync(p.join(dir,'active-model.json'),payload);` +
33
+ `fs.writeFileSync(p.join(fbDir,'active-model.json'),payload);`;
34
+
35
+ // Extracts model from a JSONL transcript by reading the tail (last 50KB).
36
+ // Looks for message.model on assistant turns, or session.model_change events.
37
+ // Same approach as git-ai's extract_model_from_jsonl_tail.
38
+ const EXTRACT_MODEL_FROM_JSONL =
39
+ `function extractModelFromJsonl(tp){` +
40
+ `try{` +
41
+ `const _fs=require('fs');` +
42
+ `const stat=_fs.statSync(tp);` +
43
+ `const readSize=Math.min(51200,stat.size);` +
44
+ `const buf=Buffer.alloc(readSize);` +
45
+ `const fd=_fs.openSync(tp,'r');` +
46
+ `_fs.readSync(fd,buf,0,readSize,stat.size-readSize);` +
47
+ `_fs.closeSync(fd);` +
48
+ `const lines=buf.toString('utf8').split('\\n').reverse();` +
49
+ `for(const line of lines){` +
50
+ `if(!line.trim())continue;` +
51
+ `try{const o=JSON.parse(line);` +
52
+ `if(o.type==='session.model_change'&&o.data&&o.data.newModel)return o.data.newModel;` +
53
+ `const m=o?.message?.model||o?.model;` +
54
+ `if(m&&m!=='<synthetic>')return m;` +
55
+ `}catch{}` +
56
+ `}` +
57
+ `}catch{}` +
58
+ `return null;` +
59
+ `}`;
60
+
61
+ // Universal hook: works for every tool without per-tool customization.
62
+ // Resolution order:
63
+ // 1. Direct payload fields (covers Cursor, Windsurf, Codex, Cline, Gemini, etc.)
64
+ // 2. transcript_path JSONL tail (covers Claude Code, Gemini — same as git-ai)
65
+ // 3. Env vars as last resort
66
+ function buildHookCommand(tool: string): string {
22
67
  return (
23
- `node -e "let b='';process.stdin.on('data',c=>b+=c);` +
24
- `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');` +
68
+ `node -e "/*${HOOK_VERSION}*/` +
69
+ EXTRACT_MODEL_FROM_JSONL +
70
+ `let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{` +
71
+ `try{const j=JSON.parse(b);` +
72
+ `let file;try{file=j?.tool_input?.path||j?.tool_input?.file_path||j?.toolInput?.path;}catch{}` +
73
+ `let m=j.model||j.model_name||j.modelName||j.modelID||j?.data?.model||null;` +
74
+ `if(!m&&j.transcript_path)m=extractModelFromJsonl(j.transcript_path);` +
75
+ `if(!m)m=process.env.CLAUDE_MODEL||process.env.ANTHROPIC_MODEL||null;` +
29
76
  `if(!m)return;` +
30
- `let file;try{file=j?.tool_input?.path||j?.tool_input?.file_path;}catch{}` +
31
- `fs.mkdirSync(dir,{recursive:true});` +
32
- `fs.writeFileSync(p.join(dir,'active-model.json'),` +
33
- `JSON.stringify(Object.assign({model:m,tool:'${tool}',ts:Date.now()},file&&{file})))}catch{}})"`
77
+ WRITE_SENTINEL(tool) +
78
+ `}catch{}})"`
34
79
  );
35
80
  }
36
81
 
37
- // Claude doesn't expose model in hook stdin — reads from env/settings instead.
38
- // Still reads stdin to extract the target file path for path-gated matching.
39
- const CLAUDE_HOOK_CMD =
40
- `node -e "let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{` +
41
- `const fs=require('fs'),os=require('os'),p=require('path');` +
42
- `const dir=p.join(os.homedir(),'.omnitype');` +
43
- `let m=process.env.CLAUDE_MODEL||process.env.ANTHROPIC_MODEL;` +
44
- `if(!m){try{const s=JSON.parse(fs.readFileSync(p.join(os.homedir(),'.claude','settings.json'),'utf8'));` +
45
- `m=s.model||s.defaultModel;}catch{}}` +
46
- `if(!m)return;` +
47
- `let file;try{const j=JSON.parse(b);file=j?.tool_input?.path||j?.tool_input?.file_path;}catch{}` +
48
- `fs.mkdirSync(dir,{recursive:true});` +
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
-
82
+ const CLAUDE_HOOK_CMD = buildHookCommand('claude-code');
52
83
  function readJson(filePath: string): Record<string, any> {
53
84
  try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return {}; }
54
85
  }
@@ -60,6 +91,20 @@ function writeJsonAtomic(filePath: string, data: unknown): void {
60
91
  fs.renameSync(tmp, filePath);
61
92
  }
62
93
 
94
+ /** True if a hook command string belongs to us (any version). */
95
+ function isOurs(cmd: string): boolean { return cmd.includes('.omnitype'); }
96
+
97
+ /** True if a hook command belongs to us AND is current (no upgrade needed). */
98
+ function isCurrent(cmd: string): boolean { return cmd.includes(HOOK_VERSION); }
99
+
100
+ /** Remove stale omnitype entries from an array of hook objects (any shape). */
101
+ function purgeStale(arr: any[], cmdExtract: (h: any) => string | undefined): any[] {
102
+ return arr.filter(h => {
103
+ const cmd = cmdExtract(h) ?? '';
104
+ return !(isOurs(cmd) && !isCurrent(cmd));
105
+ });
106
+ }
107
+
63
108
  // ── Claude Code ───────────────────────────────────────────────────────────────
64
109
 
65
110
  const CLAUDE_DIR = path.join(os.homedir(), '.claude');
@@ -74,16 +119,17 @@ function installClaudeHooks(): void {
74
119
  const s = readJson(CLAUDE_SETTINGS);
75
120
  if (!s.hooks) s.hooks = {};
76
121
  if (!Array.isArray(s.hooks.PreToolUse)) s.hooks.PreToolUse = [];
77
- const already = (s.hooks.PreToolUse as any[]).some(
78
- (h: any) => typeof h?.hooks?.[0]?.command === 'string' && h.hooks[0].command.includes('.omnitype')
79
- );
80
- if (!already) { s.hooks.PreToolUse.push(CLAUDE_HOOK_ENTRY); writeJsonAtomic(CLAUDE_SETTINGS, s); }
122
+ // Remove stale versions, then skip if current version already present.
123
+ s.hooks.PreToolUse = purgeStale(s.hooks.PreToolUse, h => h?.hooks?.[0]?.command);
124
+ if ((s.hooks.PreToolUse as any[]).some((h: any) => isCurrent(h?.hooks?.[0]?.command ?? ''))) return;
125
+ s.hooks.PreToolUse.push(CLAUDE_HOOK_ENTRY);
126
+ writeJsonAtomic(CLAUDE_SETTINGS, s);
81
127
  }
82
128
 
83
129
  // ── Cursor ────────────────────────────────────────────────────────────────────
84
130
 
85
131
  const CURSOR_HOOKS_PATH = path.join(os.homedir(), '.cursor', 'hooks.json');
86
- const CURSOR_CMD = buildHookCommand('cursor', 'model');
132
+ const CURSOR_CMD = buildHookCommand('cursor');
87
133
 
88
134
  function installCursorHooks(): void {
89
135
  if (!fs.existsSync(path.join(os.homedir(), '.cursor'))) return;
@@ -92,10 +138,10 @@ function installCursorHooks(): void {
92
138
  let changed = false;
93
139
  for (const event of ['preToolUse', 'postToolUse']) {
94
140
  if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
95
- const already = (settings.hooks[event] as any[]).some(
96
- (h: any) => typeof h?.command === 'string' && h.command.includes('.omnitype')
97
- );
98
- if (!already) { settings.hooks[event].push({ command: CURSOR_CMD }); changed = true; }
141
+ settings.hooks[event] = purgeStale(settings.hooks[event], h => h?.command);
142
+ if (!(settings.hooks[event] as any[]).some((h: any) => isCurrent(h?.command ?? ''))) {
143
+ settings.hooks[event].push({ command: CURSOR_CMD }); changed = true;
144
+ }
99
145
  }
100
146
  if (!settings.version) settings.version = 1;
101
147
  if (changed) writeJsonAtomic(CURSOR_HOOKS_PATH, settings);
@@ -108,7 +154,7 @@ const WINDSURF_HOOK_PATHS = [
108
154
  path.join(os.homedir(), '.codeium', 'hooks.json'),
109
155
  ];
110
156
  const WINDSURF_EVENTS = ['pre_write_code', 'post_write_code', 'pre_run_command', 'post_run_command'];
111
- const WINDSURF_CMD = buildHookCommand('windsurf', 'model_name');
157
+ const WINDSURF_CMD = buildHookCommand('windsurf');
112
158
 
113
159
  function installWindsurfHooks(): void {
114
160
  if (!fs.existsSync(path.join(os.homedir(), '.codeium'))) return;
@@ -119,10 +165,10 @@ function installWindsurfHooks(): void {
119
165
  let changed = false;
120
166
  for (const event of WINDSURF_EVENTS) {
121
167
  if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
122
- const already = (settings.hooks[event] as any[]).some(
123
- (h: any) => typeof h?.command === 'string' && h.command.includes('.omnitype')
124
- );
125
- if (!already) { settings.hooks[event].push({ command: WINDSURF_CMD }); changed = true; }
168
+ settings.hooks[event] = purgeStale(settings.hooks[event], h => h?.command);
169
+ if (!(settings.hooks[event] as any[]).some((h: any) => isCurrent(h?.command ?? ''))) {
170
+ settings.hooks[event].push({ command: WINDSURF_CMD }); changed = true;
171
+ }
126
172
  }
127
173
  if (changed) writeJsonAtomic(hookPath, settings);
128
174
  }
@@ -131,7 +177,7 @@ function installWindsurfHooks(): void {
131
177
  // ── Codex ─────────────────────────────────────────────────────────────────────
132
178
 
133
179
  const CODEX_HOOKS_PATH = path.join(os.homedir(), '.codex', 'hooks.json');
134
- const CODEX_CMD = buildHookCommand('codex', 'model');
180
+ const CODEX_CMD = buildHookCommand('codex');
135
181
 
136
182
  function installCodexHooks(): void {
137
183
  if (!fs.existsSync(path.join(os.homedir(), '.codex'))) return;
@@ -139,10 +185,10 @@ function installCodexHooks(): void {
139
185
  let changed = false;
140
186
  for (const event of ['PreToolUse', 'PostToolUse']) {
141
187
  if (!Array.isArray(settings[event])) settings[event] = [];
142
- const already = (settings[event] as any[]).some(
143
- (h: any) => typeof h?.command === 'string' && h.command.includes('.omnitype')
144
- );
145
- if (!already) { settings[event].push({ type: 'command', command: CODEX_CMD }); changed = true; }
188
+ settings[event] = purgeStale(settings[event], h => h?.command);
189
+ if (!(settings[event] as any[]).some((h: any) => isCurrent(h?.command ?? ''))) {
190
+ settings[event].push({ type: 'command', command: CODEX_CMD }); changed = true;
191
+ }
146
192
  }
147
193
  if (changed) writeJsonAtomic(CODEX_HOOKS_PATH, settings);
148
194
  }
@@ -150,22 +196,52 @@ function installCodexHooks(): void {
150
196
  // ── Cline ─────────────────────────────────────────────────────────────────────
151
197
  // Cline looks for executable scripts in ~/Documents/Cline/Hooks/ named after
152
198
  // hook events. We drop a PreToolUse script that reads stdin JSON and writes
153
- // the sentinel only for file-modifying tools.
199
+ // the sentinel only for file-modifying tools, including the target file path.
154
200
 
155
201
  const CLINE_HOOKS_DIR = path.join(os.homedir(), 'Documents', 'Cline', 'Hooks');
156
202
  const CLINE_HOOK_SCRIPT = `#!/usr/bin/env node
203
+ // ${HOOK_VERSION}
157
204
  const FILE_WRITE_TOOLS = new Set(['write_to_file','apply_diff','insert_content','search_and_replace']);
205
+ function extractModelFromJsonl(tp) {
206
+ try {
207
+ const fs = require('fs');
208
+ const stat = fs.statSync(tp);
209
+ const readSize = Math.min(51200, stat.size);
210
+ const buf = Buffer.alloc(readSize);
211
+ const fd = fs.openSync(tp, 'r');
212
+ fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
213
+ fs.closeSync(fd);
214
+ const lines = buf.toString('utf8').split('\\n').reverse();
215
+ for (const line of lines) {
216
+ if (!line.trim()) continue;
217
+ try {
218
+ const o = JSON.parse(line);
219
+ if (o.type === 'session.model_change' && o.data?.newModel) return o.data.newModel;
220
+ const m = o?.message?.model || o?.model;
221
+ if (m && m !== '<synthetic>') return m;
222
+ } catch {}
223
+ }
224
+ } catch {}
225
+ return null;
226
+ }
158
227
  let buf = '';
159
228
  process.stdin.on('data', c => buf += c);
160
229
  process.stdin.on('end', () => {
161
230
  try {
162
231
  const j = JSON.parse(buf);
163
232
  if (!FILE_WRITE_TOOLS.has(j.toolName)) return;
164
- const model = j.model || j.preToolUse?.model || '';
233
+ const m = j.model || j.model_name || j.modelName || j.modelID || j?.data?.model
234
+ || (j.transcript_path ? extractModelFromJsonl(j.transcript_path) : null)
235
+ || j.preToolUse?.model || '';
236
+ const file = j.toolInput?.path || j.toolInput?.file_path || j.tool_input?.path || undefined;
165
237
  const fs = require('fs'), p = require('path'), os = require('os');
166
- const dir = p.join(os.homedir(), '.omnitype');
238
+ const dir = p.join(process.cwd(), '.omnitype');
239
+ const fbDir = p.join(os.homedir(), '.omnitype');
167
240
  fs.mkdirSync(dir, { recursive: true });
168
- fs.writeFileSync(p.join(dir, 'active-model.json'), JSON.stringify({ model, tool: 'cline', ts: Date.now() }));
241
+ fs.mkdirSync(fbDir, { recursive: true });
242
+ const payload = JSON.stringify(Object.assign({ model: m, tool: 'cline', ts: Date.now() }, file && { file }));
243
+ fs.writeFileSync(p.join(dir, 'active-model.json'), payload);
244
+ fs.writeFileSync(p.join(fbDir, 'active-model.json'), payload);
169
245
  } catch {}
170
246
  });
171
247
  `;
@@ -173,12 +249,213 @@ process.stdin.on('end', () => {
173
249
  function installClineHooks(): void {
174
250
  if (!fs.existsSync(CLINE_HOOKS_DIR)) return;
175
251
  const scriptPath = path.join(CLINE_HOOKS_DIR, 'PreToolUse');
176
- const already = fs.existsSync(scriptPath) &&
177
- fs.readFileSync(scriptPath, 'utf8').includes('.omnitype');
178
- if (!already) {
179
- fs.writeFileSync(scriptPath, CLINE_HOOK_SCRIPT, 'utf8');
180
- fs.chmodSync(scriptPath, 0o755);
252
+ let existing = '';
253
+ try { existing = fs.readFileSync(scriptPath, 'utf8'); } catch {}
254
+ if (existing.includes(HOOK_VERSION)) return; // already current
255
+ // Write (or overwrite stale version)
256
+ fs.writeFileSync(scriptPath, CLINE_HOOK_SCRIPT, 'utf8');
257
+ fs.chmodSync(scriptPath, 0o755);
258
+ }
259
+
260
+ // ── Gemini CLI ────────────────────────────────────────────────────────────────
261
+ // ~/.gemini/settings.json — BeforeTool / AfterTool with nested hooks array
262
+
263
+ const GEMINI_SETTINGS_PATH = path.join(os.homedir(), '.gemini', 'settings.json');
264
+ const GEMINI_CMD = buildHookCommand('gemini-cli');
265
+ const GEMINI_HOOK_ENTRY = { matcher: '*', hooks: [{ type: 'command', command: GEMINI_CMD }] };
266
+
267
+ function installGeminiHooks(): void {
268
+ if (!fs.existsSync(path.join(os.homedir(), '.gemini'))) return;
269
+ const s = readJson(GEMINI_SETTINGS_PATH);
270
+ let changed = false;
271
+ for (const ev of ['BeforeTool', 'AfterTool']) {
272
+ if (!Array.isArray(s[ev])) s[ev] = [];
273
+ s[ev] = purgeStale(s[ev], h => h?.hooks?.[0]?.command);
274
+ if (!(s[ev] as any[]).some((h: any) => isCurrent(h?.hooks?.[0]?.command ?? ''))) {
275
+ s[ev].push(GEMINI_HOOK_ENTRY); changed = true;
276
+ }
277
+ }
278
+ if (changed) writeJsonAtomic(GEMINI_SETTINGS_PATH, s);
279
+ }
280
+
281
+ // ── Droid (Factory) ───────────────────────────────────────────────────────────
282
+ // ~/.factory/settings.json — PreToolUse with nested hooks array
283
+
284
+ const DROID_SETTINGS_PATH = path.join(os.homedir(), '.factory', 'settings.json');
285
+ const DROID_CMD = buildHookCommand('droid');
286
+ const DROID_HOOK_ENTRY = { matcher: '*', hooks: [{ type: 'command', command: DROID_CMD }] };
287
+
288
+ function installDroidHooks(): void {
289
+ if (!fs.existsSync(path.join(os.homedir(), '.factory'))) return;
290
+ const s = readJson(DROID_SETTINGS_PATH);
291
+ if (!Array.isArray(s.PreToolUse)) s.PreToolUse = [];
292
+ s.PreToolUse = purgeStale(s.PreToolUse, h => h?.hooks?.[0]?.command);
293
+ if ((s.PreToolUse as any[]).some((h: any) => isCurrent(h?.hooks?.[0]?.command ?? ''))) return;
294
+ s.PreToolUse.push(DROID_HOOK_ENTRY);
295
+ writeJsonAtomic(DROID_SETTINGS_PATH, s);
296
+ }
297
+
298
+ // ── Firebender ────────────────────────────────────────────────────────────────
299
+ // ~/.firebender/hooks.json — preToolUse / postToolUse flat command arrays
300
+
301
+ const FIREBENDER_HOOKS_PATH = path.join(os.homedir(), '.firebender', 'hooks.json');
302
+ const FIREBENDER_CMD = buildHookCommand('firebender');
303
+
304
+ function installFirebenderHooks(): void {
305
+ if (!fs.existsSync(path.join(os.homedir(), '.firebender'))) return;
306
+ const s = readJson(FIREBENDER_HOOKS_PATH);
307
+ let changed = false;
308
+ for (const ev of ['preToolUse', 'postToolUse']) {
309
+ if (!Array.isArray(s[ev])) s[ev] = [];
310
+ s[ev] = purgeStale(s[ev], h => h?.command);
311
+ if (!(s[ev] as any[]).some((h: any) => isCurrent(h?.command ?? ''))) {
312
+ s[ev].push({ command: FIREBENDER_CMD }); changed = true;
313
+ }
181
314
  }
315
+ if (changed) writeJsonAtomic(FIREBENDER_HOOKS_PATH, s);
316
+ }
317
+
318
+ // ── Amp ───────────────────────────────────────────────────────────────────────
319
+ // Amp uses a TypeScript plugin (not hooks.json). Drop a .ts file to
320
+ // ~/.config/amp/plugins/omnitype.ts
321
+
322
+ const AMP_PLUGINS_DIR = path.join(os.homedir(), '.config', 'amp', 'plugins');
323
+ const AMP_PLUGIN_PATH = path.join(AMP_PLUGINS_DIR, 'omnitype.ts');
324
+ const AMP_PLUGIN_SRC = `// @i-know-the-amp-plugin-api-is-wip-and-very-experimental-right-now
325
+ // ${HOOK_VERSION}
326
+ import * as amp from 'amp';
327
+ import * as fs from 'fs';
328
+ import * as path from 'path';
329
+ import * as os from 'os';
330
+
331
+ function writeSentinel(model: string, file?: string) {
332
+ const payload = JSON.stringify(Object.assign({ model, tool: 'amp', ts: Date.now() }, file && { file }));
333
+ for (const dir of [path.join(process.cwd(), '.omnitype'), path.join(os.homedir(), '.omnitype')]) {
334
+ try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'active-model.json'), payload); } catch {}
335
+ }
336
+ }
337
+
338
+ amp.on('tool.call', (event: any) => {
339
+ try {
340
+ const model = event?.model ?? event?.session?.model;
341
+ if (!model) return;
342
+ writeSentinel(model, event?.input?.path ?? event?.input?.file_path);
343
+ } catch {}
344
+ });
345
+ `;
346
+
347
+ function installAmpPlugin(): void {
348
+ const ampDataDir = process.platform === 'win32'
349
+ ? path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'amp')
350
+ : path.join(process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share'), 'amp');
351
+ if (!fs.existsSync(ampDataDir) && !fs.existsSync(AMP_PLUGINS_DIR)) return;
352
+ let existing = '';
353
+ try { existing = fs.readFileSync(AMP_PLUGIN_PATH, 'utf8'); } catch {}
354
+ if (existing.includes(HOOK_VERSION)) return;
355
+ fs.mkdirSync(AMP_PLUGINS_DIR, { recursive: true });
356
+ fs.writeFileSync(AMP_PLUGIN_PATH, AMP_PLUGIN_SRC, 'utf8');
357
+ }
358
+
359
+ // ── OpenCode ──────────────────────────────────────────────────────────────────
360
+ // TS plugin at ~/.config/opencode/plugins/omnitype.ts
361
+
362
+ const OPENCODE_PLUGINS_DIR = path.join(os.homedir(), '.config', 'opencode', 'plugins');
363
+ const OPENCODE_PLUGIN_PATH = path.join(OPENCODE_PLUGINS_DIR, 'omnitype.ts');
364
+ const OPENCODE_PLUGIN_SRC = `// ${HOOK_VERSION}
365
+ import * as fs from 'fs';
366
+ import * as path from 'path';
367
+ import * as os from 'os';
368
+
369
+ function writeSentinel(model: string, file?: string) {
370
+ const payload = JSON.stringify(Object.assign({ model, tool: 'opencode', ts: Date.now() }, file && { file }));
371
+ for (const dir of [path.join(process.cwd(), '.omnitype'), path.join(os.homedir(), '.omnitype')]) {
372
+ try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'active-model.json'), payload); } catch {}
373
+ }
374
+ }
375
+
376
+ export function activate(ctx: any) {
377
+ ctx.on('tool.execute.before', (event: any) => {
378
+ try {
379
+ const model = event?.model ?? event?.session?.model;
380
+ if (!model) return;
381
+ writeSentinel(model, event?.input?.path ?? event?.input?.file_path);
382
+ } catch {}
383
+ });
384
+ }
385
+ `;
386
+
387
+ function installOpenCodePlugin(): void {
388
+ if (!fs.existsSync(path.join(os.homedir(), '.config', 'opencode'))) return;
389
+ let existing = '';
390
+ try { existing = fs.readFileSync(OPENCODE_PLUGIN_PATH, 'utf8'); } catch {}
391
+ if (existing.includes(HOOK_VERSION)) return;
392
+ fs.mkdirSync(OPENCODE_PLUGINS_DIR, { recursive: true });
393
+ fs.writeFileSync(OPENCODE_PLUGIN_PATH, OPENCODE_PLUGIN_SRC, 'utf8');
394
+ }
395
+
396
+ // ── Pi ────────────────────────────────────────────────────────────────────────
397
+ // TS extension at ~/.pi/agent/extensions/omnitype.ts
398
+
399
+ const PI_EXTENSIONS_DIR = path.join(os.homedir(), '.pi', 'agent', 'extensions');
400
+ const PI_PLUGIN_PATH = path.join(PI_EXTENSIONS_DIR, 'omnitype.ts');
401
+ const PI_PLUGIN_SRC = `// ${HOOK_VERSION}
402
+ import * as fs from 'fs';
403
+ import * as path from 'path';
404
+ import * as os from 'os';
405
+
406
+ function writeSentinel(model: string, file?: string) {
407
+ const payload = JSON.stringify(Object.assign({ model, tool: 'pi', ts: Date.now() }, file && { file }));
408
+ for (const dir of [path.join(process.cwd(), '.omnitype'), path.join(os.homedir(), '.omnitype')]) {
409
+ try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'active-model.json'), payload); } catch {}
410
+ }
411
+ }
412
+
413
+ export default {
414
+ name: 'omnitype',
415
+ onToolCall(event: any) {
416
+ try {
417
+ const model = event?.model ?? event?.agent?.model;
418
+ if (!model) return;
419
+ writeSentinel(model, event?.input?.path ?? event?.input?.file_path);
420
+ } catch {}
421
+ },
422
+ };
423
+ `;
424
+
425
+ function installPiPlugin(): void {
426
+ if (!fs.existsSync(path.join(os.homedir(), '.pi'))) return;
427
+ let existing = '';
428
+ try { existing = fs.readFileSync(PI_PLUGIN_PATH, 'utf8'); } catch {}
429
+ if (existing.includes(HOOK_VERSION)) return;
430
+ fs.mkdirSync(PI_EXTENSIONS_DIR, { recursive: true });
431
+ fs.writeFileSync(PI_PLUGIN_PATH, PI_PLUGIN_SRC, 'utf8');
432
+ }
433
+
434
+ // ── GitHub Copilot ────────────────────────────────────────────────────────────
435
+ // Copilot supports a hooks file at ~/.copilot/hooks/omnitype.json with
436
+ // PreToolUse / PostToolUse arrays — same shape as Claude Code.
437
+
438
+ const COPILOT_HOOKS_PATH = path.join(os.homedir(), '.copilot', 'hooks', 'omnitype.json');
439
+ const COPILOT_CMD = buildHookCommand('copilot');
440
+
441
+ function installCopilotHooks(): void {
442
+ // Require either ~/.copilot or ~/.vscode or VS Code settings to exist
443
+ const hasVscode = fs.existsSync(path.join(os.homedir(), '.vscode')) ||
444
+ fs.existsSync(path.join(os.homedir(), 'Library', 'Application Support', 'Code'));
445
+ const hasCopilotDir = fs.existsSync(path.join(os.homedir(), '.copilot'));
446
+ if (!hasVscode && !hasCopilotDir) return;
447
+
448
+ const s = readJson(COPILOT_HOOKS_PATH);
449
+ if (!s.hooks) s.hooks = {};
450
+ let changed = false;
451
+ for (const ev of ['PreToolUse', 'PostToolUse']) {
452
+ if (!Array.isArray(s.hooks[ev])) s.hooks[ev] = [];
453
+ s.hooks[ev] = purgeStale(s.hooks[ev], h => h?.command);
454
+ if (!(s.hooks[ev] as any[]).some((h: any) => isCurrent(h?.command ?? ''))) {
455
+ s.hooks[ev].push({ type: 'command', command: COPILOT_CMD }); changed = true;
456
+ }
457
+ }
458
+ if (changed) writeJsonAtomic(COPILOT_HOOKS_PATH, s);
182
459
  }
183
460
 
184
461
  // ── Public API ────────────────────────────────────────────────────────────────
@@ -189,4 +466,158 @@ export function installAllToolHooks(): void {
189
466
  try { installWindsurfHooks(); } catch {}
190
467
  try { installCodexHooks(); } catch {}
191
468
  try { installClineHooks(); } catch {}
469
+ try { installCopilotHooks(); } catch {}
470
+ try { installGeminiHooks(); } catch {}
471
+ try { installDroidHooks(); } catch {}
472
+ try { installFirebenderHooks(); } catch {}
473
+ try { installAmpPlugin(); } catch {}
474
+ try { installOpenCodePlugin(); } catch {}
475
+ try { installPiPlugin(); } catch {}
476
+ }
477
+
478
+ export type HookStatus = 'installed' | 'stale' | 'not-installed' | 'tool-absent';
479
+
480
+ export interface ToolHookStatus {
481
+ tool: string;
482
+ status: HookStatus;
483
+ }
484
+
485
+ export function checkHookStatus(): ToolHookStatus[] {
486
+ const results: ToolHookStatus[] = [];
487
+
488
+ // Claude Code
489
+ if (!fs.existsSync(CLAUDE_DIR)) {
490
+ results.push({ tool: 'claude-code', status: 'tool-absent' });
491
+ } else {
492
+ const hooks: any[] = readJson(CLAUDE_SETTINGS)?.hooks?.PreToolUse ?? [];
493
+ const cmd = (h: any) => h?.hooks?.[0]?.command ?? '';
494
+ if (hooks.some(h => isCurrent(cmd(h)))) results.push({ tool: 'claude-code', status: 'installed' });
495
+ else if (hooks.some(h => isOurs(cmd(h)))) results.push({ tool: 'claude-code', status: 'stale' });
496
+ else results.push({ tool: 'claude-code', status: 'not-installed' });
497
+ }
498
+
499
+ // Cursor
500
+ if (!fs.existsSync(path.join(os.homedir(), '.cursor'))) {
501
+ results.push({ tool: 'cursor', status: 'tool-absent' });
502
+ } else {
503
+ const hooks: any[] = readJson(CURSOR_HOOKS_PATH)?.hooks?.preToolUse ?? [];
504
+ if (hooks.some(h => isCurrent(h?.command ?? ''))) results.push({ tool: 'cursor', status: 'installed' });
505
+ else if (hooks.some(h => isOurs(h?.command ?? ''))) results.push({ tool: 'cursor', status: 'stale' });
506
+ else results.push({ tool: 'cursor', status: 'not-installed' });
507
+ }
508
+
509
+ // Windsurf
510
+ if (!fs.existsSync(path.join(os.homedir(), '.codeium'))) {
511
+ results.push({ tool: 'windsurf', status: 'tool-absent' });
512
+ } else {
513
+ let status: HookStatus = 'not-installed';
514
+ for (const hp of WINDSURF_HOOK_PATHS) {
515
+ const hooks: any[] = readJson(hp)?.hooks?.pre_write_code ?? [];
516
+ if (hooks.some(h => isCurrent(h?.command ?? ''))) { status = 'installed'; break; }
517
+ if (hooks.some(h => isOurs(h?.command ?? ''))) { status = 'stale'; }
518
+ }
519
+ results.push({ tool: 'windsurf', status });
520
+ }
521
+
522
+ // Codex
523
+ if (!fs.existsSync(path.join(os.homedir(), '.codex'))) {
524
+ results.push({ tool: 'codex', status: 'tool-absent' });
525
+ } else {
526
+ const hooks: any[] = readJson(CODEX_HOOKS_PATH)?.PreToolUse ?? [];
527
+ if (hooks.some(h => isCurrent(h?.command ?? ''))) results.push({ tool: 'codex', status: 'installed' });
528
+ else if (hooks.some(h => isOurs(h?.command ?? ''))) results.push({ tool: 'codex', status: 'stale' });
529
+ else results.push({ tool: 'codex', status: 'not-installed' });
530
+ }
531
+
532
+ // Cline
533
+ if (!fs.existsSync(CLINE_HOOKS_DIR)) {
534
+ results.push({ tool: 'cline', status: 'tool-absent' });
535
+ } else {
536
+ let existing = '';
537
+ try { existing = fs.readFileSync(path.join(CLINE_HOOKS_DIR, 'PreToolUse'), 'utf8'); } catch {}
538
+ if (existing.includes(HOOK_VERSION)) results.push({ tool: 'cline', status: 'installed' });
539
+ else if (existing.includes('.omnitype')) results.push({ tool: 'cline', status: 'stale' });
540
+ else results.push({ tool: 'cline', status: 'not-installed' });
541
+ }
542
+
543
+ // GitHub Copilot
544
+ const hasVscode = fs.existsSync(path.join(os.homedir(), '.vscode')) ||
545
+ fs.existsSync(path.join(os.homedir(), 'Library', 'Application Support', 'Code'));
546
+ const hasCopilotDir = fs.existsSync(path.join(os.homedir(), '.copilot'));
547
+ if (!hasVscode && !hasCopilotDir) {
548
+ results.push({ tool: 'copilot', status: 'tool-absent' });
549
+ } else {
550
+ const hooks: any[] = readJson(COPILOT_HOOKS_PATH)?.hooks?.PreToolUse ?? [];
551
+ if (hooks.some(h => isCurrent(h?.command ?? ''))) results.push({ tool: 'copilot', status: 'installed' });
552
+ else if (hooks.some(h => isOurs(h?.command ?? ''))) results.push({ tool: 'copilot', status: 'stale' });
553
+ else results.push({ tool: 'copilot', status: 'not-installed' });
554
+ }
555
+
556
+ // Gemini CLI
557
+ if (!fs.existsSync(path.join(os.homedir(), '.gemini'))) {
558
+ results.push({ tool: 'gemini-cli', status: 'tool-absent' });
559
+ } else {
560
+ const hooks: any[] = readJson(GEMINI_SETTINGS_PATH)?.BeforeTool ?? [];
561
+ if (hooks.some(h => isCurrent(h?.hooks?.[0]?.command ?? ''))) results.push({ tool: 'gemini-cli', status: 'installed' });
562
+ else if (hooks.some(h => isOurs(h?.hooks?.[0]?.command ?? ''))) results.push({ tool: 'gemini-cli', status: 'stale' });
563
+ else results.push({ tool: 'gemini-cli', status: 'not-installed' });
564
+ }
565
+
566
+ // Droid
567
+ if (!fs.existsSync(path.join(os.homedir(), '.factory'))) {
568
+ results.push({ tool: 'droid', status: 'tool-absent' });
569
+ } else {
570
+ const hooks: any[] = readJson(DROID_SETTINGS_PATH)?.PreToolUse ?? [];
571
+ if (hooks.some(h => isCurrent(h?.hooks?.[0]?.command ?? ''))) results.push({ tool: 'droid', status: 'installed' });
572
+ else if (hooks.some(h => isOurs(h?.hooks?.[0]?.command ?? ''))) results.push({ tool: 'droid', status: 'stale' });
573
+ else results.push({ tool: 'droid', status: 'not-installed' });
574
+ }
575
+
576
+ // Firebender
577
+ if (!fs.existsSync(path.join(os.homedir(), '.firebender'))) {
578
+ results.push({ tool: 'firebender', status: 'tool-absent' });
579
+ } else {
580
+ const hooks: any[] = readJson(FIREBENDER_HOOKS_PATH)?.preToolUse ?? [];
581
+ if (hooks.some(h => isCurrent(h?.command ?? ''))) results.push({ tool: 'firebender', status: 'installed' });
582
+ else if (hooks.some(h => isOurs(h?.command ?? ''))) results.push({ tool: 'firebender', status: 'stale' });
583
+ else results.push({ tool: 'firebender', status: 'not-installed' });
584
+ }
585
+
586
+ // Amp plugin
587
+ const ampDataDir = process.platform === 'win32'
588
+ ? path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'amp')
589
+ : path.join(process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share'), 'amp');
590
+ if (!fs.existsSync(ampDataDir) && !fs.existsSync(AMP_PLUGINS_DIR)) {
591
+ results.push({ tool: 'amp', status: 'tool-absent' });
592
+ } else {
593
+ let src = '';
594
+ try { src = fs.readFileSync(AMP_PLUGIN_PATH, 'utf8'); } catch {}
595
+ if (src.includes(HOOK_VERSION)) results.push({ tool: 'amp', status: 'installed' });
596
+ else if (src.includes('.omnitype')) results.push({ tool: 'amp', status: 'stale' });
597
+ else results.push({ tool: 'amp', status: 'not-installed' });
598
+ }
599
+
600
+ // OpenCode plugin
601
+ if (!fs.existsSync(path.join(os.homedir(), '.config', 'opencode'))) {
602
+ results.push({ tool: 'opencode', status: 'tool-absent' });
603
+ } else {
604
+ let src = '';
605
+ try { src = fs.readFileSync(OPENCODE_PLUGIN_PATH, 'utf8'); } catch {}
606
+ if (src.includes(HOOK_VERSION)) results.push({ tool: 'opencode', status: 'installed' });
607
+ else if (src.includes('.omnitype')) results.push({ tool: 'opencode', status: 'stale' });
608
+ else results.push({ tool: 'opencode', status: 'not-installed' });
609
+ }
610
+
611
+ // Pi plugin
612
+ if (!fs.existsSync(path.join(os.homedir(), '.pi'))) {
613
+ results.push({ tool: 'pi', status: 'tool-absent' });
614
+ } else {
615
+ let src = '';
616
+ try { src = fs.readFileSync(PI_PLUGIN_PATH, 'utf8'); } catch {}
617
+ if (src.includes(HOOK_VERSION)) results.push({ tool: 'pi', status: 'installed' });
618
+ else if (src.includes('.omnitype')) results.push({ tool: 'pi', status: 'stale' });
619
+ else results.push({ tool: 'pi', status: 'not-installed' });
620
+ }
621
+
622
+ return results;
192
623
  }