@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,11 +1,12 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Auto-installs OmniType model-detection hooks into AI tools that support
4
- * a preToolUse/postToolUse hook system: Claude Code, Cursor, Windsurf, Codex, Cline.
4
+ * a preToolUse/postToolUse hook system.
5
5
  *
6
- * Hooks write {model, tool, ts, file} to ~/.omnitype/active-model.json.
7
- * Including the target file path lets the detector use path-matching instead
8
- * of a short TTL eliminating the race between human and AI edits.
6
+ * Hooks write {model, tool, ts, file} to <project_root>/.omnitype/active-model.json.
7
+ * The project root is process.cwd() at hook invocation time this isolates every
8
+ * workspace so concurrent sessions in different windows never bleed into each other.
9
+ * Falls back to ~/.omnitype/ if cwd is outside any workspace.
9
10
  *
10
11
  * Each installer is idempotent — safe to call on every startup.
11
12
  * Fails silently so it never breaks the CLI for the user.
@@ -44,39 +45,68 @@ var __importStar = (this && this.__importStar) || (function () {
44
45
  };
45
46
  })();
46
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.HOOK_VERSION = exports.GLOBAL_OMNITYPE_DIR = void 0;
47
49
  exports.installAllToolHooks = installAllToolHooks;
50
+ exports.checkHookStatus = checkHookStatus;
48
51
  const fs = __importStar(require("fs"));
49
52
  const os = __importStar(require("os"));
50
53
  const path = __importStar(require("path"));
51
- const OMNITYPE_DIR = path.join(os.homedir(), '.omnitype');
52
- // Reads stdin JSON, extracts model + file path, writes sentinel.
53
- // `modelField` is the tool-specific key for model in the hook payload.
54
- function buildHookCommand(tool, modelField) {
55
- return (`node -e "let b='';process.stdin.on('data',c=>b+=c);` +
56
- `process.stdin.on('end',()=>{` +
57
- `try{const j=JSON.parse(b),` +
58
- `m=j['${modelField}']||j.model,` +
59
- `fs=require('fs'),p=require('path'),os=require('os'),` +
60
- `dir=p.join(os.homedir(),'.omnitype');` +
61
- `if(!m)return;` +
62
- `let file;try{file=j?.tool_input?.path||j?.tool_input?.file_path;}catch{}` +
63
- `fs.mkdirSync(dir,{recursive:true});` +
64
- `fs.writeFileSync(p.join(dir,'active-model.json'),` +
65
- `JSON.stringify(Object.assign({model:m,tool:'${tool}',ts:Date.now()},file&&{file})))}catch{}})"`);
66
- }
67
- // Claude doesn't expose model in hook stdin — reads from env/settings instead.
68
- // Still reads stdin to extract the target file path for path-gated matching.
69
- const CLAUDE_HOOK_CMD = `node -e "let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{` +
70
- `const fs=require('fs'),os=require('os'),p=require('path');` +
71
- `const dir=p.join(os.homedir(),'.omnitype');` +
72
- `let m=process.env.CLAUDE_MODEL||process.env.ANTHROPIC_MODEL;` +
73
- `if(!m){try{const s=JSON.parse(fs.readFileSync(p.join(os.homedir(),'.claude','settings.json'),'utf8'));` +
74
- `m=s.model||s.defaultModel;}catch{}}` +
75
- `if(!m)return;` +
76
- `let file;try{const j=JSON.parse(b);file=j?.tool_input?.path||j?.tool_input?.file_path;}catch{}` +
54
+ exports.GLOBAL_OMNITYPE_DIR = path.join(os.homedir(), '.omnitype');
55
+ // Version token embedded in every hook command.
56
+ // Bump whenever hook logic changes so stale installs are replaced automatically.
57
+ exports.HOOK_VERSION = 'omnitype-hook-v5';
58
+ // Shared sentinel-write snippet: resolves dir from process.cwd(), falls back to ~/.omnitype/.
59
+ const WRITE_SENTINEL = (tool) => `const fs=require('fs'),p=require('path'),os=require('os');` +
60
+ `const dir=p.join(process.cwd(),'.omnitype');` +
61
+ `const fbDir=p.join(os.homedir(),'.omnitype');` +
77
62
  `fs.mkdirSync(dir,{recursive:true});` +
