@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
@@ -0,0 +1,237 @@
1
+ /**
2
+ * ToolHookInstallers coverage tests — targets uncovered tool installers:
3
+ * Windsurf, Codex, Gemini, Droid, Firebender, Cline, Copilot, Amp, OpenCode, Pi
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as os from 'os';
8
+ import * as path from 'path';
9
+
10
+ import {
11
+ installAllToolHooks,
12
+ checkHookStatus,
13
+ HOOK_VERSION,
14
+ } from '../core/ToolHookInstallers';
15
+
16
+ const HOME = os.homedir();
17
+
18
+ function makeScratchDir(name: string): string {
19
+ const d = path.join(HOME, `.omnitype-cov-test-${name}-${Date.now()}`);
20
+ fs.mkdirSync(d, { recursive: true });
21
+ return d;
22
+ }
23
+ function rmDir(d: string) { fs.rmSync(d, { recursive: true, force: true }); }
24
+ function readJson(p: string) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; } }
25
+
26
+ // ── Universal hook command content ────────────────────────────────────────────
27
+
28
+ describe('ToolHookInstallers — universal hook command (all tools)', () => {
29
+ // Install into a scratch ~/.claude and read the command back
30
+ let cmd: string;
31
+ let scratchClaude: string;
32
+ const realClaude = path.join(HOME, '.claude');
33
+ const backup = path.join(HOME, `.claude-backup-cov-${Date.now()}`);
34
+ let hadReal = false;
35
+
36
+ beforeAll(() => {
37
+ scratchClaude = makeScratchDir('claude');
38
+ if (fs.existsSync(realClaude)) { fs.renameSync(realClaude, backup); hadReal = true; }
39
+ fs.renameSync(scratchClaude, realClaude);
40
+ installAllToolHooks();
41
+ const s = readJson(path.join(realClaude, 'settings.json'));
42
+ cmd = s?.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command ?? '';
43
+ rmDir(realClaude);
44
+ if (hadReal) fs.renameSync(backup, realClaude);
45
+ });
46
+
47
+ it('tries j.model (Cursor/Codex field)', () => {
48
+ expect(cmd).toContain('j.model');
49
+ });
50
+ it('tries j.model_name (Windsurf field)', () => {
51
+ expect(cmd).toContain('j.model_name');
52
+ });
53
+ it('tries j.modelName (camelCase variant)', () => {
54
+ expect(cmd).toContain('j.modelName');
55
+ });
56
+ it('tries j.modelID', () => {
57
+ expect(cmd).toContain('j.modelID');
58
+ });
59
+ it('tries j.data.model (nested variant)', () => {
60
+ expect(cmd).toContain('j?.data?.model');
61
+ });
62
+ it('falls back to transcript_path JSONL', () => {
63
+ expect(cmd).toContain('j.transcript_path');
64
+ expect(cmd).toContain('extractModelFromJsonl');
65
+ });
66
+ it('falls back to env var last', () => {
67
+ expect(cmd).toContain('CLAUDE_MODEL');
68
+ expect(cmd).toContain('ANTHROPIC_MODEL');
69
+ });
70
+ });
71
+
72
+ // ── Windsurf installer ────────────────────────────────────────────────────────
73
+
74
+ describe('ToolHookInstallers — Windsurf', () => {
75
+ let scratchCodium: string;
76
+ const realCodium = path.join(HOME, '.codeium');
77
+ const backup = path.join(HOME, `.codeium-backup-${Date.now()}`);
78
+ let hadReal = false;
79
+
80
+ beforeEach(() => {
81
+ scratchCodium = makeScratchDir('codeium');
82
+ if (fs.existsSync(realCodium)) { fs.renameSync(realCodium, backup); hadReal = true; }
83
+ fs.renameSync(scratchCodium, realCodium);
84
+ });
85
+
86
+ afterEach(() => {
87
+ rmDir(realCodium);
88
+ if (hadReal) { fs.renameSync(backup, realCodium); hadReal = false; }
89
+ });
90
+
91
+ it('installs hook into windsurf/hooks.json', () => {
92
+ // The installer skips if neither hookPath nor its parent dir exists.
93
+ // Create the parent dir so it proceeds.
94
+ const windsurfDir = path.join(realCodium, 'windsurf');
95
+ fs.mkdirSync(windsurfDir, { recursive: true });
96
+ installAllToolHooks();
97
+ const hooks = readJson(path.join(windsurfDir, 'hooks.json'));
98
+ const cmds: string[] = (hooks?.hooks?.pre_write_code ?? []).map((h: any) => h?.command ?? '');
99
+ expect(cmds.some(c => c.includes(HOOK_VERSION))).toBe(true);
100
+ });
101
+
102
+ it('checkHookStatus returns installed for windsurf', () => {
103
+ installAllToolHooks();
104
+ const statuses = checkHookStatus();
105
+ const ws = statuses.find(s => s.tool === 'windsurf');
106
+ expect(ws?.status).toBe('installed');
107
+ });
108
+
109
+ it('checkHookStatus returns stale when old hook present', () => {
110
+ const hooksDir = path.join(realCodium, 'windsurf');
111
+ fs.mkdirSync(hooksDir, { recursive: true });
112
+ fs.writeFileSync(path.join(hooksDir, 'hooks.json'), JSON.stringify({
113
+ hooks: { pre_write_code: [{ command: 'node -e "/*omnitype-hook-v3*/.omnitype"' }] }
114
+ }));
115
+ const statuses = checkHookStatus();
116
+ const ws = statuses.find(s => s.tool === 'windsurf');
117
+ expect(ws?.status).toBe('stale');
118
+ });
119
+ });
120
+
121
+ // ── Gemini CLI installer ──────────────────────────────────────────────────────
122
+
123
+ describe('ToolHookInstallers — Gemini CLI', () => {
124
+ let scratchGemini: string;
125
+ const realGemini = path.join(HOME, '.gemini');
126
+ const backup = path.join(HOME, `.gemini-backup-${Date.now()}`);
127
+ let hadReal = false;
128
+
129
+ beforeEach(() => {
130
+ scratchGemini = makeScratchDir('gemini');
131
+ if (fs.existsSync(realGemini)) { fs.renameSync(realGemini, backup); hadReal = true; }
132
+ fs.renameSync(scratchGemini, realGemini);
133
+ });
134
+
135
+ afterEach(() => {
136
+ rmDir(realGemini);
137
+ if (hadReal) { fs.renameSync(backup, realGemini); hadReal = false; }
138
+ });
139
+
140
+ it('installs hook into gemini settings.json BeforeTool', () => {
141
+ installAllToolHooks();
142
+ const s = readJson(path.join(realGemini, 'settings.json'));
143
+ const cmds: string[] = (s?.BeforeTool ?? []).flatMap((e: any) => e?.hooks?.map((h: any) => h?.command ?? '') ?? []);
144
+ expect(cmds.some(c => c.includes(HOOK_VERSION))).toBe(true);
145
+ });
146
+
147
+ it('checkHookStatus returns installed for gemini-cli', () => {
148
+ installAllToolHooks();
149
+ const statuses = checkHookStatus();
150
+ const g = statuses.find(s => s.tool === 'gemini-cli');
151
+ expect(g?.status).toBe('installed');
152
+ });
153
+ });
154
+
155
+ // ── Codex installer ───────────────────────────────────────────────────────────
156
+
157
+ describe('ToolHookInstallers — Codex', () => {
158
+ let scratchCodex: string;
159
+ const realCodex = path.join(HOME, '.codex');
160
+ const backup = path.join(HOME, `.codex-backup-${Date.now()}`);
161
+ let hadReal = false;
162
+
163
+ beforeEach(() => {
164
+ scratchCodex = makeScratchDir('codex');
165
+ if (fs.existsSync(realCodex)) { fs.renameSync(realCodex, backup); hadReal = true; }
166
+ fs.renameSync(scratchCodex, realCodex);
167
+ });
168
+
169
+ afterEach(() => {
170
+ rmDir(realCodex);
171
+ if (hadReal) { fs.renameSync(backup, realCodex); hadReal = false; }
172
+ });
173
+
174
+ it('installs hook into codex hooks.json PreToolUse', () => {
175
+ installAllToolHooks();
176
+ const s = readJson(path.join(realCodex, 'hooks.json'));
177
+ const cmds: string[] = (s?.PreToolUse ?? []).map((h: any) => h?.command ?? '');
178
+ expect(cmds.some(c => c.includes(HOOK_VERSION))).toBe(true);
179
+ });
180
+
181
+ it('checkHookStatus returns installed for codex', () => {
182
+ installAllToolHooks();
183
+ const statuses = checkHookStatus();
184
+ const c = statuses.find(s => s.tool === 'codex');
185
+ expect(c?.status).toBe('installed');
186
+ });
187
+ });
188
+
189
+ // ── Cline installer ───────────────────────────────────────────────────────────
190
+
191
+ describe('ToolHookInstallers — Cline hook script content', () => {
192
+ it('Cline script contains version token', () => {
193
+ // The CLINE_HOOK_SCRIPT is embedded — verify via installing if dir exists
194
+ // otherwise just verify the constant is referenced in the module
195
+ const clineDir = path.join(HOME, 'Documents', 'Cline', 'Hooks');
196
+ if (!fs.existsSync(clineDir)) {
197
+ // Can't install, but we can verify the exported status
198
+ const statuses = checkHookStatus();
199
+ const c = statuses.find(s => s.tool === 'cline');
200
+ expect(c?.status).toBe('tool-absent');
201
+ return;
202
+ }
203
+ installAllToolHooks();
204
+ const script = fs.readFileSync(path.join(clineDir, 'PreToolUse'), 'utf8');
205
+ expect(script).toContain(HOOK_VERSION);
206
+ expect(script).toContain('extractModelFromJsonl');
207
+ expect(script).toContain('session.model_change');
208
+ });
209
+ });
210
+
211
+ // ── installAllToolHooks — silent on missing dirs ──────────────────────────────
212
+
213
+ describe('ToolHookInstallers — installAllToolHooks resilience', () => {
214
+ it('does not throw even when all tool dirs are absent', () => {
215
+ // All real tool dirs may or may not exist — the function is always silent
216
+ expect(() => installAllToolHooks()).not.toThrow();
217
+ });
218
+
219
+ it('checkHookStatus returns array with entries for every tracked tool', () => {
220
+ const statuses = checkHookStatus();
221
+ const tools = statuses.map(s => s.tool);
222
+ expect(tools).toContain('claude-code');
223
+ expect(tools).toContain('cursor');
224
+ expect(tools).toContain('windsurf');
225
+ expect(tools).toContain('codex');
226
+ expect(tools).toContain('cline');
227
+ expect(tools).toContain('gemini-cli');
228
+ });
229
+
230
+ it('every status entry has valid status value', () => {
231
+ const valid = new Set(['installed', 'stale', 'not-installed', 'tool-absent']);
232
+ const statuses = checkHookStatus();
233
+ for (const s of statuses) {
234
+ expect(valid.has(s.status)).toBe(true);
235
+ }
236
+ });
237
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * TranscriptScanner Tests
3
+ *
4
+ * Tests the JSONL model-extraction logic and directory-scanning behaviour.
5
+ * All file I/O uses tmp directories — real ~/.claude is never touched.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as os from 'os';
10
+ import * as path from 'path';
11
+
12
+ // We import and test the public API plus the cache invalidation helper.
13
+ import { scanTranscripts, invalidateTranscriptCache } from '../core/TranscriptScanner';
14
+
15
+ // ── Helpers ───────────────────────────────────────────────────────────────────
16
+
17
+ function makeTmp(): string {
18
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'omnitype-ts-'));
19
+ }
20
+
21
+ function rmTmp(dir: string): void {
22
+ fs.rmSync(dir, { recursive: true, force: true });
23
+ }
24
+
25
+ /** Write a JSONL file with the given lines and return its path. */
26
+ function writeJsonl(dir: string, name: string, lines: object[]): string {
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ const filePath = path.join(dir, name);
29
+ fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n') + '\n', 'utf8');
30
+ return filePath;
31
+ }
32
+
33
+ // ── Tests ─────────────────────────────────────────────────────────────────────
34
+
35
+ describe('TranscriptScanner — extractJsonl (via scanTranscripts)', () => {
36
+ let tmp: string;
37
+ let origEnv: NodeJS.ProcessEnv;
38
+
39
+ beforeEach(() => {
40
+ tmp = makeTmp();
41
+ origEnv = { ...process.env };
42
+ invalidateTranscriptCache();
43
+ // Override CLAUDE_CONFIG_DIR so the scanner looks in our tmp dir
44
+ process.env.CLAUDE_CONFIG_DIR = tmp;
45
+ });
46
+
47
+ afterEach(() => {
48
+ process.env = origEnv;
49
+ invalidateTranscriptCache();
50
+ rmTmp(tmp);
51
+ });
52
+
53
+ it('reads model from message.model field in JSONL tail', () => {
54
+ const projectDir = path.join(tmp, 'projects', '-tmp-project');
55
+ writeJsonl(projectDir, 'session1.jsonl', [
56
+ { type: 'system', content: 'hello' },
57
+ { type: 'assistant', message: { model: 'claude-sonnet-4-5', role: 'assistant' }, content: '' },
58
+ ]);
59
+
60
+ const result = scanTranscripts();
61
+ expect(result).toBeDefined();
62
+ expect(result!.model).toBe('claude-sonnet-4-5');
63
+ });
64
+
65
+ it('reads model from session.model_change event', () => {
66
+ const projectDir = path.join(tmp, 'projects', '-tmp-model-change');
67
+ writeJsonl(projectDir, 'session2.jsonl', [
68
+ { type: 'system', content: 'start' },
69
+ { type: 'session.model_change', data: { newModel: 'claude-opus-4' } },
70
+ ]);
71
+
72
+ invalidateTranscriptCache();
73
+ const result = scanTranscripts();
74
+ expect(result).toBeDefined();
75
+ expect(result!.model).toBe('claude-opus-4');
76
+ });
77
+
78
+ it('skips <synthetic> model values', () => {
79
+ const projectDir = path.join(tmp, 'projects', '-tmp-synthetic');
80
+ writeJsonl(projectDir, 'session_synth.jsonl', [
81
+ { type: 'assistant', message: { model: '<synthetic>', role: 'assistant' }, content: '' },
82
+ ]);
83
+
84
+ invalidateTranscriptCache();
85
+ const result = scanTranscripts();
86
+ // <synthetic> should be skipped — no valid model found
87
+ if (result) {
88
+ expect(result.model).not.toBe('<synthetic>');
89
+ }
90
+ // Result is either undefined or from another tool dir — not the synthetic value
91
+ });
92
+
93
+ it('returns undefined for empty JSONL file', () => {
94
+ const projectDir = path.join(tmp, 'projects', '-tmp-empty');
95
+ const filePath = path.join(projectDir, 'empty.jsonl');
96
+ fs.mkdirSync(projectDir, { recursive: true });
97
+ fs.writeFileSync(filePath, '', 'utf8');
98
+
99
+ invalidateTranscriptCache();
100
+ const result = scanTranscripts();
101
+ expect(result).toBeUndefined();
102
+ });
103
+
104
+ it('returns undefined when no session files exist', () => {
105
+ // tmp has CLAUDE_CONFIG_DIR but no projects dir at all
106
+ invalidateTranscriptCache();
107
+ const result = scanTranscripts();
108
+ expect(result).toBeUndefined();
109
+ });
110
+
111
+ it('caches results and does not re-scan within TTL', () => {
112
+ const projectDir = path.join(tmp, 'projects', '-tmp-cache');
113
+ writeJsonl(projectDir, 'sess.jsonl', [
114
+ { type: 'assistant', message: { model: 'claude-haiku-3', role: 'assistant' }, content: '' },
115
+ ]);
116
+
117
+ invalidateTranscriptCache();
118
+ const first = scanTranscripts();
119
+ expect(first?.model).toBe('claude-haiku-3');
120
+
121
+ // Now add a new file with a different model — but cache should return stale result
122
+ const projectDir2 = path.join(tmp, 'projects', '-tmp-cache2');
123
+ writeJsonl(projectDir2, 'sess2.jsonl', [
124
+ { type: 'assistant', message: { model: 'claude-opus-4', role: 'assistant' }, content: '' },
125
+ ]);
126
+
127
+ const second = scanTranscripts(); // still within TTL
128
+ // Cache should return same result (the mtime of the new file might actually be newer
129
+ // in practice due to clock resolution — we verify the cache object is the same reference)
130
+ expect(second).toBe(first);
131
+ });
132
+
133
+ it('picks the most recently modified file when multiple sessions exist', async () => {
134
+ const projectDir = path.join(tmp, 'projects', '-tmp-multi');
135
+ fs.mkdirSync(projectDir, { recursive: true });
136
+
137
+ // Write older file first
138
+ const older = path.join(projectDir, 'old.jsonl');
139
+ fs.writeFileSync(older, JSON.stringify({ type: 'assistant', message: { model: 'claude-haiku-3' } }) + '\n');
140
+ // Set mtime to 1 second ago
141
+ const oldTime = new Date(Date.now() - 1000);
142
+ fs.utimesSync(older, oldTime, oldTime);
143
+
144
+ // Write newer file
145
+ const newer = path.join(projectDir, 'new.jsonl');
146
+ fs.writeFileSync(newer, JSON.stringify({ type: 'assistant', message: { model: 'claude-sonnet-4-5' } }) + '\n');
147
+
148
+ invalidateTranscriptCache();
149
+ const result = scanTranscripts();
150
+ expect(result?.model).toBe('claude-sonnet-4-5');
151
+ });
152
+
153
+ it('falls back to head scan when file is large and model_change is near start', () => {
154
+ const projectDir = path.join(tmp, 'projects', '-tmp-large');
155
+ fs.mkdirSync(projectDir, { recursive: true });
156
+ const filePath = path.join(projectDir, 'large.jsonl');
157
+
158
+ // First line: model_change event
159
+ const firstLine = JSON.stringify({ type: 'session.model_change', data: { newModel: 'claude-opus-4' } }) + '\n';
160
+ // Pad with >50KB of data so the tail read won't include the first line
161
+ const padding = JSON.stringify({ type: 'padding', data: 'x'.repeat(512) }) + '\n';
162
+ let content = firstLine;
163
+ // Add enough padding lines to exceed 51200 bytes
164
+ while (content.length < 55000) {
165
+ content += padding;
166
+ }
167
+ fs.writeFileSync(filePath, content, 'utf8');
168
+
169
+ invalidateTranscriptCache();
170
+ const result = scanTranscripts();
171
+ // The head fallback should find the model_change in the first line
172
+ expect(result).toBeDefined();
173
+ expect(result!.model).toBe('claude-opus-4');
174
+ });
175
+
176
+ it('scopes claude-code scanning to the given cwd', () => {
177
+ // Write sessions for two different projects
178
+ const cwdA = '/fake/project-a';
179
+ const cwdB = '/fake/project-b';
180
+ const keyA = cwdA.replace(/\//g, '-');
181
+ const keyB = cwdB.replace(/\//g, '-');
182
+
183
+ const dirA = path.join(tmp, 'projects', keyA);
184
+ const dirB = path.join(tmp, 'projects', keyB);
185
+
186
+ writeJsonl(dirA, 'sess.jsonl', [
187
+ { type: 'assistant', message: { model: 'claude-haiku-3' } },
188
+ ]);
189
+ writeJsonl(dirB, 'sess.jsonl', [
190
+ { type: 'assistant', message: { model: 'claude-opus-4' } },
191
+ ]);
192
+
193
+ invalidateTranscriptCache();
194
+ const resultA = scanTranscripts(cwdA);
195
+ invalidateTranscriptCache();
196
+ const resultB = scanTranscripts(cwdB);
197
+
198
+ expect(resultA?.model).toBe('claude-haiku-3');
199
+ expect(resultB?.model).toBe('claude-opus-4');
200
+ });
201
+ });
@@ -156,6 +156,21 @@ export class ApiClient {
156
156
  if (failed.length) throw new Error(`Push failed for chunks: ${failed.join(', ')}`);
157
157
  }
158
158
 
159
+ /**
160
+ * Fire-and-forget: reports which AI tools are detected on this machine.
161
+ * Used for market analysis — understanding which tools users have alongside OmniType.
162
+ * Never throws. Only sends if the user is signed in (respects opt-in via login).
163
+ */
164
+ reportToolEnvironment(tools: Array<{ id: string; name: string; hookSupport: string }>): void {
165
+ if (!this.isSignedIn) return;
166
+ const body = { tools, platform: process.platform, ts: Date.now() };
167
+ fetch(`${this.apiUrl}/telemetry/tools`, {
168
+ method: 'POST',
169
+ headers: { 'Authorization': `Bearer ${this.config.token}`, 'Content-Type': 'application/json' },
170
+ body: JSON.stringify(body),
171
+ }).catch(() => {}); // best-effort, never surface errors
172
+ }
173
+
159
174
  private async _postWithRetry(url: string, body: unknown, attempts: number, label: string): Promise<void> {
160
175
  const compressed = await _gzip(Buffer.from(JSON.stringify(body)));
161
176
  for (let i = 0; i < attempts; i++) {
@@ -11,9 +11,12 @@ export interface ModelDetectionResult {
11
11
  }
12
12
 
13
13
  const UNKNOWN: ModelDetectionResult = { model: 'unknown', tool: 'unknown', confidence: 'low' };
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');
14
+ const SENTINEL_MAX_AGE_MS = 30_000;
15
+ const GLOBAL_SENTINEL_PATH = path.join(os.homedir(), '.omnitype', 'active-model.json');
16
+
17
+ function projectSentinelPath(cwd?: string): string {
18
+ return path.join(cwd ?? process.cwd(), '.omnitype', 'active-model.json');
19
+ }
17
20
 
18
21
  // Matches model identifier strings from all major providers.
19
22
  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;
@@ -49,12 +52,12 @@ const PROC_MAP: Array<{ match: string; tool: string }> = [
49
52
  ];
50
53
 
51
54
  export class ModelDetector {
52
- detect(changedFilePath?: string): ModelDetectionResult {
55
+ detect(changedFilePath?: string, cwd?: string): ModelDetectionResult {
53
56
  return (
54
- this._sentinel(UNIVERSAL_PATH, changedFilePath) ??
55
- this._sentinel(HOOKS_PATH, changedFilePath) ??
57
+ this._sentinel(projectSentinelPath(cwd), changedFilePath) ??
58
+ this._sentinel(GLOBAL_SENTINEL_PATH, changedFilePath) ??
56
59
  this._fromEnv() ??
57
- this._fromTranscripts() ??
60
+ this._fromTranscripts(cwd) ??
58
61
  this._fromIdeConfigs() ??
59
62
  this._fromPs() ??
60
63
  (changedFilePath ? this._fromLsof(changedFilePath) : undefined) ??
@@ -62,8 +65,8 @@ export class ModelDetector {
62
65
  );
63
66
  }
64
67
 
65
- private _fromTranscripts(): ModelDetectionResult | undefined {
66
- const r = scanTranscripts();
68
+ private _fromTranscripts(cwd?: string): ModelDetectionResult | undefined {
69
+ const r = scanTranscripts(cwd);
67
70
  if (!r) return undefined;
68
71
  return { model: r.model, tool: r.tool, confidence: 'high' };
69
72
  }
@@ -100,6 +103,37 @@ export class ModelDetector {
100
103
  const scan = this._scanIdeSettings(appName);
101
104
  if (scan) return { model: scan.model, tool: appName.toLowerCase(), confidence: scan.isAuto ? 'medium' : 'high' };
102
105
  }
106
+ // Copilot: no hook API, but model is readable from VS Code / fork settings.json
107
+ const copilot = this._fromCopilotConfig();
108
+ if (copilot) return copilot;
109
+ return undefined;
110
+ }
111
+
112
+ private _fromCopilotConfig(): ModelDetectionResult | undefined {
113
+ const COPILOT_KEYS = [
114
+ 'github.copilot.advanced.model',
115
+ 'github.copilot.selectedModel',
116
+ 'github.copilot.chat.models.default',
117
+ 'github.copilot.chat.agent.model',
118
+ ];
119
+ // Check VS Code and every known fork's settings.json
120
+ const vscodePaths = (() => {
121
+ switch (process.platform) {
122
+ case 'darwin': return [path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'settings.json')];
123
+ case 'win32': return [path.join(process.env.APPDATA ?? '', 'Code', 'User', 'settings.json')];
124
+ default: return [path.join(os.homedir(), '.config', 'Code', 'User', 'settings.json')];
125
+ }
126
+ })();
127
+ for (const settingsPath of vscodePaths) {
128
+ try {
129
+ const flat = this._flatten(JSON.parse(fs.readFileSync(settingsPath, 'utf8')));
130
+ for (const key of COPILOT_KEYS) {
131
+ const val = flat[key];
132
+ if (typeof val === 'string' && val && MODEL_PATTERN.test(val))
133
+ return { model: val.toLowerCase(), tool: 'copilot', confidence: 'high' };
134
+ }
135
+ } catch {}
136
+ }
103
137
  return undefined;
104
138
  }
105
139