@omnitype-code/cli 0.1.1 → 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to the **@omnitype-code/cli** package are documented here.
4
+
5
+ ---
6
+
7
+ ## [0.1.2] — 2026-05-16
8
+
9
+ ### Fixed
10
+ - **Sentinel race condition** — Replaced 10s time-window TTL with path-gated matching. Sentinel is only trusted if `sentinel.file === changedPath`, eliminating cross-file attribution contamination.
11
+ - **TranscriptScanner** — Added as a detection tier between env vars and IDE config parsing. Reads the 50KB tail of the most recent JSONL session file per tool, with a 60s cache.
12
+ - **Claude Code hook** — Added to `installAllToolHooks` — was missing from the CLI installer in v0.1.1.
13
+ - **Hook model fallbacks removed** — Hook writer now skips write if the model cannot be resolved. No more hardcoded `claude-sonnet-4-6` fallback.
14
+ - **`SENTINEL_MAX_AGE_MS`** raised from 10s to 30s for the strict (no file field) fallback path.
15
+
16
+ ### Changed
17
+ - Detection pipeline order is now: sentinel (universal) → sentinel (hooks) → env vars → transcript scan → IDE configs → ps → lsof.
18
+
19
+ ### Added
20
+ - **Cline hook installer** — drops a `PreToolUse` script into `~/Documents/Cline/Hooks/` on startup. Idempotent, fires only for file-modifying tools.
21
+ - `installAllToolHooks` now covers Claude Code, Cursor, Windsurf, Codex, and Cline.
22
+
23
+ ---
24
+
25
+ ## [0.1.1] — 2026-05-14
26
+
27
+ ### Added
28
+ - **`omnitype signal --model <name>`** — Manually report active AI models from scripts or unsupported tools.
29
+ - **`omnitype blame <file>`** — ANSI-colored per-line attribution with model names.
30
+ - **`omnitype daemon`** — Editor-agnostic file watching for Cursor, Windsurf, JetBrains, Neovim, and any other editor. Auto-yields to the VS Code extension for live-tracked workspaces (30-second yield guard).
31
+ - **`omnitype hooks install`** — Installs a pre-push git hook that writes git notes and syncs attribution to the cloud.
32
+ - **`omnitype syncNow`** — Manual provenance sync command.
33
+ - **Smart Claude hook** — Automatically detects model preferences from `~/.claude/settings.json`.
34
+ - **Protocol v1.1 support** — Writes `file` and `genId` fields in sentinel output.
35
+ - **Rich CLI UI** — Colors, progress spinners, and structured error reporting.
36
+ - **Org-only cloud storage** — Individual users remain local-only. Cloud sync gates on organization membership.
37
+ - **Provenance routes modularized** — Internal route handlers split into separate files.
38
+
39
+ ---
40
+
41
+ ## [0.1.0] — 2026-05-13
42
+
43
+ ### Added
44
+ - Initial CLI package.
45
+ - Git hook manager for push coverage — hooks written per-repo on install.
46
+ - AI model attribution tracking wired into personal vs. org project storage.
47
+ - File deletion tracking with per-line timestamps in provenance metadata.
48
+ - Cloud sync to S3 blobs with MongoDB manifest references.
49
+
50
+ ---
@@ -1,15 +1,4 @@
1
1
  "use strict";
2
- /**
3
- * CLI ModelDetector — editor-agnostic model detection.
4
- *
5
- * Detection tiers:
6
- * 1. Universal sentinel (~/.omnitype/active-model.json)
7
- * 2. Hooks sentinel file (~/.claude/provenance-hook.json)
8
- * 3. Host IDE config (Cursor/Windsurf/Zed settings.json — scanned generically)
9
- * 4. Config files (per-tool config paths: .aider.conf.yml, etc.)
10
- * 5. Environment variables (CLAUDE_MODEL, AIDER_MODEL, etc.)
11
- * 6. Process detection (lsof / ps — identifies the writing process)
12
- */
13
2
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
3
  if (k2 === undefined) k2 = k;
15
4
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -49,116 +38,111 @@ const fs = __importStar(require("fs"));
49
38
  const os = __importStar(require("os"));
50
39
  const path = __importStar(require("path"));
51
40
  const child_process_1 = require("child_process");
41
+ const TranscriptScanner_1 = require("./TranscriptScanner");
52
42
  const UNKNOWN = { model: 'unknown', tool: 'unknown', confidence: 'low' };