78
- `fs.writeFileSync(p.join(dir,'active-model.json'),` +
79
- `JSON.stringify(Object.assign({model:m,tool:'claude-code',ts:Date.now()},file&&{file})))})"`;
63
+ `fs.mkdirSync(fbDir,{recursive:true});` +
64
+ `const payload=JSON.stringify(Object.assign({model:m,tool:'${tool}',ts:Date.now()},file&&{file}));` +
65
+ `fs.writeFileSync(p.join(dir,'active-model.json'),payload);` +
66
+ `fs.writeFileSync(p.join(fbDir,'active-model.json'),payload);`;
67
+ // Extracts model from a JSONL transcript by reading the tail (last 50KB).
68
+ // Looks for message.model on assistant turns, or session.model_change events.
69
+ // Same approach as git-ai's extract_model_from_jsonl_tail.
70
+ const EXTRACT_MODEL_FROM_JSONL = `function extractModelFromJsonl(tp){` +
71
+ `try{` +
72
+ `const _fs=require('fs');` +
73
+ `const stat=_fs.statSync(tp);` +
74
+ `const readSize=Math.min(51200,stat.size);` +
75
+ `const buf=Buffer.alloc(readSize);` +
76
+ `const fd=_fs.openSync(tp,'r');` +
77
+ `_fs.readSync(fd,buf,0,readSize,stat.size-readSize);` +
78
+ `_fs.closeSync(fd);` +
79
+ `const lines=buf.toString('utf8').split('\\n').reverse();` +
80
+ `for(const line of lines){` +
81
+ `if(!line.trim())continue;` +
82
+ `try{const o=JSON.parse(line);` +
83
+ `if(o.type==='session.model_change'&&o.data&&o.data.newModel)return o.data.newModel;` +
84
+ `const m=o?.message?.model||o?.model;` +
85
+ `if(m&&m!=='<synthetic>')return m;` +
86
+ `}catch{}` +
87
+ `}` +
88
+ `}catch{}` +
89
+ `return null;` +
90
+ `}`;
91
+ // Universal hook: works for every tool without per-tool customization.
92
+ // Resolution order:
93
+ // 1. Direct payload fields (covers Cursor, Windsurf, Codex, Cline, Gemini, etc.)
94
+ // 2. transcript_path JSONL tail (covers Claude Code, Gemini — same as git-ai)
95
+ // 3. Env vars as last resort
96
+ function buildHookCommand(tool) {
97
+ return (`node -e "/*${exports.HOOK_VERSION}*/` +
98
+ EXTRACT_MODEL_FROM_JSONL +
99
+ `let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{` +
100
+ `try{const j=JSON.parse(b);` +
101
+ `let file;try{file=j?.tool_input?.path||j?.tool_input?.file_path||j?.toolInput?.path;}catch{}` +
102
+ `let m=j.model||j.model_name||j.modelName||j.modelID||j?.data?.model||null;` +
103
+ `if(!m&&j.transcript_path)m=extractModelFromJsonl(j.transcript_path);` +
104
+ `if(!m)m=process.env.CLAUDE_MODEL||process.env.ANTHROPIC_MODEL||null;` +
105
+ `if(!m)return;` +
106
+ WRITE_SENTINEL(tool) +
107
+ `}catch{}})"`);
108
+ }
109
+ const CLAUDE_HOOK_CMD = buildHookCommand('claude-code');
80
110
  function readJson(filePath) {
81
111
  try {
82
112
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -91,6 +121,17 @@ function writeJsonAtomic(filePath, data) {
91
121
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
92
122
  fs.renameSync(tmp, filePath);
93
123
  }
124
+ /** True if a hook command string belongs to us (any version). */
125
+ function isOurs(cmd) { return cmd.includes('.omnitype'); }
126
+ /** True if a hook command belongs to us AND is current (no upgrade needed). */
127
+ function isCurrent(cmd) { return cmd.includes(exports.HOOK_VERSION); }
128
+ /** Remove stale omnitype entries from an array of hook objects (any shape). */
129
+ function purgeStale(arr, cmdExtract) {
130
+ return arr.filter(h => {
131
+ const cmd = cmdExtract(h) ?? '';
132
+ return !(isOurs(cmd) && !isCurrent(cmd));
133
+ });
134
+ }
94
135
  // ── Claude Code ───────────────────────────────────────────────────────────────
95
136
  const CLAUDE_DIR = path.join(os.homedir(), '.claude');
96
137
  const CLAUDE_SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
@@ -106,15 +147,16 @@ function installClaudeHooks() {
106
147
  s.hooks = {};
107
148
  if (!Array.isArray(s.hooks.PreToolUse))
108
149
  s.hooks.PreToolUse = [];
109
- const already = s.hooks.PreToolUse.some((h) => typeof h?.hooks?.[0]?.command === 'string' && h.hooks[0].command.includes('.omnitype'));
110
- if (!already) {
111
- s.hooks.PreToolUse.push(CLAUDE_HOOK_ENTRY);
112
- writeJsonAtomic(CLAUDE_SETTINGS, s);
113
- }
150
+ // Remove stale versions, then skip if current version already present.
151
+ s.hooks.PreToolUse = purgeStale(s.hooks.PreToolUse, h => h?.hooks?.[0]?.command);
152
+ if (s.hooks.PreToolUse.some((h) => isCurrent(h?.hooks?.[0]?.command ?? '')))
153
+ return;
154
+ s.hooks.PreToolUse.push(CLAUDE_HOOK_ENTRY);
155
+ writeJsonAtomic(CLAUDE_SETTINGS, s);
114
156
  }
115
157
  // ── Cursor ────────────────────────────────────────────────────────────────────
116
158
  const CURSOR_HOOKS_PATH = path.join(os.homedir(), '.cursor', 'hooks.json');
117
- const CURSOR_CMD = buildHookCommand('cursor', 'model');
159
+ const CURSOR_CMD = buildHookCommand('cursor');
118
160
  function installCursorHooks() {
119
161
  if (!fs.existsSync(path.join(os.homedir(), '.cursor')))
120
162
  return;
@@ -125,8 +167,8 @@ function installCursorHooks() {
125
167
  for (const event of ['preToolUse', 'postToolUse']) {
126
168
  if (!Array.isArray(settings.hooks[event]))
127
169
  settings.hooks[event] = [];
128
- const already = settings.hooks[event].some((h) => typeof h?.command === 'string' && h.command.includes('.omnitype'));
129
- if (!already) {
170
+ settings.hooks[event] = purgeStale(settings.hooks[event], h => h?.command);
171
+ if (!settings.hooks[event].some((h) => isCurrent(h?.command ?? ''))) {
130
172
  settings.hooks[event].push({ command: CURSOR_CMD });
131
173
  changed = true;
132
174
  }
@@ -142,7 +184,7 @@ const WINDSURF_HOOK_PATHS = [
142
184
  path.join(os.homedir(), '.codeium', 'hooks.json'),
143
185
  ];
144
186
  const WINDSURF_EVENTS = ['pre_write_code', 'post_write_code', 'pre_run_command', 'post_run_command'];
145
- const WINDSURF_CMD = buildHookCommand('windsurf', 'model_name');
187
+ const WINDSURF_CMD = buildHookCommand('windsurf');
146
188
  function installWindsurfHooks() {
147
189
  if (!fs.existsSync(path.join(os.homedir(), '.codeium')))
148
190
  return;
@@ -156,8 +198,8 @@ function installWindsurfHooks() {
156
198
  for (const event of WINDSURF_EVENTS) {
157
199
  if (!Array.isArray(settings.hooks[event]))
158
200
  settings.hooks[event] = [];
159
- const already = settings.hooks[event].some((h) => typeof h?.command === 'string' && h.command.includes('.omnitype'));
160
- if (!already) {
201
+ settings.hooks[event] = purgeStale(settings.hooks[event], h => h?.command);
202
+ if (!settings.hooks[event].some((h) => isCurrent(h?.command ?? ''))) {
161
203
  settings.hooks[event].push({ command: WINDSURF_CMD });
162
204
  changed = true;
163
205
  }
@@ -168,7 +210,7 @@ function installWindsurfHooks() {
168
210
  }
169
211
  // ── Codex ─────────────────────────────────────────────────────────────────────
170
212
  const CODEX_HOOKS_PATH = path.join(os.homedir(), '.codex', 'hooks.json');
171
- const CODEX_CMD = buildHookCommand('codex', 'model');
213
+ const CODEX_CMD = buildHookCommand('codex');
172
214
  function installCodexHooks() {
173
215
  if (!fs.existsSync(path.join(os.homedir(), '.codex')))
174
216
  return;
@@ -177,8 +219,8 @@ function installCodexHooks() {
177
219
  for (const event of ['PreToolUse', 'PostToolUse']) {
178
220
  if (!Array.isArray(settings[event]))
179
221
  settings[event] = [];
180
- const already = settings[event].some((h) => typeof h?.command === 'string' && h.command.includes('.omnitype'));
181
- if (!already) {
222
+ settings[event] = purgeStale(settings[event], h => h?.command);
223
+ if (!settings[event].some((h) => isCurrent(h?.command ?? ''))) {
182
224
  settings[event].push({ type: 'command', command: CODEX_CMD });
183
225
  changed = true;
184
226
  }
@@ -189,21 +231,51 @@ function installCodexHooks() {
189
231
  // ── Cline ─────────────────────────────────────────────────────────────────────
190
232
  // Cline looks for executable scripts in ~/Documents/Cline/Hooks/ named after
191
233
  // hook events. We drop a PreToolUse script that reads stdin JSON and writes
192
- // the sentinel only for file-modifying tools.
234
+ // the sentinel only for file-modifying tools, including the target file path.
193
235
  const CLINE_HOOKS_DIR = path.join(os.homedir(), 'Documents', 'Cline', 'Hooks');
194
236
  const CLINE_HOOK_SCRIPT = `#!/usr/bin/env node
237
+ // ${exports.HOOK_VERSION}
195
238
  const FILE_WRITE_TOOLS = new Set(['write_to_file','apply_diff','insert_content','search_and_replace']);
239
+ function extractModelFromJsonl(tp) {
240
+ try {
241
+ const fs = require('fs');
242
+ const stat = fs.statSync(tp);
243
+ const readSize = Math.min(51200, stat.size);
244
+ const buf = Buffer.alloc(readSize);
245
+ const fd = fs.openSync(tp, 'r');
246
+ fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
247
+ fs.closeSync(fd);
248
+ const lines = buf.toString('utf8').split('\\n').reverse();
249
+ for (const line of lines) {
250
+ if (!line.trim()) continue;
251
+ try {
252
+ const o = JSON.parse(line);
253
+ if (o.type === 'session.model_change' && o.data?.newModel) return o.data.newModel;
254
+ const m = o?.message?.model || o?.model;
255
+ if (m && m !== '<synthetic>') return m;
256
+ } catch {}
257
+ }
258
+ } catch {}
259
+ return null;
260
+ }
196
261
  let buf = '';
197
262
  process.stdin.on('data', c => buf += c);
198
263
  process.stdin.on('end', () => {
199
264
  try {
200
265
  const j = JSON.parse(buf);
201
266
  if (!FILE_WRITE_TOOLS.has(j.toolName)) return;
202
- const model = j.model || j.preToolUse?.model || '';
267
+ const m = j.model || j.model_name || j.modelName || j.modelID || j?.data?.model
268
+ || (j.transcript_path ? extractModelFromJsonl(j.transcript_path) : null)
269
+ || j.preToolUse?.model || '';
270
+ const file = j.toolInput?.path || j.toolInput?.file_path || j.tool_input?.path || undefined;
203
271
  const fs = require('fs'), p = require('path'), os = require('os');
204
- const dir = p.join(os.homedir(), '.omnitype');
272
+ const dir = p.join(process.cwd(), '.omnitype');
273
+ const fbDir = p.join(os.homedir(), '.omnitype');
205
274
  fs.mkdirSync(dir, { recursive: true });
206
- fs.writeFileSync(p.join(dir, 'active-model.json'), JSON.stringify({ model, tool: 'cline', ts: Date.now() }));
275
+ fs.mkdirSync(fbDir, { recursive: true });
276
+ const payload = JSON.stringify(Object.assign({ model: m, tool: 'cline', ts: Date.now() }, file && { file }));
277
+ fs.writeFileSync(p.join(dir, 'active-model.json'), payload);
278
+ fs.writeFileSync(p.join(fbDir, 'active-model.json'), payload);
207
279
  } catch {}
208
280
  });
209
281
  `;
@@ -211,12 +283,226 @@ function installClineHooks() {
211
283
  if (!fs.existsSync(CLINE_HOOKS_DIR))
212
284
  return;
213
285
  const scriptPath = path.join(CLINE_HOOKS_DIR, 'PreToolUse');
214
- const already = fs.existsSync(scriptPath) &&
215
- fs.readFileSync(scriptPath, 'utf8').includes('.omnitype');
216
- if (!already) {
217
- fs.writeFileSync(scriptPath, CLINE_HOOK_SCRIPT, 'utf8');
218
- fs.chmodSync(scriptPath, 0o755);
286
+ let existing = '';
287
+ try {
288
+ existing = fs.readFileSync(scriptPath, 'utf8');
289
+ }
290
+ catch { }
291
+ if (existing.includes(exports.HOOK_VERSION))
292
+ return; // already current
293
+ // Write (or overwrite stale version)
294
+ fs.writeFileSync(scriptPath, CLINE_HOOK_SCRIPT, 'utf8');
295
+ fs.chmodSync(scriptPath, 0o755);
296
+ }
297
+ // ── Gemini CLI ────────────────────────────────────────────────────────────────
298
+ // ~/.gemini/settings.json — BeforeTool / AfterTool with nested hooks array
299
+ const GEMINI_SETTINGS_PATH = path.join(os.homedir(), '.gemini', 'settings.json');
300
+ const GEMINI_CMD = buildHookCommand('gemini-cli');
301
+ const GEMINI_HOOK_ENTRY = { matcher: '*', hooks: [{ type: 'command', command: GEMINI_CMD }] };
302
+ function installGeminiHooks() {
303
+ if (!fs.existsSync(path.join(os.homedir(), '.gemini')))
304
+ return;
305
+ const s = readJson(GEMINI_SETTINGS_PATH);
306
+ let changed = false;
307
+ for (const ev of ['BeforeTool', 'AfterTool']) {
308
+ if (!Array.isArray(s[ev]))
309
+ s[ev] = [];
310
+ s[ev] = purgeStale(s[ev], h => h?.hooks?.[0]?.command);
311
+ if (!s[ev].some((h) => isCurrent(h?.hooks?.[0]?.command ?? ''))) {
312
+ s[ev].push(GEMINI_HOOK_ENTRY);
313
+ changed = true;
314
+ }
315
+ }
316
+ if (changed)
317
+ writeJsonAtomic(GEMINI_SETTINGS_PATH, s);
318
+ }
319
+ // ── Droid (Factory) ───────────────────────────────────────────────────────────
320
+ // ~/.factory/settings.json — PreToolUse with nested hooks array
321
+ const DROID_SETTINGS_PATH = path.join(os.homedir(), '.factory', 'settings.json');
322
+ const DROID_CMD = buildHookCommand('droid');
323
+ const DROID_HOOK_ENTRY = { matcher: '*', hooks: [{ type: 'command', command: DROID_CMD }] };
324
+ function installDroidHooks() {
325
+ if (!fs.existsSync(path.join(os.homedir(), '.factory')))
326
+ return;
327
+ const s = readJson(DROID_SETTINGS_PATH);
328
+ if (!Array.isArray(s.PreToolUse))
329
+ s.PreToolUse = [];
330
+ s.PreToolUse = purgeStale(s.PreToolUse, h => h?.hooks?.[0]?.command);
331
+ if (s.PreToolUse.some((h) => isCurrent(h?.hooks?.[0]?.command ?? '')))
332
+ return;
333
+ s.PreToolUse.push(DROID_HOOK_ENTRY);
334
+ writeJsonAtomic(DROID_SETTINGS_PATH, s);
335
+ }
336
+ // ── Firebender ────────────────────────────────────────────────────────────────
337
+ // ~/.firebender/hooks.json — preToolUse / postToolUse flat command arrays
338
+ const FIREBENDER_HOOKS_PATH = path.join(os.homedir(), '.firebender', 'hooks.json');
339
+ const FIREBENDER_CMD = buildHookCommand('firebender');
340
+ function installFirebenderHooks() {
341
+ if (!fs.existsSync(path.join(os.homedir(), '.firebender')))
342
+ return;
343
+ const s = readJson(FIREBENDER_HOOKS_PATH);
344
+ let changed = false;
345
+ for (const ev of ['preToolUse', 'postToolUse']) {
346
+ if (!Array.isArray(s[ev]))
347
+ s[ev] = [];
348
+ s[ev] = purgeStale(s[ev], h => h?.command);
349
+ if (!s[ev].some((h) => isCurrent(h?.command ?? ''))) {
350
+ s[ev].push({ command: FIREBENDER_CMD });
351
+ changed = true;
352
+ }
353
+ }
354
+ if (changed)
355
+ writeJsonAtomic(FIREBENDER_HOOKS_PATH, s);
356
+ }
357
+ // ── Amp ───────────────────────────────────────────────────────────────────────
358
+ // Amp uses a TypeScript plugin (not hooks.json). Drop a .ts file to
359
+ // ~/.config/amp/plugins/omnitype.ts
360
+ const AMP_PLUGINS_DIR = path.join(os.homedir(), '.config', 'amp', 'plugins');
361
+ const AMP_PLUGIN_PATH = path.join(AMP_PLUGINS_DIR, 'omnitype.ts');
362
+ const AMP_PLUGIN_SRC = `// @i-know-the-amp-plugin-api-is-wip-and-very-experimental-right-now
363
+ // ${exports.HOOK_VERSION}
364
+ import * as amp from 'amp';
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: 'amp', 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
+ amp.on('tool.call', (event: any) => {
377
+ try {
378
+ const model = event?.model ?? event?.session?.model;
379
+ if (!model) return;
380
+ writeSentinel(model, event?.input?.path ?? event?.input?.file_path);
381
+ } catch {}
382
+ });
383
+ `;
384
+ function installAmpPlugin() {
385
+ const ampDataDir = process.platform === 'win32'
386
+ ? path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'amp')
387
+ : path.join(process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share'), 'amp');
388
+ if (!fs.existsSync(ampDataDir) && !fs.existsSync(AMP_PLUGINS_DIR))
389
+ return;
390
+ let existing = '';
391
+ try {
392
+ existing = fs.readFileSync(AMP_PLUGIN_PATH, 'utf8');
393
+ }
394
+ catch { }
395
+ if (existing.includes(exports.HOOK_VERSION))
396
+ return;
397
+ fs.mkdirSync(AMP_PLUGINS_DIR, { recursive: true });
398
+ fs.writeFileSync(AMP_PLUGIN_PATH, AMP_PLUGIN_SRC, 'utf8');
399
+ }
400
+ // ── OpenCode ──────────────────────────────────────────────────────────────────
401
+ // TS plugin at ~/.config/opencode/plugins/omnitype.ts
402
+ const OPENCODE_PLUGINS_DIR = path.join(os.homedir(), '.config', 'opencode', 'plugins');
403
+ const OPENCODE_PLUGIN_PATH = path.join(OPENCODE_PLUGINS_DIR, 'omnitype.ts');
404
+ const OPENCODE_PLUGIN_SRC = `// ${exports.HOOK_VERSION}
405
+ import * as fs from 'fs';
406
+ import * as path from 'path';
407
+ import * as os from 'os';
408
+
409
+ function writeSentinel(model: string, file?: string) {
410
+ const payload = JSON.stringify(Object.assign({ model, tool: 'opencode', ts: Date.now() }, file && { file }));
411
+ for (const dir of [path.join(process.cwd(), '.omnitype'), path.join(os.homedir(), '.omnitype')]) {
412
+ try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'active-model.json'), payload); } catch {}
413
+ }
414
+ }
415
+
416
+ export function activate(ctx: any) {
417
+ ctx.on('tool.execute.before', (event: any) => {
418
+ try {
419
+ const model = event?.model ?? event?.session?.model;
420
+ if (!model) return;
421
+ writeSentinel(model, event?.input?.path ?? event?.input?.file_path);
422
+ } catch {}
423
+ });
424
+ }
425
+ `;
426
+ function installOpenCodePlugin() {
427
+ if (!fs.existsSync(path.join(os.homedir(), '.config', 'opencode')))
428
+ return;
429
+ let existing = '';
430
+ try {
431
+ existing = fs.readFileSync(OPENCODE_PLUGIN_PATH, 'utf8');
432
+ }
433
+ catch { }
434
+ if (existing.includes(exports.HOOK_VERSION))
435
+ return;
436
+ fs.mkdirSync(OPENCODE_PLUGINS_DIR, { recursive: true });
437
+ fs.writeFileSync(OPENCODE_PLUGIN_PATH, OPENCODE_PLUGIN_SRC, 'utf8');
438
+ }
439
+ // ── Pi ────────────────────────────────────────────────────────────────────────
440
+ // TS extension at ~/.pi/agent/extensions/omnitype.ts
441
+ const PI_EXTENSIONS_DIR = path.join(os.homedir(), '.pi', 'agent', 'extensions');
442
+ const PI_PLUGIN_PATH = path.join(PI_EXTENSIONS_DIR, 'omnitype.ts');
443
+ const PI_PLUGIN_SRC = `// ${exports.HOOK_VERSION}
444
+ import * as fs from 'fs';
445
+ import * as path from 'path';
446
+ import * as os from 'os';
447
+
448
+ function writeSentinel(model: string, file?: string) {
449
+ const payload = JSON.stringify(Object.assign({ model, tool: 'pi', ts: Date.now() }, file && { file }));
450
+ for (const dir of [path.join(process.cwd(), '.omnitype'), path.join(os.homedir(), '.omnitype')]) {
451
+ try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'active-model.json'), payload); } catch {}
452
+ }
453
+ }
454
+
455
+ export default {
456
+ name: 'omnitype',
457
+ onToolCall(event: any) {
458
+ try {
459
+ const model = event?.model ?? event?.agent?.model;
460
+ if (!model) return;
461
+ writeSentinel(model, event?.input?.path ?? event?.input?.file_path);
462
+ } catch {}
463
+ },
464
+ };
465
+ `;
466
+ function installPiPlugin() {
467
+ if (!fs.existsSync(path.join(os.homedir(), '.pi')))
468
+ return;
469
+ let existing = '';
470
+ try {
471
+ existing = fs.readFileSync(PI_PLUGIN_PATH, 'utf8');
472
+ }
473
+ catch { }
474
+ if (existing.includes(exports.HOOK_VERSION))
475
+ return;
476
+ fs.mkdirSync(PI_EXTENSIONS_DIR, { recursive: true });
477
+ fs.writeFileSync(PI_PLUGIN_PATH, PI_PLUGIN_SRC, 'utf8');
478
+ }
479
+ // ── GitHub Copilot ────────────────────────────────────────────────────────────
480
+ // Copilot supports a hooks file at ~/.copilot/hooks/omnitype.json with
481
+ // PreToolUse / PostToolUse arrays — same shape as Claude Code.
482
+ const COPILOT_HOOKS_PATH = path.join(os.homedir(), '.copilot', 'hooks', 'omnitype.json');
483
+ const COPILOT_CMD = buildHookCommand('copilot');
484
+ function installCopilotHooks() {
485
+ // Require either ~/.copilot or ~/.vscode or VS Code settings to exist
486
+ const hasVscode = fs.existsSync(path.join(os.homedir(), '.vscode')) ||
487
+ fs.existsSync(path.join(os.homedir(), 'Library', 'Application Support', 'Code'));
488
+ const hasCopilotDir = fs.existsSync(path.join(os.homedir(), '.copilot'));
489
+ if (!hasVscode && !hasCopilotDir)
490
+ return;
491
+ const s = readJson(COPILOT_HOOKS_PATH);
492
+ if (!s.hooks)
493
+ s.hooks = {};
494
+ let changed = false;
495
+ for (const ev of ['PreToolUse', 'PostToolUse']) {
496
+ if (!Array.isArray(s.hooks[ev]))
497
+ s.hooks[ev] = [];
498
+ s.hooks[ev] = purgeStale(s.hooks[ev], h => h?.command);
499
+ if (!s.hooks[ev].some((h) => isCurrent(h?.command ?? ''))) {
500
+ s.hooks[ev].push({ type: 'command', command: COPILOT_CMD });
501
+ changed = true;
502
+ }
219
503
  }
504
+ if (changed)
505
+ writeJsonAtomic(COPILOT_HOOKS_PATH, s);
220
506
  }
221
507
  // ── Public API ────────────────────────────────────────────────────────────────
222
508
  function installAllToolHooks() {
@@ -240,4 +526,220 @@ function installAllToolHooks() {
240
526
  installClineHooks();
241
527
  }
242
528
  catch { }
529
+ try {
530
+ installCopilotHooks();
531
+ }
532
+ catch { }
533
+ try {
534
+ installGeminiHooks();
535
+ }
536
+ catch { }
537
+ try {
538
+ installDroidHooks();
539
+ }
540
+ catch { }
541
+ try {
542
+ installFirebenderHooks();
543
+ }
544
+ catch { }
545
+ try {
546
+ installAmpPlugin();
547
+ }
548
+ catch { }
549
+ try {
550
+ installOpenCodePlugin();
551
+ }
552
+ catch { }
553
+ try {
554
+ installPiPlugin();
555
+ }
556
+ catch { }
557
+ }
558
+ function checkHookStatus() {
559
+ const results = [];
560
+ // Claude Code
561
+ if (!fs.existsSync(CLAUDE_DIR)) {
562
+ results.push({ tool: 'claude-code', status: 'tool-absent' });
563
+ }
564
+ else {
565
+ const hooks = readJson(CLAUDE_SETTINGS)?.hooks?.PreToolUse ?? [];
566
+ const cmd = (h) => h?.hooks?.[0]?.command ?? '';
567
+ if (hooks.some(h => isCurrent(cmd(h))))
568
+ results.push({ tool: 'claude-code', status: 'installed' });
569
+ else if (hooks.some(h => isOurs(cmd(h))))
570
+ results.push({ tool: 'claude-code', status: 'stale' });
571
+ else
572
+ results.push({ tool: 'claude-code', status: 'not-installed' });
573
+ }
574
+ // Cursor
575
+ if (!fs.existsSync(path.join(os.homedir(), '.cursor'))) {
576
+ results.push({ tool: 'cursor', status: 'tool-absent' });
577
+ }
578
+ else {
579
+ const hooks = readJson(CURSOR_HOOKS_PATH)?.hooks?.preToolUse ?? [];
580
+ if (hooks.some(h => isCurrent(h?.command ?? '')))
581
+ results.push({ tool: 'cursor', status: 'installed' });
582
+ else if (hooks.some(h => isOurs(h?.command ?? '')))
583
+ results.push({ tool: 'cursor', status: 'stale' });
584
+ else
585
+ results.push({ tool: 'cursor', status: 'not-installed' });
586
+ }
587
+ // Windsurf
588
+ if (!fs.existsSync(path.join(os.homedir(), '.codeium'))) {
589
+ results.push({ tool: 'windsurf', status: 'tool-absent' });
590
+ }
591
+ else {
592
+ let status = 'not-installed';
593
+ for (const hp of WINDSURF_HOOK_PATHS) {
594
+ const hooks = readJson(hp)?.hooks?.pre_write_code ?? [];
595
+ if (hooks.some(h => isCurrent(h?.command ?? ''))) {
596
+ status = 'installed';
597
+ break;
598
+ }
599
+ if (hooks.some(h => isOurs(h?.command ?? ''))) {
600
+ status = 'stale';
601
+ }
602
+ }
603
+ results.push({ tool: 'windsurf', status });
604
+ }
605
+ // Codex
606
+ if (!fs.existsSync(path.join(os.homedir(), '.codex'))) {
607
+ results.push({ tool: 'codex', status: 'tool-absent' });
608
+ }
609
+ else {
610
+ const hooks = readJson(CODEX_HOOKS_PATH)?.PreToolUse ?? [];
611
+ if (hooks.some(h => isCurrent(h?.command ?? '')))
612
+ results.push({ tool: 'codex', status: 'installed' });
613
+ else if (hooks.some(h => isOurs(h?.command ?? '')))
614
+ results.push({ tool: 'codex', status: 'stale' });
615
+ else
616
+ results.push({ tool: 'codex', status: 'not-installed' });
617
+ }
618
+ // Cline
619
+ if (!fs.existsSync(CLINE_HOOKS_DIR)) {
620
+ results.push({ tool: 'cline', status: 'tool-absent' });
621
+ }
622
+ else {
623
+ let existing = '';
624
+ try {
625
+ existing = fs.readFileSync(path.join(CLINE_HOOKS_DIR, 'PreToolUse'), 'utf8');
626
+ }
627
+ catch { }
628
+ if (existing.includes(exports.HOOK_VERSION))
629
+ results.push({ tool: 'cline', status: 'installed' });
630
+ else if (existing.includes('.omnitype'))
631
+ results.push({ tool: 'cline', status: 'stale' });
632
+ else
633
+ results.push({ tool: 'cline', status: 'not-installed' });
634
+ }
635
+ // GitHub Copilot
636
+ const hasVscode = fs.existsSync(path.join(os.homedir(), '.vscode')) ||
637
+ fs.existsSync(path.join(os.homedir(), 'Library', 'Application Support', 'Code'));
638
+ const hasCopilotDir = fs.existsSync(path.join(os.homedir(), '.copilot'));
639
+ if (!hasVscode && !hasCopilotDir) {
640
+ results.push({ tool: 'copilot', status: 'tool-absent' });
641
+ }
642
+ else {
643
+ const hooks = readJson(COPILOT_HOOKS_PATH)?.hooks?.PreToolUse ?? [];
644
+ if (hooks.some(h => isCurrent(h?.command ?? '')))
645
+ results.push({ tool: 'copilot', status: 'installed' });
646
+ else if (hooks.some(h => isOurs(h?.command ?? '')))
647
+ results.push({ tool: 'copilot', status: 'stale' });
648
+ else
649
+ results.push({ tool: 'copilot', status: 'not-installed' });
650
+ }
651
+ // Gemini CLI
652
+ if (!fs.existsSync(path.join(os.homedir(), '.gemini'))) {
653
+ results.push({ tool: 'gemini-cli', status: 'tool-absent' });
654
+ }
655
+ else {
656
+ const hooks = readJson(GEMINI_SETTINGS_PATH)?.BeforeTool ?? [];
657
+ if (hooks.some(h => isCurrent(h?.hooks?.[0]?.command ?? '')))
658
+ results.push({ tool: 'gemini-cli', status: 'installed' });
659
+ else if (hooks.some(h => isOurs(h?.hooks?.[0]?.command ?? '')))
660
+ results.push({ tool: 'gemini-cli', status: 'stale' });
661
+ else
662
+ results.push({ tool: 'gemini-cli', status: 'not-installed' });
663
+ }
664
+ // Droid
665
+ if (!fs.existsSync(path.join(os.homedir(), '.factory'))) {
666
+ results.push({ tool: 'droid', status: 'tool-absent' });
667
+ }
668
+ else {
669
+ const hooks = readJson(DROID_SETTINGS_PATH)?.PreToolUse ?? [];
670
+ if (hooks.some(h => isCurrent(h?.hooks?.[0]?.command ?? '')))
671
+ results.push({ tool: 'droid', status: 'installed' });
672
+ else if (hooks.some(h => isOurs(h?.hooks?.[0]?.command ?? '')))
673
+ results.push({ tool: 'droid', status: 'stale' });
674
+ else
675
+ results.push({ tool: 'droid', status: 'not-installed' });
676
+ }
677
+ // Firebender
678
+ if (!fs.existsSync(path.join(os.homedir(), '.firebender'))) {
679
+ results.push({ tool: 'firebender', status: 'tool-absent' });
680
+ }
681
+ else {
682
+ const hooks = readJson(FIREBENDER_HOOKS_PATH)?.preToolUse ?? [];
683
+ if (hooks.some(h => isCurrent(h?.command ?? '')))
684
+ results.push({ tool: 'firebender', status: 'installed' });
685
+ else if (hooks.some(h => isOurs(h?.command ?? '')))
686
+ results.push({ tool: 'firebender', status: 'stale' });
687
+ else
688
+ results.push({ tool: 'firebender', status: 'not-installed' });
689
+ }
690
+ // Amp plugin
691
+ const ampDataDir = process.platform === 'win32'
692
+ ? path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'amp')
693
+ : path.join(process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share'), 'amp');
694
+ if (!fs.existsSync(ampDataDir) && !fs.existsSync(AMP_PLUGINS_DIR)) {
695
+ results.push({ tool: 'amp', status: 'tool-absent' });
696
+ }
697
+ else {
698
+ let src = '';
699
+ try {
700
+ src = fs.readFileSync(AMP_PLUGIN_PATH, 'utf8');
701
+ }
702
+ catch { }
703
+ if (src.includes(exports.HOOK_VERSION))
704
+ results.push({ tool: 'amp', status: 'installed' });
705
+ else if (src.includes('.omnitype'))
706
+ results.push({ tool: 'amp', status: 'stale' });
707
+ else
708
+ results.push({ tool: 'amp', status: 'not-installed' });
709
+ }
710
+ // OpenCode plugin
711
+ if (!fs.existsSync(path.join(os.homedir(), '.config', 'opencode'))) {
712
+ results.push({ tool: 'opencode', status: 'tool-absent' });
713
+ }
714
+ else {
715
+ let src = '';
716
+ try {
717
+ src = fs.readFileSync(OPENCODE_PLUGIN_PATH, 'utf8');
718
+ }
719
+ catch { }
720
+ if (src.includes(exports.HOOK_VERSION))
721
+ results.push({ tool: 'opencode', status: 'installed' });
722
+ else if (src.includes('.omnitype'))
723
+ results.push({ tool: 'opencode', status: 'stale' });
724
+ else
725
+ results.push({ tool: 'opencode', status: 'not-installed' });
726
+ }
727
+ // Pi plugin
728
+ if (!fs.existsSync(path.join(os.homedir(), '.pi'))) {
729
+ results.push({ tool: 'pi', status: 'tool-absent' });
730
+ }
731
+ else {
732
+ let src = '';
733
+ try {
734
+ src = fs.readFileSync(PI_PLUGIN_PATH, 'utf8');
735
+ }
736
+ catch { }
737
+ if (src.includes(exports.HOOK_VERSION))
738
+ results.push({ tool: 'pi', status: 'installed' });
739
+ else if (src.includes('.omnitype'))
740
+ results.push({ tool: 'pi', status: 'stale' });
741
+ else
742
+ results.push({ tool: 'pi', status: 'not-installed' });
743
+ }
744
+ return results;
243
745
  }