@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.
@@ -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,50 @@ 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
+ 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-v4';
58
+ // Shared sentinel-write snippet: resolves dir from process.cwd(), falls back to ~/.omnitype/.
59
+ // `modelExpr` is a JS expression that evaluates to the model string (already extracted).
60
+ // `tool` is the literal tool name string.
61
+ const WRITE_SENTINEL = (tool) => `const fs=require('fs'),p=require('path'),os=require('os');` +
62
+ `const dir=p.join(process.cwd(),'.omnitype');` +
63
+ `const fbDir=p.join(os.homedir(),'.omnitype');` +
64
+ `fs.mkdirSync(dir,{recursive:true});` +
65
+ `fs.mkdirSync(fbDir,{recursive:true});` +
66
+ `const payload=JSON.stringify(Object.assign({model:m,tool:'${tool}',ts:Date.now()},file&&{file}));` +
67
+ `fs.writeFileSync(p.join(dir,'active-model.json'),payload);` +
68
+ `fs.writeFileSync(p.join(fbDir,'active-model.json'),payload);`;
69
+ // Generic hook: reads model from stdin JSON, writes sentinel to project root + global fallback.
54
70
  function buildHookCommand(tool, modelField) {
55
- return (`node -e "let b='';process.stdin.on('data',c=>b+=c);` +
71
+ return (`node -e "/*${exports.HOOK_VERSION}*/let b='';process.stdin.on('data',c=>b+=c);` +
56
72
  `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');` +
73
+ `try{const j=JSON.parse(b);` +
74
+ `let m=j['${modelField}']||j.model;` +
61
75
  `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'));` +
76
+ `let file;try{file=j?.tool_input?.path||j?.tool_input?.file_path||j?.toolInput?.path;}catch{}` +
77
+ WRITE_SENTINEL(tool) +
78
+ `}catch{}})"`);
79
+ }
80
+ // Claude Code hook: reads model from stdin first, then env, then settings.json.
81
+ const CLAUDE_HOOK_CMD = `node -e "/*${exports.HOOK_VERSION}*/let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{` +
82
+ `try{` +
83
+ `let m,file;` +
84
+ `try{const j=JSON.parse(b);m=j.model;file=j?.tool_input?.path||j?.tool_input?.file_path||j?.toolInput?.path;}catch{}` +
85
+ `if(!m)m=process.env.CLAUDE_MODEL||process.env.ANTHROPIC_MODEL;` +
86
+ `if(!m){try{const _fs=require('fs'),_p=require('path'),_os=require('os');` +
87
+ `const s=JSON.parse(_fs.readFileSync(_p.join(_os.homedir(),'.claude','settings.json'),'utf8'));` +
74
88
  `m=s.model||s.defaultModel;}catch{}}` +
75
89
  `if(!m)return;` +
76
- `let file;try{const j=JSON.parse(b);file=j?.tool_input?.path||j?.tool_input?.file_path;}catch{}` +
77
- `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})))})"`;
90
+ WRITE_SENTINEL('claude-code') +
91
+ `}catch{}})"`;
80
92
  function readJson(filePath) {
81
93
  try {
82
94
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -91,6 +103,17 @@ function writeJsonAtomic(filePath, data) {
91
103
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
92
104
  fs.renameSync(tmp, filePath);
93
105
  }
106
+ /** True if a hook command string belongs to us (any version). */
107
+ function isOurs(cmd) { return cmd.includes('.omnitype'); }
108
+ /** True if a hook command belongs to us AND is current (no upgrade needed). */
109
+ function isCurrent(cmd) { return cmd.includes(exports.HOOK_VERSION); }
110
+ /** Remove stale omnitype entries from an array of hook objects (any shape). */
111
+ function purgeStale(arr, cmdExtract) {
112
+ return arr.filter(h => {
113
+ const cmd = cmdExtract(h) ?? '';
114
+ return !(isOurs(cmd) && !isCurrent(cmd));
115
+ });
116
+ }
94
117
  // ── Claude Code ───────────────────────────────────────────────────────────────
95
118
  const CLAUDE_DIR = path.join(os.homedir(), '.claude');
96
119
  const CLAUDE_SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
@@ -106,11 +129,12 @@ function installClaudeHooks() {
106
129
  s.hooks = {};
107
130
  if (!Array.isArray(s.hooks.PreToolUse))
108
131
  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
- }
132
+ // Remove stale versions, then skip if current version already present.
133
+ s.hooks.PreToolUse = purgeStale(s.hooks.PreToolUse, h => h?.hooks?.[0]?.command);
134
+ if (s.hooks.PreToolUse.some((h) => isCurrent(h?.hooks?.[0]?.command ?? '')))
135
+ return;
136
+ s.hooks.PreToolUse.push(CLAUDE_HOOK_ENTRY);
137
+ writeJsonAtomic(CLAUDE_SETTINGS, s);
114
138
  }
115
139
  // ── Cursor ────────────────────────────────────────────────────────────────────
116
140
  const CURSOR_HOOKS_PATH = path.join(os.homedir(), '.cursor', 'hooks.json');
@@ -125,8 +149,8 @@ function installCursorHooks() {
125
149
  for (const event of ['preToolUse', 'postToolUse']) {
126
150
  if (!Array.isArray(settings.hooks[event]))
127
151
  settings.hooks[event] = [];
128
- const already = settings.hooks[event].some((h) => typeof h?.command === 'string' && h.command.includes('.omnitype'));
129
- if (!already) {
152
+ settings.hooks[event] = purgeStale(settings.hooks[event], h => h?.command);
153
+ if (!settings.hooks[event].some((h) => isCurrent(h?.command ?? ''))) {
130
154
  settings.hooks[event].push({ command: CURSOR_CMD });
131
155
  changed = true;
132
156
  }
@@ -156,8 +180,8 @@ function installWindsurfHooks() {
156
180
  for (const event of WINDSURF_EVENTS) {
157
181
  if (!Array.isArray(settings.hooks[event]))
158
182
  settings.hooks[event] = [];
159
- const already = settings.hooks[event].some((h) => typeof h?.command === 'string' && h.command.includes('.omnitype'));
160
- if (!already) {
183
+ settings.hooks[event] = purgeStale(settings.hooks[event], h => h?.command);
184
+ if (!settings.hooks[event].some((h) => isCurrent(h?.command ?? ''))) {
161
185
  settings.hooks[event].push({ command: WINDSURF_CMD });
162
186
  changed = true;
163
187
  }
@@ -177,8 +201,8 @@ function installCodexHooks() {
177
201
  for (const event of ['PreToolUse', 'PostToolUse']) {
178
202
  if (!Array.isArray(settings[event]))
179
203
  settings[event] = [];
180
- const already = settings[event].some((h) => typeof h?.command === 'string' && h.command.includes('.omnitype'));
181
- if (!already) {
204
+ settings[event] = purgeStale(settings[event], h => h?.command);
205
+ if (!settings[event].some((h) => isCurrent(h?.command ?? ''))) {
182
206
  settings[event].push({ type: 'command', command: CODEX_CMD });
183
207
  changed = true;
184
208
  }
@@ -189,9 +213,10 @@ function installCodexHooks() {
189
213
  // ── Cline ─────────────────────────────────────────────────────────────────────
190
214
  // Cline looks for executable scripts in ~/Documents/Cline/Hooks/ named after
191
215
  // hook events. We drop a PreToolUse script that reads stdin JSON and writes
192
- // the sentinel only for file-modifying tools.
216
+ // the sentinel only for file-modifying tools, including the target file path.
193
217
  const CLINE_HOOKS_DIR = path.join(os.homedir(), 'Documents', 'Cline', 'Hooks');
194
218
  const CLINE_HOOK_SCRIPT = `#!/usr/bin/env node
219
+ // ${exports.HOOK_VERSION}
195
220
  const FILE_WRITE_TOOLS = new Set(['write_to_file','apply_diff','insert_content','search_and_replace']);
196
221
  let buf = '';
197
222
  process.stdin.on('data', c => buf += c);
@@ -199,11 +224,16 @@ process.stdin.on('end', () => {
199
224
  try {
200
225
  const j = JSON.parse(buf);
201
226
  if (!FILE_WRITE_TOOLS.has(j.toolName)) return;
202
- const model = j.model || j.preToolUse?.model || '';
227
+ const m = j.model || j.preToolUse?.model || '';
228
+ const file = j.toolInput?.path || j.toolInput?.file_path || j.tool_input?.path || undefined;
203
229
  const fs = require('fs'), p = require('path'), os = require('os');
204
- const dir = p.join(os.homedir(), '.omnitype');
230
+ const dir = p.join(process.cwd(), '.omnitype');
231
+ const fbDir = p.join(os.homedir(), '.omnitype');
205
232
  fs.mkdirSync(dir, { recursive: true });
206
- fs.writeFileSync(p.join(dir, 'active-model.json'), JSON.stringify({ model, tool: 'cline', ts: Date.now() }));
233
+ fs.mkdirSync(fbDir, { recursive: true });
234
+ const payload = JSON.stringify(Object.assign({ model: m, tool: 'cline', ts: Date.now() }, file && { file }));
235
+ fs.writeFileSync(p.join(dir, 'active-model.json'), payload);
236
+ fs.writeFileSync(p.join(fbDir, 'active-model.json'), payload);
207
237
  } catch {}
208
238
  });
209
239
  `;
@@ -211,12 +241,226 @@ function installClineHooks() {
211
241
  if (!fs.existsSync(CLINE_HOOKS_DIR))
212
242
  return;
213
243
  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);
244
+ let existing = '';
245
+ try {
246
+ existing = fs.readFileSync(scriptPath, 'utf8');
247
+ }
248
+ catch { }
249
+ if (existing.includes(exports.HOOK_VERSION))
250
+ return; // already current
251
+ // Write (or overwrite stale version)
252
+ fs.writeFileSync(scriptPath, CLINE_HOOK_SCRIPT, 'utf8');
253
+ fs.chmodSync(scriptPath, 0o755);
254
+ }
255
+ // ── Gemini CLI ────────────────────────────────────────────────────────────────
256
+ // ~/.gemini/settings.json — BeforeTool / AfterTool with nested hooks array
257
+ const GEMINI_SETTINGS_PATH = path.join(os.homedir(), '.gemini', 'settings.json');
258
+ const GEMINI_CMD = buildHookCommand('gemini-cli', 'model');
259
+ const GEMINI_HOOK_ENTRY = { matcher: '*', hooks: [{ type: 'command', command: GEMINI_CMD }] };
260
+ function installGeminiHooks() {
261
+ if (!fs.existsSync(path.join(os.homedir(), '.gemini')))
262
+ return;
263
+ const s = readJson(GEMINI_SETTINGS_PATH);
264
+ let changed = false;
265
+ for (const ev of ['BeforeTool', 'AfterTool']) {
266
+ if (!Array.isArray(s[ev]))
267
+ s[ev] = [];
268
+ s[ev] = purgeStale(s[ev], h => h?.hooks?.[0]?.command);
269
+ if (!s[ev].some((h) => isCurrent(h?.hooks?.[0]?.command ?? ''))) {
270
+ s[ev].push(GEMINI_HOOK_ENTRY);
271
+ changed = true;
272
+ }
273
+ }
274
+ if (changed)
275
+ writeJsonAtomic(GEMINI_SETTINGS_PATH, s);
276
+ }
277
+ // ── Droid (Factory) ───────────────────────────────────────────────────────────
278
+ // ~/.factory/settings.json — PreToolUse with nested hooks array
279
+ const DROID_SETTINGS_PATH = path.join(os.homedir(), '.factory', 'settings.json');
280
+ const DROID_CMD = buildHookCommand('droid', 'model');
281
+ const DROID_HOOK_ENTRY = { matcher: '*', hooks: [{ type: 'command', command: DROID_CMD }] };
282
+ function installDroidHooks() {
283
+ if (!fs.existsSync(path.join(os.homedir(), '.factory')))
284
+ return;
285
+ const s = readJson(DROID_SETTINGS_PATH);
286
+ if (!Array.isArray(s.PreToolUse))
287
+ s.PreToolUse = [];
288
+ s.PreToolUse = purgeStale(s.PreToolUse, h => h?.hooks?.[0]?.command);
289
+ if (s.PreToolUse.some((h) => isCurrent(h?.hooks?.[0]?.command ?? '')))
290
+ return;
291
+ s.PreToolUse.push(DROID_HOOK_ENTRY);
292
+ writeJsonAtomic(DROID_SETTINGS_PATH, s);
293
+ }
294
+ // ── Firebender ────────────────────────────────────────────────────────────────
295
+ // ~/.firebender/hooks.json — preToolUse / postToolUse flat command arrays
296
+ const FIREBENDER_HOOKS_PATH = path.join(os.homedir(), '.firebender', 'hooks.json');
297
+ const FIREBENDER_CMD = buildHookCommand('firebender', 'model');
298
+ function installFirebenderHooks() {
299
+ if (!fs.existsSync(path.join(os.homedir(), '.firebender')))
300
+ return;
301
+ const s = readJson(FIREBENDER_HOOKS_PATH);
302
+ let changed = false;
303
+ for (const ev of ['preToolUse', 'postToolUse']) {
304
+ if (!Array.isArray(s[ev]))
305
+ s[ev] = [];
306
+ s[ev] = purgeStale(s[ev], h => h?.command);
307
+ if (!s[ev].some((h) => isCurrent(h?.command ?? ''))) {
308
+ s[ev].push({ command: FIREBENDER_CMD });
309
+ changed = true;
310
+ }
311
+ }
312
+ if (changed)
313
+ writeJsonAtomic(FIREBENDER_HOOKS_PATH, s);
314
+ }
315
+ // ── Amp ───────────────────────────────────────────────────────────────────────
316
+ // Amp uses a TypeScript plugin (not hooks.json). Drop a .ts file to
317
+ // ~/.config/amp/plugins/omnitype.ts
318
+ const AMP_PLUGINS_DIR = path.join(os.homedir(), '.config', 'amp', 'plugins');
319
+ const AMP_PLUGIN_PATH = path.join(AMP_PLUGINS_DIR, 'omnitype.ts');
320
+ const AMP_PLUGIN_SRC = `// @i-know-the-amp-plugin-api-is-wip-and-very-experimental-right-now
321
+ // ${exports.HOOK_VERSION}
322
+ import * as amp from 'amp';
323
+ import * as fs from 'fs';
324
+ import * as path from 'path';
325
+ import * as os from 'os';
326
+
327
+ function writeSentinel(model: string, file?: string) {
328
+ const payload = JSON.stringify(Object.assign({ model, tool: 'amp', ts: Date.now() }, file && { file }));
329
+ for (const dir of [path.join(process.cwd(), '.omnitype'), path.join(os.homedir(), '.omnitype')]) {
330
+ try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'active-model.json'), payload); } catch {}
331
+ }
332
+ }
333
+
334
+ amp.on('tool.call', (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
+ function installAmpPlugin() {
343
+ const ampDataDir = process.platform === 'win32'
344
+ ? path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'amp')
345
+ : path.join(process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share'), 'amp');
346
+ if (!fs.existsSync(ampDataDir) && !fs.existsSync(AMP_PLUGINS_DIR))
347
+ return;
348
+ let existing = '';
349
+ try {
350
+ existing = fs.readFileSync(AMP_PLUGIN_PATH, 'utf8');
351
+ }
352
+ catch { }
353
+ if (existing.includes(exports.HOOK_VERSION))
354
+ return;
355
+ fs.mkdirSync(AMP_PLUGINS_DIR, { recursive: true });
356
+ fs.writeFileSync(AMP_PLUGIN_PATH, AMP_PLUGIN_SRC, 'utf8');
357
+ }
358
+ // ── OpenCode ──────────────────────────────────────────────────────────────────
359
+ // TS plugin at ~/.config/opencode/plugins/omnitype.ts
360
+ const OPENCODE_PLUGINS_DIR = path.join(os.homedir(), '.config', 'opencode', 'plugins');
361
+ const OPENCODE_PLUGIN_PATH = path.join(OPENCODE_PLUGINS_DIR, 'omnitype.ts');
362
+ const OPENCODE_PLUGIN_SRC = `// ${exports.HOOK_VERSION}
363
+ import * as fs from 'fs';
364
+ import * as path from 'path';
365
+ import * as os from 'os';
366
+
367
+ function writeSentinel(model: string, file?: string) {
368
+ const payload = JSON.stringify(Object.assign({ model, tool: 'opencode', ts: Date.now() }, file && { file }));
369
+ for (const dir of [path.join(process.cwd(), '.omnitype'), path.join(os.homedir(), '.omnitype')]) {
370
+ try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'active-model.json'), payload); } catch {}
371
+ }
372
+ }
373
+
374
+ export function activate(ctx: any) {
375
+ ctx.on('tool.execute.before', (event: any) => {
376
+ try {
377
+ const model = event?.model ?? event?.session?.model;
378
+ if (!model) return;
379
+ writeSentinel(model, event?.input?.path ?? event?.input?.file_path);
380
+ } catch {}
381
+ });
382
+ }
383
+ `;
384
+ function installOpenCodePlugin() {
385
+ if (!fs.existsSync(path.join(os.homedir(), '.config', 'opencode')))
386
+ return;
387
+ let existing = '';
388
+ try {
389
+ existing = fs.readFileSync(OPENCODE_PLUGIN_PATH, 'utf8');
390
+ }
391
+ catch { }
392
+ if (existing.includes(exports.HOOK_VERSION))
393
+ return;
394
+ fs.mkdirSync(OPENCODE_PLUGINS_DIR, { recursive: true });
395
+ fs.writeFileSync(OPENCODE_PLUGIN_PATH, OPENCODE_PLUGIN_SRC, 'utf8');
396
+ }
397
+ // ── Pi ────────────────────────────────────────────────────────────────────────
398
+ // TS extension at ~/.pi/agent/extensions/omnitype.ts
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 = `// ${exports.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
+ function installPiPlugin() {
425
+ if (!fs.existsSync(path.join(os.homedir(), '.pi')))
426
+ return;
427
+ let existing = '';
428
+ try {
429
+ existing = fs.readFileSync(PI_PLUGIN_PATH, 'utf8');
430
+ }
431
+ catch { }
432
+ if (existing.includes(exports.HOOK_VERSION))
433
+ return;
434
+ fs.mkdirSync(PI_EXTENSIONS_DIR, { recursive: true });
435
+ fs.writeFileSync(PI_PLUGIN_PATH, PI_PLUGIN_SRC, 'utf8');
436
+ }
437
+ // ── GitHub Copilot ────────────────────────────────────────────────────────────
438
+ // Copilot supports a hooks file at ~/.copilot/hooks/omnitype.json with
439
+ // PreToolUse / PostToolUse arrays — same shape as Claude Code.
440
+ const COPILOT_HOOKS_PATH = path.join(os.homedir(), '.copilot', 'hooks', 'omnitype.json');
441
+ const COPILOT_CMD = buildHookCommand('copilot', 'model');
442
+ function installCopilotHooks() {
443
+ // Require either ~/.copilot or ~/.vscode or VS Code settings to exist
444
+ const hasVscode = fs.existsSync(path.join(os.homedir(), '.vscode')) ||
445
+ fs.existsSync(path.join(os.homedir(), 'Library', 'Application Support', 'Code'));
446
+ const hasCopilotDir = fs.existsSync(path.join(os.homedir(), '.copilot'));
447
+ if (!hasVscode && !hasCopilotDir)
448
+ return;
449
+ const s = readJson(COPILOT_HOOKS_PATH);
450
+ if (!s.hooks)
451
+ s.hooks = {};
452
+ let changed = false;
453
+ for (const ev of ['PreToolUse', 'PostToolUse']) {
454
+ if (!Array.isArray(s.hooks[ev]))
455
+ s.hooks[ev] = [];
456
+ s.hooks[ev] = purgeStale(s.hooks[ev], h => h?.command);
457
+ if (!s.hooks[ev].some((h) => isCurrent(h?.command ?? ''))) {
458
+ s.hooks[ev].push({ type: 'command', command: COPILOT_CMD });
459
+ changed = true;
460
+ }
219
461
  }
462
+ if (changed)
463
+ writeJsonAtomic(COPILOT_HOOKS_PATH, s);
220
464
  }
221
465
  // ── Public API ────────────────────────────────────────────────────────────────
222
466
  function installAllToolHooks() {
@@ -240,4 +484,220 @@ function installAllToolHooks() {
240
484
  installClineHooks();
241
485
  }
242
486
  catch { }
487
+ try {
488
+ installCopilotHooks();
489
+ }
490
+ catch { }
491
+ try {
492
+ installGeminiHooks();
493
+ }
494
+ catch { }
495
+ try {
496
+ installDroidHooks();
497
+ }
498
+ catch { }
499
+ try {
500
+ installFirebenderHooks();
501
+ }
502
+ catch { }
503
+ try {
504
+ installAmpPlugin();
505
+ }
506
+ catch { }
507
+ try {
508
+ installOpenCodePlugin();
509
+ }
510
+ catch { }
511
+ try {
512
+ installPiPlugin();
513
+ }
514
+ catch { }
515
+ }
516
+ function checkHookStatus() {
517
+ const results = [];
518
+ // Claude Code
519
+ if (!fs.existsSync(CLAUDE_DIR)) {
520
+ results.push({ tool: 'claude-code', status: 'tool-absent' });
521
+ }
522
+ else {
523
+ const hooks = readJson(CLAUDE_SETTINGS)?.hooks?.PreToolUse ?? [];
524
+ const cmd = (h) => h?.hooks?.[0]?.command ?? '';
525
+ if (hooks.some(h => isCurrent(cmd(h))))
526
+ results.push({ tool: 'claude-code', status: 'installed' });
527
+ else if (hooks.some(h => isOurs(cmd(h))))
528
+ results.push({ tool: 'claude-code', status: 'stale' });
529
+ else
530
+ results.push({ tool: 'claude-code', status: 'not-installed' });
531
+ }
532
+ // Cursor
533
+ if (!fs.existsSync(path.join(os.homedir(), '.cursor'))) {
534
+ results.push({ tool: 'cursor', status: 'tool-absent' });
535
+ }
536
+ else {
537
+ const hooks = readJson(CURSOR_HOOKS_PATH)?.hooks?.preToolUse ?? [];
538
+ if (hooks.some(h => isCurrent(h?.command ?? '')))
539
+ results.push({ tool: 'cursor', status: 'installed' });
540
+ else if (hooks.some(h => isOurs(h?.command ?? '')))
541
+ results.push({ tool: 'cursor', status: 'stale' });
542
+ else
543
+ results.push({ tool: 'cursor', status: 'not-installed' });
544
+ }
545
+ // Windsurf
546
+ if (!fs.existsSync(path.join(os.homedir(), '.codeium'))) {
547
+ results.push({ tool: 'windsurf', status: 'tool-absent' });
548
+ }
549
+ else {
550
+ let status = 'not-installed';
551
+ for (const hp of WINDSURF_HOOK_PATHS) {
552
+ const hooks = readJson(hp)?.hooks?.pre_write_code ?? [];
553
+ if (hooks.some(h => isCurrent(h?.command ?? ''))) {
554
+ status = 'installed';
555
+ break;
556
+ }
557
+ if (hooks.some(h => isOurs(h?.command ?? ''))) {
558
+ status = 'stale';
559
+ }
560
+ }
561
+ results.push({ tool: 'windsurf', status });
562
+ }
563
+ // Codex
564
+ if (!fs.existsSync(path.join(os.homedir(), '.codex'))) {
565
+ results.push({ tool: 'codex', status: 'tool-absent' });
566
+ }
567
+ else {
568
+ const hooks = readJson(CODEX_HOOKS_PATH)?.PreToolUse ?? [];
569
+ if (hooks.some(h => isCurrent(h?.command ?? '')))
570
+ results.push({ tool: 'codex', status: 'installed' });
571
+ else if (hooks.some(h => isOurs(h?.command ?? '')))
572
+ results.push({ tool: 'codex', status: 'stale' });
573
+ else
574
+ results.push({ tool: 'codex', status: 'not-installed' });
575
+ }
576
+ // Cline
577
+ if (!fs.existsSync(CLINE_HOOKS_DIR)) {
578
+ results.push({ tool: 'cline', status: 'tool-absent' });
579
+ }
580
+ else {
581
+ let existing = '';
582
+ try {
583
+ existing = fs.readFileSync(path.join(CLINE_HOOKS_DIR, 'PreToolUse'), 'utf8');
584
+ }
585
+ catch { }
586
+ if (existing.includes(exports.HOOK_VERSION))
587
+ results.push({ tool: 'cline', status: 'installed' });
588
+ else if (existing.includes('.omnitype'))
589
+ results.push({ tool: 'cline', status: 'stale' });
590
+ else
591
+ results.push({ tool: 'cline', status: 'not-installed' });
592
+ }
593
+ // GitHub Copilot
594
+ const hasVscode = fs.existsSync(path.join(os.homedir(), '.vscode')) ||
595
+ fs.existsSync(path.join(os.homedir(), 'Library', 'Application Support', 'Code'));
596
+ const hasCopilotDir = fs.existsSync(path.join(os.homedir(), '.copilot'));
597
+ if (!hasVscode && !hasCopilotDir) {
598
+ results.push({ tool: 'copilot', status: 'tool-absent' });
599
+ }
600
+ else {
601
+ const hooks = readJson(COPILOT_HOOKS_PATH)?.hooks?.PreToolUse ?? [];
602
+ if (hooks.some(h => isCurrent(h?.command ?? '')))
603
+ results.push({ tool: 'copilot', status: 'installed' });
604
+ else if (hooks.some(h => isOurs(h?.command ?? '')))
605
+ results.push({ tool: 'copilot', status: 'stale' });
606
+ else
607
+ results.push({ tool: 'copilot', status: 'not-installed' });
608
+ }
609
+ // Gemini CLI
610
+ if (!fs.existsSync(path.join(os.homedir(), '.gemini'))) {
611
+ results.push({ tool: 'gemini-cli', status: 'tool-absent' });
612
+ }
613
+ else {
614
+ const hooks = readJson(GEMINI_SETTINGS_PATH)?.BeforeTool ?? [];
615
+ if (hooks.some(h => isCurrent(h?.hooks?.[0]?.command ?? '')))
616
+ results.push({ tool: 'gemini-cli', status: 'installed' });
617
+ else if (hooks.some(h => isOurs(h?.hooks?.[0]?.command ?? '')))
618
+ results.push({ tool: 'gemini-cli', status: 'stale' });
619
+ else
620
+ results.push({ tool: 'gemini-cli', status: 'not-installed' });
621
+ }
622
+ // Droid
623
+ if (!fs.existsSync(path.join(os.homedir(), '.factory'))) {
624
+ results.push({ tool: 'droid', status: 'tool-absent' });
625
+ }
626
+ else {
627
+ const hooks = readJson(DROID_SETTINGS_PATH)?.PreToolUse ?? [];
628
+ if (hooks.some(h => isCurrent(h?.hooks?.[0]?.command ?? '')))
629
+ results.push({ tool: 'droid', status: 'installed' });
630
+ else if (hooks.some(h => isOurs(h?.hooks?.[0]?.command ?? '')))
631
+ results.push({ tool: 'droid', status: 'stale' });
632
+ else
633
+ results.push({ tool: 'droid', status: 'not-installed' });
634
+ }
635
+ // Firebender
636
+ if (!fs.existsSync(path.join(os.homedir(), '.firebender'))) {
637
+ results.push({ tool: 'firebender', status: 'tool-absent' });
638
+ }
639
+ else {
640
+ const hooks = readJson(FIREBENDER_HOOKS_PATH)?.preToolUse ?? [];
641
+ if (hooks.some(h => isCurrent(h?.command ?? '')))
642
+ results.push({ tool: 'firebender', status: 'installed' });
643
+ else if (hooks.some(h => isOurs(h?.command ?? '')))
644
+ results.push({ tool: 'firebender', status: 'stale' });
645
+ else
646
+ results.push({ tool: 'firebender', status: 'not-installed' });
647
+ }
648
+ // Amp plugin
649
+ const ampDataDir = process.platform === 'win32'
650
+ ? path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'amp')
651
+ : path.join(process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share'), 'amp');
652
+ if (!fs.existsSync(ampDataDir) && !fs.existsSync(AMP_PLUGINS_DIR)) {
653
+ results.push({ tool: 'amp', status: 'tool-absent' });
654
+ }
655
+ else {
656
+ let src = '';
657
+ try {
658
+ src = fs.readFileSync(AMP_PLUGIN_PATH, 'utf8');
659
+ }
660
+ catch { }
661
+ if (src.includes(exports.HOOK_VERSION))
662
+ results.push({ tool: 'amp', status: 'installed' });
663
+ else if (src.includes('.omnitype'))
664
+ results.push({ tool: 'amp', status: 'stale' });
665
+ else
666
+ results.push({ tool: 'amp', status: 'not-installed' });
667
+ }
668
+ // OpenCode plugin
669
+ if (!fs.existsSync(path.join(os.homedir(), '.config', 'opencode'))) {
670
+ results.push({ tool: 'opencode', status: 'tool-absent' });
671
+ }
672
+ else {
673
+ let src = '';
674
+ try {
675
+ src = fs.readFileSync(OPENCODE_PLUGIN_PATH, 'utf8');
676
+ }
677
+ catch { }
678
+ if (src.includes(exports.HOOK_VERSION))
679
+ results.push({ tool: 'opencode', status: 'installed' });
680
+ else if (src.includes('.omnitype'))
681
+ results.push({ tool: 'opencode', status: 'stale' });
682
+ else
683
+ results.push({ tool: 'opencode', status: 'not-installed' });
684
+ }
685
+ // Pi plugin
686
+ if (!fs.existsSync(path.join(os.homedir(), '.pi'))) {
687
+ results.push({ tool: 'pi', status: 'tool-absent' });
688
+ }
689
+ else {
690
+ let src = '';
691
+ try {
692
+ src = fs.readFileSync(PI_PLUGIN_PATH, 'utf8');
693
+ }
694
+ catch { }
695
+ if (src.includes(exports.HOOK_VERSION))
696
+ results.push({ tool: 'pi', status: 'installed' });
697
+ else if (src.includes('.omnitype'))
698
+ results.push({ tool: 'pi', status: 'stale' });
699
+ else
700
+ results.push({ tool: 'pi', status: 'not-installed' });
701
+ }
702
+ return results;
243
703
  }