53
- const HOOKS_SENTINEL_PATH = path.join(os.homedir(), '.claude', 'provenance-hook.json');
54
- const UNIVERSAL_SENTINEL_PATH = path.join(os.homedir(), '.omnitype', 'active-model.json');
55
- const SENTINEL_MAX_AGE_MS = 10000;
56
- 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;
57
- // Maps known CLI/IDE env vars to their tool
43
+ const SENTINEL_MAX_AGE_MS = 30000; // 30 s — covers slow multi-file AI diffs
44
+ const UNIVERSAL_PATH = path.join(os.homedir(), '.omnitype', 'active-model.json');
45
+ const HOOKS_PATH = path.join(os.homedir(), '.claude', 'provenance-hook.json');
46
+ // Matches model identifier strings from all major providers.
47
+ 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;
48
+ // Each tool lists its OWN env vars only. Shared vars (OPENAI_MODEL) are NOT duplicated
49
+ // across tools — the first matching entry wins, so ambiguous vars are assigned to
50
+ // the most common owner (openai). Tools with a dedicated var (CODEX_MODEL) are checked
51
+ // earlier so they can claim edits even when OPENAI_MODEL is also set.
58
52
  const ENV_VARS = [
59
- { vars: ['CLAUDE_MODEL', 'CLAUDE_CODE_MODEL', 'ANTHROPIC_MODEL'], tool: 'claude-code' },
53
+ { vars: ['CLAUDE_CODE_MODEL', 'CLAUDE_MODEL', 'ANTHROPIC_MODEL'], tool: 'claude-code' },
60
54
  { vars: ['AIDER_MODEL'], tool: 'aider' },
55
+ { vars: ['CODEX_MODEL'], tool: 'codex' }, // before generic OPENAI_MODEL
61
56
  { vars: ['OPENAI_MODEL', 'OPENAI_API_MODEL'], tool: 'openai' },
62
- { vars: ['GEMINI_MODEL', 'GEMINI_API_KEY'], tool: 'gemini-cli' },
57
+ { vars: ['GEMINI_MODEL'], tool: 'gemini-cli' },
63
58
  { vars: ['OLLAMA_MODEL'], tool: 'ollama' },
64
59
  { vars: ['COPILOT_MODEL'], tool: 'copilot' },
65
60
  { vars: ['LLM_MODEL'], tool: 'openhands' },
66
61
  { vars: ['TABBY_MODEL'], tool: 'tabby' },
67
62
  ];
