@omnitype-code/cli 0.1.0 → 0.1.2

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,19 +1,8 @@
1
- /**
2
- * CLI ModelDetector — editor-agnostic model detection.
3
- *
4
- * Detection tiers:
5
- * 1. Universal sentinel (~/.omnitype/active-model.json)
6
- * 2. Hooks sentinel file (~/.claude/provenance-hook.json)
7
- * 3. Host IDE config (Cursor/Windsurf/Zed settings.json — scanned generically)
8
- * 4. Config files (per-tool config paths: .aider.conf.yml, etc.)
9
- * 5. Environment variables (CLAUDE_MODEL, AIDER_MODEL, etc.)
10
- * 6. Process detection (lsof / ps — identifies the writing process)
11
- */
12
-
13
1
  import * as fs from 'fs';
14
2
  import * as os from 'os';
15
3
  import * as path from 'path';
16
4
  import { execFileSync } from 'child_process';
5
+ import { scanTranscripts } from './TranscriptScanner';
17
6
 
18
7
  export interface ModelDetectionResult {
19
8
  model: string;
@@ -22,115 +11,107 @@ export interface ModelDetectionResult {
22
11
  }
23
12
 
24
13
  const UNKNOWN: ModelDetectionResult = { model: 'unknown', tool: 'unknown', confidence: 'low' };
25
- const HOOKS_SENTINEL_PATH = path.join(os.homedir(), '.claude', 'provenance-hook.json');
26
- const UNIVERSAL_SENTINEL_PATH = path.join(os.homedir(), '.omnitype', 'active-model.json');
27
- const SENTINEL_MAX_AGE_MS = 10_000;
14
+ const SENTINEL_MAX_AGE_MS = 30_000; // 30 s — covers slow multi-file AI diffs
15
+ const UNIVERSAL_PATH = path.join(os.homedir(), '.omnitype', 'active-model.json');
16
+ const HOOKS_PATH = path.join(os.homedir(), '.claude', 'provenance-hook.json');
28
17
 
29
- const MODEL_PATTERN = /\b(claude-[\w.-]+|gpt-[\w.-]+|o[1234](?:-[\w.-]+)?|gemini-[\w.-]+|gemma[\w.-]*|llama-[\w.-]+|mistral[\w.-]*|codestral[\w.-]*|deepseek[\w.-]*|qwen[\w.-]+|command[\w.-]*|phi[\w.-]+|grok[\w.-]*|kimi[\w.-]*|moonshot[\w.-]*)\b/i;
18
+ // Matches model identifier strings from all major providers.
19
+ const MODEL_PATTERN = /\b(claude-[\w.-]+|gpt-[\w.-]+|o[134](?:-[\w.-]+)?|gemini-[\w.-]+|gemma[\w.-]*|llama-[\w.-]+|mistral[\w.-]*|codestral[\w.-]*|deepseek[\w.-]*|qwen[\w.-]+|command[\w.-]*|phi[\w.-]+|grok[\w.-]*|kimi[\w.-]*|moonshot[\w.-]*)\b/i;
30
20
 
31
- // Maps known CLI/IDE env vars to their tool
21
+ // Each tool lists its OWN env vars only. Shared vars (OPENAI_MODEL) are NOT duplicated
22
+ // across tools — the first matching entry wins, so ambiguous vars are assigned to
23
+ // the most common owner (openai). Tools with a dedicated var (CODEX_MODEL) are checked
24
+ // earlier so they can claim edits even when OPENAI_MODEL is also set.
32
25
  const ENV_VARS: Array<{ vars: string[]; tool: string }> = [
33
- { vars: ['CLAUDE_MODEL', 'CLAUDE_CODE_MODEL', 'ANTHROPIC_MODEL'], tool: 'claude-code' },
26
+ { vars: ['CLAUDE_CODE_MODEL', 'CLAUDE_MODEL', 'ANTHROPIC_MODEL'], tool: 'claude-code' },
34
27
  { vars: ['AIDER_MODEL'], tool: 'aider' },
28
+ { vars: ['CODEX_MODEL'], tool: 'codex' }, // before generic OPENAI_MODEL
35
29
  { vars: ['OPENAI_MODEL', 'OPENAI_API_MODEL'], tool: 'openai' },
36
- { vars: ['GEMINI_MODEL', 'GEMINI_API_KEY'], tool: 'gemini-cli' },
37
- { vars: ['OLLAMA_MODEL'], tool: 'ollama' },
38
- { vars: ['COPILOT_MODEL'], tool: 'copilot' },
39
- { vars: ['LLM_MODEL'], tool: 'openhands' },
40
- { vars: ['TABBY_MODEL'], tool: 'tabby' },
30
+ { vars: ['GEMINI_MODEL'], tool: 'gemini-cli' },
31
+ { vars: ['OLLAMA_MODEL'], tool: 'ollama' },
32
+ { vars: ['COPILOT_MODEL'], tool: 'copilot' },
33
+ { vars: ['LLM_MODEL'], tool: 'openhands' },
34
+ { vars: ['TABBY_MODEL'], tool: 'tabby' },
41
35
  ];
42
36
 
43
- const KNOWN_FORKS = [
44
- 'Cursor', 'Windsurf', 'PearAI', 'Void', 'Trae', 'Zed', 'Antigravity'
45
- ];
37
+ const KNOWN_FORKS = ['Cursor', 'Windsurf', 'PearAI', 'Void', 'Trae', 'Zed', 'Antigravity'];
38
+ const AUTO_SENTINELS = new Set(['auto', 'cursor-auto', 'windsurf-auto', 'default', 'best']);
39
+
40
+ // Host IDE processes: always running — their presence does NOT mean they caused the edit.
41
+ const HOST_IDE_PROCS = new Set(['cursor', 'windsurf', 'antigravity', 'pearai', 'void', 'trae', 'zed']);
46
42
 
47
- // lsof command-name tool mapping
48
- const LSOF_CMD_MAP: Array<{ match: string; tool: string }> = [
49
- { match: 'claude', tool: 'claude-code' },
50
- { match: 'aider', tool: 'aider' },
51
- { match: 'cursor', tool: 'cursor' },
52
- { match: 'windsurf', tool: 'windsurf' },
53
- { match: 'zed', tool: 'zed' },
54
- { match: 'pearai', tool: 'pearai' },
55
- { match: 'void', tool: 'void' },
56
- { match: 'tabby', tool: 'tabby' },
57
- { match: 'goose', tool: 'goose' },
58
- { match: 'node', tool: 'unknown-cli' },
59
- { match: 'python', tool: 'unknown-cli' },
43
+ const PROC_MAP: Array<{ match: string; tool: string }> = [
44
+ { match: 'claude', tool: 'claude-code' },
45
+ { match: 'aider', tool: 'aider' },
46
+ { match: 'goose', tool: 'goose' },
47
+ { match: 'codex', tool: 'codex' },
48
+ { match: 'tabby', tool: 'tabby' },
60
49
  ];
61
50
 
62
51
  export class ModelDetector {
63
52
  detect(changedFilePath?: string): ModelDetectionResult {
64
53
  return (
65
- this._fromUniversalSentinel() ??
66
- this._fromHooksSentinel() ??
67
- this._fromIdeConfigs() ??
54
+ this._sentinel(UNIVERSAL_PATH, changedFilePath) ??
55
+ this._sentinel(HOOKS_PATH, changedFilePath) ??
68
56
  this._fromEnv() ??
69
- this._fromPsPatterns() ??
57
+ this._fromTranscripts() ??
58
+ this._fromIdeConfigs() ??
59
+ this._fromPs() ??
70
60
  (changedFilePath ? this._fromLsof(changedFilePath) : undefined) ??
71
61
  UNKNOWN
72
62
  );
73
63
  }
74
64
 
75
- private _fromUniversalSentinel(): ModelDetectionResult | undefined {
76
- try {
77
- const stat = fs.statSync(UNIVERSAL_SENTINEL_PATH);
78
- if (Date.now() - stat.mtimeMs > SENTINEL_MAX_AGE_MS) return undefined;
79
- const data = JSON.parse(fs.readFileSync(UNIVERSAL_SENTINEL_PATH, 'utf8'));
80
- if (!data?.model || data.model === 'unknown') return undefined;
81
- return { model: data.model, tool: data.tool ?? 'unknown-tool', confidence: 'deterministic' };
82
- } catch { return undefined; }
65
+ private _fromTranscripts(): ModelDetectionResult | undefined {
66
+ const r = scanTranscripts();
67
+ if (!r) return undefined;
68
+ return { model: r.model, tool: r.tool, confidence: 'high' };
83
69
  }
84
70
 
85
- private _fromHooksSentinel(): ModelDetectionResult | undefined {
71
+ private _sentinel(filePath: string, changedPath?: string): ModelDetectionResult | undefined {
86
72
  try {
87
- const stat = fs.statSync(HOOKS_SENTINEL_PATH);
88
- if (Date.now() - stat.mtimeMs > SENTINEL_MAX_AGE_MS) return undefined;
89
- const data = JSON.parse(fs.readFileSync(HOOKS_SENTINEL_PATH, 'utf8'));
90
- if (!data?.model || data.model === 'unknown') return undefined;
91
- return { model: data.model, tool: data.tool ?? 'claude-code', confidence: 'deterministic' };
73
+ const mtime = fs.statSync(filePath).mtimeMs;
74
+ const d = JSON.parse(fs.readFileSync(filePath, 'utf8'));
75
+ if (!d?.model || d.model === 'unknown') return undefined;
76
+
77
+ if (d.file && changedPath) {
78
+ // File-path gated: trust only if the sentinel targets this exact file.
79
+ // Use a generous TTL since path specificity eliminates cross-file contamination.
80
+ if (d.file !== changedPath) return undefined;
81
+ if (Date.now() - mtime > 120_000) return undefined; // 2 min max
82
+ } else {
83
+ // No path info: fall back to strict TTL to minimise false attribution.
84
+ if (Date.now() - mtime > SENTINEL_MAX_AGE_MS) return undefined;
85
+ }
86
+
87
+ if (!d?.model || d.model === 'unknown') return undefined;
88
+ return { model: d.model, tool: d.tool ?? 'unknown-tool', confidence: 'deterministic' };
92
89
  } catch { return undefined; }
93
90
  }
94
91
 
95
- private _fromIdeConfigs(): ModelDetectionResult | undefined {
96
- for (const appName of KNOWN_FORKS) {
97
- const scan = this._scanIdeSettings(appName);
98
- if (scan) {
99
- return {
100
- model: scan.model,
101
- tool: appName.toLowerCase(),
102
- confidence: scan.isAuto ? 'medium' : 'high'
103
- };
104
- }
105
- }
92
+ private _fromEnv(): ModelDetectionResult | undefined {
93
+ for (const { vars, tool } of ENV_VARS)
94
+ for (const v of vars) { const val = process.env[v]; if (val && MODEL_PATTERN.test(val)) return { model: val, tool, confidence: 'high' }; }
106
95
  return undefined;
107
96
  }
108
97
 
109
- private _fromEnv(): ModelDetectionResult | undefined {
110
- for (const { vars, tool } of ENV_VARS) {
111
- for (const v of vars) {
112
- const val = process.env[v];
113
- if (val && MODEL_PATTERN.test(val)) {
114
- return { model: val, tool, confidence: 'high' };
115
- }
116
- }
98
+ private _fromIdeConfigs(): ModelDetectionResult | undefined {
99
+ for (const appName of KNOWN_FORKS) {
100
+ const scan = this._scanIdeSettings(appName);
101
+ if (scan) return { model: scan.model, tool: appName.toLowerCase(), confidence: scan.isAuto ? 'medium' : 'high' };
117
102
  }
118
103
  return undefined;
119
104
  }
120
105
 
121
- private _fromPsPatterns(): ModelDetectionResult | undefined {
106
+ private _fromPs(): ModelDetectionResult | undefined {
122
107
  if (process.platform === 'win32') return undefined;
123
108
  try {
124
109
  const lines = execFileSync('ps', ['ax', '-o', 'args='], { timeout: 800, encoding: 'utf8' }).split('\n');
125
- for (const line of lines) {
126
- for (const entry of LSOF_CMD_MAP) {
127
- if (line.toLowerCase().includes(entry.match)) {
128
- // Fallback model name
129
- return { model: `${entry.tool}-default`, tool: entry.tool, confidence: 'low' };
130
- }
131
- }
132
- }
133
- } catch { /* ps unavailable */ }
110
+ for (const line of lines)
111
+ for (const { match, tool } of PROC_MAP)
112
+ if (line.toLowerCase().includes(match) && !HOST_IDE_PROCS.has(tool))
113
+ return { model: `${tool}-default`, tool, confidence: 'low' };
114
+ } catch {}
134
115
  return undefined;
135
116
  }
136
117
 
@@ -141,57 +122,36 @@ export class ModelDetector {
141
122
  for (const line of out.split('\n')) {
142
123
  if (!line.startsWith('c')) continue;
143
124
  const cmd = line.slice(1).toLowerCase();
144
- for (const entry of LSOF_CMD_MAP) {
145
- if (cmd.includes(entry.match)) {
146
- return { model: `${entry.tool}-default`, tool: entry.tool, confidence: 'low' };
147
- }
148
- }
125
+ for (const { match, tool } of PROC_MAP)
126
+ if (cmd.includes(match) && !HOST_IDE_PROCS.has(tool))
127
+ return { model: `${tool}-default`, tool, confidence: 'low' };
149
128
  }
150
- } catch { /* lsof unavailable */ }
129
+ } catch {}
151
130
  return undefined;
152
131
  }
153
132
 
154
133
  private _scanIdeSettings(appName: string): { model: string; isAuto: boolean } | undefined {
155
- for (const p of this._getIdeSettingsPaths(appName)) {
134
+ for (const p of this._idePaths(appName)) {
156
135
  try {
157
- const raw = fs.readFileSync(p, 'utf8');
158
- const json = JSON.parse(raw);
159
- const flat = this._flattenObject(json);
160
-
161
- const AUTO_SENTINELS = new Set(['auto', 'cursor-auto', 'windsurf-auto', 'default', 'best']);
136
+ const flat = this._flatten(JSON.parse(fs.readFileSync(p, 'utf8')));
162
137
  const candidates: Array<{ key: string; value: string }> = [];
163
-
164
138
  for (const [key, value] of Object.entries(flat)) {
165
- if (typeof value !== 'string' || !value) continue;
166
- if (!key.toLowerCase().includes('model')) continue;
167
-
168
- if (AUTO_SENTINELS.has(value.toLowerCase())) {
169
- candidates.push({ key, value: `${appName.toLowerCase()}-auto` });
170
- continue;
171
- }
172
-
173
- if (MODEL_PATTERN.test(value)) {
174
- candidates.push({ key, value: value.toLowerCase() });
175
- }
176
- }
177
-
178
- if (candidates.length > 0) {
179
- const real = candidates.filter(c => !c.value.endsWith('-auto'));
180
- const pick = real.length > 0
181
- ? real.sort((a, b) => b.key.length - a.key.length)[0]
182
- : candidates.sort((a, b) => b.key.length - a.key.length)[0];
183
- return { model: pick.value, isAuto: pick.value.endsWith('-auto') };
139
+ if (typeof value !== 'string' || !key.toLowerCase().includes('model')) continue;
140
+ if (AUTO_SENTINELS.has(value.toLowerCase())) { candidates.push({ key, value: `${appName.toLowerCase()}-auto` }); continue; }
141
+ if (MODEL_PATTERN.test(value)) candidates.push({ key, value: value.toLowerCase() });
184
142
  }
185
- } catch { /* absent */ }
143
+ if (!candidates.length) continue;
144
+ const real = candidates.filter(c => !c.value.endsWith('-auto'));
145
+ const pick = (real.length ? real : candidates).sort((a, b) => b.key.length - a.key.length)[0];
146
+ return { model: pick.value, isAuto: pick.value.endsWith('-auto') };
147
+ } catch {}
186
148
  }
187
149
  return undefined;
188
150
  }
189
151
 
190
- private _getIdeSettingsPaths(appName: string): string[] {
191
- const toolId = appName.toLowerCase().replace(/\s+/g, '-');
192
- const dirCandidates = [...new Set([appName, toolId])];
193
-
194
- return dirCandidates.map(dir => {
152
+ private _idePaths(appName: string): string[] {
153
+ const id = appName.toLowerCase().replace(/\s+/g, '-');
154
+ return [...new Set([appName, id])].map(dir => {
195
155
  switch (process.platform) {
196
156
  case 'darwin': return path.join(os.homedir(), 'Library', 'Application Support', dir, 'User', 'settings.json');
197
157
  case 'win32': return path.join(process.env.APPDATA ?? '', dir, 'User', 'settings.json');
@@ -200,16 +160,14 @@ export class ModelDetector {
200
160
  });
201
161
  }
202
162
 
203
- private _flattenObject(obj: Record<string, unknown>, prefix = '', depth = 0): Record<string, unknown> {
163
+ private _flatten(obj: Record<string, unknown>, prefix = '', depth = 0): Record<string, unknown> {
204
164
  if (depth > 5) return {};
205
165
  const out: Record<string, unknown> = {};
206
166
  for (const [k, v] of Object.entries(obj)) {
207
167
  const key = prefix ? `${prefix}.${k}` : k;
208
- if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
209
- Object.assign(out, this._flattenObject(v as Record<string, unknown>, key, depth + 1));
210
- } else {
211
- out[key] = v;
212
- }
168
+ if (v !== null && typeof v === 'object' && !Array.isArray(v))
169
+ Object.assign(out, this._flatten(v as Record<string, unknown>, key, depth + 1));
170
+ else out[key] = v;
213
171
  }
214
172
  return out;
215
173
  }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Auto-installs OmniType model-detection hooks into AI tools that support
3
+ * a preToolUse/postToolUse hook system: Claude Code, Cursor, Windsurf, Codex, Cline.
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.
8
+ *
9
+ * Each installer is idempotent — safe to call on every startup.
10
+ * Fails silently so it never breaks the CLI for the user.
11
+ */
12
+
13
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+
17
+ const OMNITYPE_DIR = path.join(os.homedir(), '.omnitype');
18
+
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 {
22
+ 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');` +
29
+ `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{}})"`
34
+ );
35
+ }
36
+
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
+
52
+ function readJson(filePath: string): Record<string, any> {
53
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return {}; }
54
+ }
55
+
56
+ function writeJsonAtomic(filePath: string, data: unknown): void {
57
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
58
+ const tmp = filePath + '.tmp';
59
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
60
+ fs.renameSync(tmp, filePath);
61
+ }
62
+
63
+ // ── Claude Code ───────────────────────────────────────────────────────────────
64
+
65
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
66
+ const CLAUDE_SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
67
+ const CLAUDE_HOOK_ENTRY = {
68
+ matcher: 'Write|Edit|MultiEdit|NotebookEdit',
69
+ hooks: [{ type: 'command', command: CLAUDE_HOOK_CMD }],
70
+ };
71
+
72
+ function installClaudeHooks(): void {
73
+ if (!fs.existsSync(CLAUDE_DIR)) return;
74
+ const s = readJson(CLAUDE_SETTINGS);
75
+ if (!s.hooks) s.hooks = {};
76
+ 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); }
81
+ }
82
+
83
+ // ── Cursor ────────────────────────────────────────────────────────────────────
84
+
85
+ const CURSOR_HOOKS_PATH = path.join(os.homedir(), '.cursor', 'hooks.json');
86
+ const CURSOR_CMD = buildHookCommand('cursor', 'model');
87
+
88
+ function installCursorHooks(): void {
89
+ if (!fs.existsSync(path.join(os.homedir(), '.cursor'))) return;
90
+ const settings = readJson(CURSOR_HOOKS_PATH);
91
+ if (!settings.hooks) settings.hooks = {};
92
+ let changed = false;
93
+ for (const event of ['preToolUse', 'postToolUse']) {
94
+ 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; }
99
+ }
100
+ if (!settings.version) settings.version = 1;
101
+ if (changed) writeJsonAtomic(CURSOR_HOOKS_PATH, settings);
102
+ }
103
+
104
+ // ── Windsurf ──────────────────────────────────────────────────────────────────
105
+
106
+ const WINDSURF_HOOK_PATHS = [
107
+ path.join(os.homedir(), '.codeium', 'windsurf', 'hooks.json'),
108
+ path.join(os.homedir(), '.codeium', 'hooks.json'),
109
+ ];
110
+ const WINDSURF_EVENTS = ['pre_write_code', 'post_write_code', 'pre_run_command', 'post_run_command'];
111
+ const WINDSURF_CMD = buildHookCommand('windsurf', 'model_name');
112
+
113
+ function installWindsurfHooks(): void {
114
+ if (!fs.existsSync(path.join(os.homedir(), '.codeium'))) return;
115
+ for (const hookPath of WINDSURF_HOOK_PATHS) {
116
+ if (!fs.existsSync(hookPath) && !fs.existsSync(path.dirname(hookPath))) continue;
117
+ const settings = readJson(hookPath);
118
+ if (!settings.hooks) settings.hooks = {};
119
+ let changed = false;
120
+ for (const event of WINDSURF_EVENTS) {
121
+ 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; }
126
+ }
127
+ if (changed) writeJsonAtomic(hookPath, settings);
128
+ }
129
+ }
130
+
131
+ // ── Codex ─────────────────────────────────────────────────────────────────────
132
+
133
+ const CODEX_HOOKS_PATH = path.join(os.homedir(), '.codex', 'hooks.json');
134
+ const CODEX_CMD = buildHookCommand('codex', 'model');
135
+
136
+ function installCodexHooks(): void {
137
+ if (!fs.existsSync(path.join(os.homedir(), '.codex'))) return;
138
+ const settings = readJson(CODEX_HOOKS_PATH);
139
+ let changed = false;
140
+ for (const event of ['PreToolUse', 'PostToolUse']) {
141
+ 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; }
146
+ }
147
+ if (changed) writeJsonAtomic(CODEX_HOOKS_PATH, settings);
148
+ }
149
+
150
+ // ── Cline ─────────────────────────────────────────────────────────────────────
151
+ // Cline looks for executable scripts in ~/Documents/Cline/Hooks/ named after
152
+ // hook events. We drop a PreToolUse script that reads stdin JSON and writes
153
+ // the sentinel only for file-modifying tools.
154
+
155
+ const CLINE_HOOKS_DIR = path.join(os.homedir(), 'Documents', 'Cline', 'Hooks');
156
+ const CLINE_HOOK_SCRIPT = `#!/usr/bin/env node
157
+ const FILE_WRITE_TOOLS = new Set(['write_to_file','apply_diff','insert_content','search_and_replace']);
158
+ let buf = '';
159
+ process.stdin.on('data', c => buf += c);
160
+ process.stdin.on('end', () => {
161
+ try {
162
+ const j = JSON.parse(buf);
163
+ if (!FILE_WRITE_TOOLS.has(j.toolName)) return;
164
+ const model = j.model || j.preToolUse?.model || '';
165
+ const fs = require('fs'), p = require('path'), os = require('os');
166
+ const dir = p.join(os.homedir(), '.omnitype');
167
+ fs.mkdirSync(dir, { recursive: true });
168
+ fs.writeFileSync(p.join(dir, 'active-model.json'), JSON.stringify({ model, tool: 'cline', ts: Date.now() }));
169
+ } catch {}
170
+ });
171
+ `;
172
+
173
+ function installClineHooks(): void {
174
+ if (!fs.existsSync(CLINE_HOOKS_DIR)) return;
175
+ 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);
181
+ }
182
+ }
183
+
184
+ // ── Public API ────────────────────────────────────────────────────────────────
185
+
186
+ export function installAllToolHooks(): void {
187
+ try { installClaudeHooks(); } catch {}
188
+ try { installCursorHooks(); } catch {}
189
+ try { installWindsurfHooks(); } catch {}
190
+ try { installCodexHooks(); } catch {}
191
+ try { installClineHooks(); } catch {}
192
+ }