68
- const KNOWN_FORKS = [
69
- 'Cursor', 'Windsurf', 'PearAI', 'Void', 'Trae', 'Zed', 'Antigravity'
70
- ];
71
- // lsof command-name tool mapping
72
- const LSOF_CMD_MAP = [
63
+ const KNOWN_FORKS = ['Cursor', 'Windsurf', 'PearAI', 'Void', 'Trae', 'Zed', 'Antigravity'];
64
+ const AUTO_SENTINELS = new Set(['auto', 'cursor-auto', 'windsurf-auto', 'default', 'best']);
65
+ // Host IDE processes: always running — their presence does NOT mean they caused the edit.
66
+ const HOST_IDE_PROCS = new Set(['cursor', 'windsurf', 'antigravity', 'pearai', 'void', 'trae', 'zed']);
67
+ const PROC_MAP = [
73
68
  { match: 'claude', tool: 'claude-code' },
74
69
  { match: 'aider', tool: 'aider' },
75
- { match: 'cursor', tool: 'cursor' },
76
- { match: 'windsurf', tool: 'windsurf' },
77
- { match: 'zed', tool: 'zed' },
78
- { match: 'pearai', tool: 'pearai' },
79
- { match: 'void', tool: 'void' },
80
- { match: 'tabby', tool: 'tabby' },
81
70
  { match: 'goose', tool: 'goose' },
82
- { match: 'node', tool: 'unknown-cli' },
83
- { match: 'python', tool: 'unknown-cli' },
71
+ { match: 'codex', tool: 'codex' },
72
+ { match: 'tabby', tool: 'tabby' },
84
73
  ];
85
74
  class ModelDetector {
86
75
  detect(changedFilePath) {
87
- return (this._fromUniversalSentinel() ??
88
- this._fromHooksSentinel() ??
89
- this._fromIdeConfigs() ??
76
+ return (this._sentinel(UNIVERSAL_PATH, changedFilePath) ??
77
+ this._sentinel(HOOKS_PATH, changedFilePath) ??
90
78
  this._fromEnv() ??
91
- this._fromPsPatterns() ??
79
+ this._fromTranscripts() ??
80
+ this._fromIdeConfigs() ??
81
+ this._fromPs() ??
92
82
  (changedFilePath ? this._fromLsof(changedFilePath) : undefined) ??
93
83
  UNKNOWN);
94
84
  }
95
- _fromUniversalSentinel() {
96
- try {
97
- const stat = fs.statSync(UNIVERSAL_SENTINEL_PATH);
98
- if (Date.now() - stat.mtimeMs > SENTINEL_MAX_AGE_MS)
99
- return undefined;
100
- const data = JSON.parse(fs.readFileSync(UNIVERSAL_SENTINEL_PATH, 'utf8'));
101
- if (!data?.model || data.model === 'unknown')
102
- return undefined;
103
- return { model: data.model, tool: data.tool ?? 'unknown-tool', confidence: 'deterministic' };
104
- }
105
- catch {
85
+ _fromTranscripts() {
86
+ const r = (0, TranscriptScanner_1.scanTranscripts)();
87
+ if (!r)
106
88
  return undefined;
107
- }
89
+ return { model: r.model, tool: r.tool, confidence: 'high' };
108
90
  }
109
- _fromHooksSentinel() {
91
+ _sentinel(filePath, changedPath) {
110
92
  try {
111
- const stat = fs.statSync(HOOKS_SENTINEL_PATH);
112
- if (Date.now() - stat.mtimeMs > SENTINEL_MAX_AGE_MS)
93
+ const mtime = fs.statSync(filePath).mtimeMs;
94
+ const d = JSON.parse(fs.readFileSync(filePath, 'utf8'));
95
+ if (!d?.model || d.model === 'unknown')
113
96
  return undefined;
114
- const data = JSON.parse(fs.readFileSync(HOOKS_SENTINEL_PATH, 'utf8'));
115
- if (!data?.model || data.model === 'unknown')
97
+ if (d.file && changedPath) {
98
+ // File-path gated: trust only if the sentinel targets this exact file.
99
+ // Use a generous TTL since path specificity eliminates cross-file contamination.
100
+ if (d.file !== changedPath)
101
+ return undefined;
102
+ if (Date.now() - mtime > 120000)
103
+ return undefined; // 2 min max
104
+ }
105
+ else {
106
+ // No path info: fall back to strict TTL to minimise false attribution.
107
+ if (Date.now() - mtime > SENTINEL_MAX_AGE_MS)
108
+ return undefined;
109
+ }
110
+ if (!d?.model || d.model === 'unknown')
116
111
  return undefined;
117
- return { model: data.model, tool: data.tool ?? 'claude-code', confidence: 'deterministic' };
112
+ return { model: d.model, tool: d.tool ?? 'unknown-tool', confidence: 'deterministic' };
118
113
  }
119
114
  catch {
120
115
  return undefined;
121
116
  }
122
117
  }
123
- _fromIdeConfigs() {
124
- for (const appName of KNOWN_FORKS) {
125
- const scan = this._scanIdeSettings(appName);
126
- if (scan) {
127
- return {
128
- model: scan.model,
129
- tool: appName.toLowerCase(),
130
- confidence: scan.isAuto ? 'medium' : 'high'
131
- };
132
- }
133
- }
134
- return undefined;
135
- }
136
118
  _fromEnv() {
137
- for (const { vars, tool } of ENV_VARS) {
119
+ for (const { vars, tool } of ENV_VARS)
138
120
  for (const v of vars) {
139
121
  const val = process.env[v];
140
- if (val && MODEL_PATTERN.test(val)) {
122
+ if (val && MODEL_PATTERN.test(val))
141
123
  return { model: val, tool, confidence: 'high' };
142
- }
143
124
  }
125
+ return undefined;
126
+ }
127
+ _fromIdeConfigs() {
128
+ for (const appName of KNOWN_FORKS) {
129
+ const scan = this._scanIdeSettings(appName);
130
+ if (scan)
131
+ return { model: scan.model, tool: appName.toLowerCase(), confidence: scan.isAuto ? 'medium' : 'high' };
144
132
  }
145
133
  return undefined;
146
134
  }
147
- _fromPsPatterns() {
135
+ _fromPs() {
148
136
  if (process.platform === 'win32')
149
137
  return undefined;
150
138
  try {
151
139
  const lines = (0, child_process_1.execFileSync)('ps', ['ax', '-o', 'args='], { timeout: 800, encoding: 'utf8' }).split('\n');
152
- for (const line of lines) {
153
- for (const entry of LSOF_CMD_MAP) {
154
- if (line.toLowerCase().includes(entry.match)) {
155
- // Fallback model name
156
- return { model: `${entry.tool}-default`, tool: entry.tool, confidence: 'low' };
157
- }
158
- }
159
- }
140
+ for (const line of lines)
141
+ for (const { match, tool } of PROC_MAP)
142
+ if (line.toLowerCase().includes(match) && !HOST_IDE_PROCS.has(tool))
143
+ return { model: `${tool}-default`, tool, confidence: 'low' };
160
144
  }
161
- catch { /* ps unavailable */ }
145
+ catch { }
162
146
  return undefined;
163
147
  }
164
148
  _fromLsof(filePath) {
@@ -170,53 +154,42 @@ class ModelDetector {
170
154
  if (!line.startsWith('c'))
171
155
  continue;
172
156
  const cmd = line.slice(1).toLowerCase();
173
- for (const entry of LSOF_CMD_MAP) {
174
- if (cmd.includes(entry.match)) {
175
- return { model: `${entry.tool}-default`, tool: entry.tool, confidence: 'low' };
176
- }
177
- }
157
+ for (const { match, tool } of PROC_MAP)
158
+ if (cmd.includes(match) && !HOST_IDE_PROCS.has(tool))
159
+ return { model: `${tool}-default`, tool, confidence: 'low' };
178
160
  }
179
161
  }
180
- catch { /* lsof unavailable */ }
162
+ catch { }
181
163
  return undefined;
182
164
  }
183
165
  _scanIdeSettings(appName) {
184
- for (const p of this._getIdeSettingsPaths(appName)) {
166
+ for (const p of this._idePaths(appName)) {
185
167
  try {
186
- const raw = fs.readFileSync(p, 'utf8');
187
- const json = JSON.parse(raw);
188
- const flat = this._flattenObject(json);
189
- const AUTO_SENTINELS = new Set(['auto', 'cursor-auto', 'windsurf-auto', 'default', 'best']);
168
+ const flat = this._flatten(JSON.parse(fs.readFileSync(p, 'utf8')));
190
169
  const candidates = [];
191
170
  for (const [key, value] of Object.entries(flat)) {
192
- if (typeof value !== 'string' || !value)
193
- continue;
194
- if (!key.toLowerCase().includes('model'))
171
+ if (typeof value !== 'string' || !key.toLowerCase().includes('model'))
195
172
  continue;
196
173
  if (AUTO_SENTINELS.has(value.toLowerCase())) {
197
174
  candidates.push({ key, value: `${appName.toLowerCase()}-auto` });
198
175
  continue;
199
176
  }
200
- if (MODEL_PATTERN.test(value)) {
177
+ if (MODEL_PATTERN.test(value))
201
178
  candidates.push({ key, value: value.toLowerCase() });
202
- }
203
- }
204
- if (candidates.length > 0) {
205
- const real = candidates.filter(c => !c.value.endsWith('-auto'));
206
- const pick = real.length > 0
207
- ? real.sort((a, b) => b.key.length - a.key.length)[0]
208
- : candidates.sort((a, b) => b.key.length - a.key.length)[0];
209
- return { model: pick.value, isAuto: pick.value.endsWith('-auto') };
210
179
  }
180
+ if (!candidates.length)
181
+ continue;
182
+ const real = candidates.filter(c => !c.value.endsWith('-auto'));
183
+ const pick = (real.length ? real : candidates).sort((a, b) => b.key.length - a.key.length)[0];
184
+ return { model: pick.value, isAuto: pick.value.endsWith('-auto') };
211
185
  }
212
- catch { /* absent */ }
186
+ catch { }
213
187
  }
214
188
  return undefined;
215
189
  }
216
- _getIdeSettingsPaths(appName) {
217
- const toolId = appName.toLowerCase().replace(/\s+/g, '-');
218
- const dirCandidates = [...new Set([appName, toolId])];
219
- return dirCandidates.map(dir => {
190
+ _idePaths(appName) {
191
+ const id = appName.toLowerCase().replace(/\s+/g, '-');
192
+ return [...new Set([appName, id])].map(dir => {
220
193
  switch (process.platform) {
221
194
  case 'darwin': return path.join(os.homedir(), 'Library', 'Application Support', dir, 'User', 'settings.json');
222
195
  case 'win32': return path.join(process.env.APPDATA ?? '', dir, 'User', 'settings.json');
@@ -224,18 +197,16 @@ class ModelDetector {
224
197
  }
225
198
  });
226
199
  }
227
- _flattenObject(obj, prefix = '', depth = 0) {
200
+ _flatten(obj, prefix = '', depth = 0) {
228
201
  if (depth > 5)
229
202
  return {};
230
203
  const out = {};
231
204
  for (const [k, v] of Object.entries(obj)) {
232
205
  const key = prefix ? `${prefix}.${k}` : k;
233
- if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
234
- Object.assign(out, this._flattenObject(v, key, depth + 1));
235
- }
236
- else {
206
+ if (v !== null && typeof v === 'object' && !Array.isArray(v))
207
+ Object.assign(out, this._flatten(v, key, depth + 1));
208
+ else
237
209
  out[key] = v;
238
- }
239
210
  }
240
211
  return out;
241
212
  }
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ /**
3
+ * Auto-installs OmniType model-detection hooks into AI tools that support
4
+ * a preToolUse/postToolUse hook system: Claude Code, Cursor, Windsurf, Codex, Cline.
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.
9
+ *
10
+ * Each installer is idempotent — safe to call on every startup.
11
+ * Fails silently so it never breaks the CLI for the user.
12
+ */
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.installAllToolHooks = installAllToolHooks;
48
+ const fs = __importStar(require("fs"));
49
+ const os = __importStar(require("os"));
50
+ 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{}` +
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})))})"`;
80
+ function readJson(filePath) {
81
+ try {
82
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
83
+ }
84
+ catch {
85
+ return {};
86
+ }
87
+ }
88
+ function writeJsonAtomic(filePath, data) {
89
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
90
+ const tmp = filePath + '.tmp';
91
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
92
+ fs.renameSync(tmp, filePath);
93
+ }
94
+ // ── Claude Code ───────────────────────────────────────────────────────────────
95
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
96
+ const CLAUDE_SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
97
+ const CLAUDE_HOOK_ENTRY = {
98
+ matcher: 'Write|Edit|MultiEdit|NotebookEdit',
99
+ hooks: [{ type: 'command', command: CLAUDE_HOOK_CMD }],
100
+ };
101
+ function installClaudeHooks() {
102
+ if (!fs.existsSync(CLAUDE_DIR))
103
+ return;
104
+ const s = readJson(CLAUDE_SETTINGS);
105
+ if (!s.hooks)
106
+ s.hooks = {};
107
+ if (!Array.isArray(s.hooks.PreToolUse))
108
+ 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
+ }
114
+ }
115
+ // ── Cursor ────────────────────────────────────────────────────────────────────
116
+ const CURSOR_HOOKS_PATH = path.join(os.homedir(), '.cursor', 'hooks.json');
117
+ const CURSOR_CMD = buildHookCommand('cursor', 'model');
118
+ function installCursorHooks() {
119
+ if (!fs.existsSync(path.join(os.homedir(), '.cursor')))
120
+ return;
121
+ const settings = readJson(CURSOR_HOOKS_PATH);
122
+ if (!settings.hooks)
123
+ settings.hooks = {};
124
+ let changed = false;
125
+ for (const event of ['preToolUse', 'postToolUse']) {
126
+ if (!Array.isArray(settings.hooks[event]))
127
+ settings.hooks[event] = [];
128
+ const already = settings.hooks[event].some((h) => typeof h?.command === 'string' && h.command.includes('.omnitype'));
129
+ if (!already) {
130
+ settings.hooks[event].push({ command: CURSOR_CMD });
131
+ changed = true;
132
+ }
133
+ }
134
+ if (!settings.version)
135
+ settings.version = 1;
136
+ if (changed)
137
+ writeJsonAtomic(CURSOR_HOOKS_PATH, settings);
138
+ }
139
+ // ── Windsurf ──────────────────────────────────────────────────────────────────
140
+ const WINDSURF_HOOK_PATHS = [
141
+ path.join(os.homedir(), '.codeium', 'windsurf', 'hooks.json'),
142
+ path.join(os.homedir(), '.codeium', 'hooks.json'),
143
+ ];
144
+ const WINDSURF_EVENTS = ['pre_write_code', 'post_write_code', 'pre_run_command', 'post_run_command'];
145
+ const WINDSURF_CMD = buildHookCommand('windsurf', 'model_name');
146
+ function installWindsurfHooks() {
147
+ if (!fs.existsSync(path.join(os.homedir(), '.codeium')))
148
+ return;
149
+ for (const hookPath of WINDSURF_HOOK_PATHS) {
150
+ if (!fs.existsSync(hookPath) && !fs.existsSync(path.dirname(hookPath)))
151
+ continue;
152
+ const settings = readJson(hookPath);
153
+ if (!settings.hooks)
154
+ settings.hooks = {};
155
+ let changed = false;
156
+ for (const event of WINDSURF_EVENTS) {
157
+ if (!Array.isArray(settings.hooks[event]))
158
+ settings.hooks[event] = [];
159
+ const already = settings.hooks[event].some((h) => typeof h?.command === 'string' && h.command.includes('.omnitype'));
160
+ if (!already) {
161
+ settings.hooks[event].push({ command: WINDSURF_CMD });
162
+ changed = true;
163
+ }
164
+ }
165
+ if (changed)
166
+ writeJsonAtomic(hookPath, settings);
167
+ }
168
+ }
169
+ // ── Codex ─────────────────────────────────────────────────────────────────────
170
+ const CODEX_HOOKS_PATH = path.join(os.homedir(), '.codex', 'hooks.json');
171
+ const CODEX_CMD = buildHookCommand('codex', 'model');
172
+ function installCodexHooks() {
173
+ if (!fs.existsSync(path.join(os.homedir(), '.codex')))
174
+ return;
175
+ const settings = readJson(CODEX_HOOKS_PATH);
176
+ let changed = false;
177
+ for (const event of ['PreToolUse', 'PostToolUse']) {
178
+ if (!Array.isArray(settings[event]))
179
+ settings[event] = [];
180
+ const already = settings[event].some((h) => typeof h?.command === 'string' && h.command.includes('.omnitype'));
181
+ if (!already) {
182
+ settings[event].push({ type: 'command', command: CODEX_CMD });
183
+ changed = true;
184
+ }
185
+ }
186
+ if (changed)
187
+ writeJsonAtomic(CODEX_HOOKS_PATH, settings);
188
+ }
189
+ // ── Cline ─────────────────────────────────────────────────────────────────────
190
+ // Cline looks for executable scripts in ~/Documents/Cline/Hooks/ named after
191
+ // hook events. We drop a PreToolUse script that reads stdin JSON and writes
192
+ // the sentinel only for file-modifying tools.
193
+ const CLINE_HOOKS_DIR = path.join(os.homedir(), 'Documents', 'Cline', 'Hooks');
194
+ const CLINE_HOOK_SCRIPT = `#!/usr/bin/env node
195
+ const FILE_WRITE_TOOLS = new Set(['write_to_file','apply_diff','insert_content','search_and_replace']);
196
+ let buf = '';
197
+ process.stdin.on('data', c => buf += c);
198
+ process.stdin.on('end', () => {
199
+ try {
200
+ const j = JSON.parse(buf);
201
+ if (!FILE_WRITE_TOOLS.has(j.toolName)) return;
202
+ const model = j.model || j.preToolUse?.model || '';
203
+ const fs = require('fs'), p = require('path'), os = require('os');
204
+ const dir = p.join(os.homedir(), '.omnitype');
205
+ fs.mkdirSync(dir, { recursive: true });
206
+ fs.writeFileSync(p.join(dir, 'active-model.json'), JSON.stringify({ model, tool: 'cline', ts: Date.now() }));
207
+ } catch {}
208
+ });
209
+ `;
210
+ function installClineHooks() {
211
+ if (!fs.existsSync(CLINE_HOOKS_DIR))
212
+ return;
213
+ 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);
219
+ }
220
+ }
221
+ // ── Public API ────────────────────────────────────────────────────────────────
222
+ function installAllToolHooks() {
223
+ try {
224
+ installClaudeHooks();
225
+ }
226
+ catch { }
227
+ try {
228
+ installCursorHooks();
229
+ }
230
+ catch { }
231
+ try {
232
+ installWindsurfHooks();
233
+ }
234
+ catch { }
235
+ try {
236
+ installCodexHooks();
237
+ }
238
+ catch { }
239
+ try {
240
+ installClineHooks();
241
+ }
242
+ catch { }
243
+ }