@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,201 @@
1
+ "use strict";
2
+ /**
3
+ * TranscriptScanner Tests
4
+ *
5
+ * Tests the JSONL model-extraction logic and directory-scanning behaviour.
6
+ * All file I/O uses tmp directories — real ~/.claude is never touched.
7
+ */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ const fs = __importStar(require("fs"));
43
+ const os = __importStar(require("os"));
44
+ const path = __importStar(require("path"));
45
+ // We import and test the public API plus the cache invalidation helper.
46
+ const TranscriptScanner_1 = require("../core/TranscriptScanner");
47
+ // ── Helpers ───────────────────────────────────────────────────────────────────
48
+ function makeTmp() {
49
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'omnitype-ts-'));
50
+ }
51
+ function rmTmp(dir) {
52
+ fs.rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ /** Write a JSONL file with the given lines and return its path. */
55
+ function writeJsonl(dir, name, lines) {
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ const filePath = path.join(dir, name);
58
+ fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n') + '\n', 'utf8');
59
+ return filePath;
60
+ }
61
+ // ── Tests ─────────────────────────────────────────────────────────────────────
62
+ describe('TranscriptScanner — extractJsonl (via scanTranscripts)', () => {
63
+ let tmp;
64
+ let origEnv;
65
+ beforeEach(() => {
66
+ tmp = makeTmp();
67
+ origEnv = { ...process.env };
68
+ (0, TranscriptScanner_1.invalidateTranscriptCache)();
69
+ // Override CLAUDE_CONFIG_DIR so the scanner looks in our tmp dir
70
+ process.env.CLAUDE_CONFIG_DIR = tmp;
71
+ });
72
+ afterEach(() => {
73
+ process.env = origEnv;
74
+ (0, TranscriptScanner_1.invalidateTranscriptCache)();
75
+ rmTmp(tmp);
76
+ });
77
+ it('reads model from message.model field in JSONL tail', () => {
78
+ const projectDir = path.join(tmp, 'projects', '-tmp-project');
79
+ writeJsonl(projectDir, 'session1.jsonl', [
80
+ { type: 'system', content: 'hello' },
81
+ { type: 'assistant', message: { model: 'claude-sonnet-4-5', role: 'assistant' }, content: '' },
82
+ ]);
83
+ const result = (0, TranscriptScanner_1.scanTranscripts)();
84
+ expect(result).toBeDefined();
85
+ expect(result.model).toBe('claude-sonnet-4-5');
86
+ });
87
+ it('reads model from session.model_change event', () => {
88
+ const projectDir = path.join(tmp, 'projects', '-tmp-model-change');
89
+ writeJsonl(projectDir, 'session2.jsonl', [
90
+ { type: 'system', content: 'start' },
91
+ { type: 'session.model_change', data: { newModel: 'claude-opus-4' } },
92
+ ]);
93
+ (0, TranscriptScanner_1.invalidateTranscriptCache)();
94
+ const result = (0, TranscriptScanner_1.scanTranscripts)();
95
+ expect(result).toBeDefined();
96
+ expect(result.model).toBe('claude-opus-4');
97
+ });
98
+ it('skips <synthetic> model values', () => {
99
+ const projectDir = path.join(tmp, 'projects', '-tmp-synthetic');
100
+ writeJsonl(projectDir, 'session_synth.jsonl', [
101
+ { type: 'assistant', message: { model: '<synthetic>', role: 'assistant' }, content: '' },
102
+ ]);
103
+ (0, TranscriptScanner_1.invalidateTranscriptCache)();
104
+ const result = (0, TranscriptScanner_1.scanTranscripts)();
105
+ // <synthetic> should be skipped — no valid model found
106
+ if (result) {
107
+ expect(result.model).not.toBe('<synthetic>');
108
+ }
109
+ // Result is either undefined or from another tool dir — not the synthetic value
110
+ });
111
+ it('returns undefined for empty JSONL file', () => {
112
+ const projectDir = path.join(tmp, 'projects', '-tmp-empty');
113
+ const filePath = path.join(projectDir, 'empty.jsonl');
114
+ fs.mkdirSync(projectDir, { recursive: true });
115
+ fs.writeFileSync(filePath, '', 'utf8');
116
+ (0, TranscriptScanner_1.invalidateTranscriptCache)();
117
+ const result = (0, TranscriptScanner_1.scanTranscripts)();
118
+ expect(result).toBeUndefined();
119
+ });
120
+ it('returns undefined when no session files exist', () => {
121
+ // tmp has CLAUDE_CONFIG_DIR but no projects dir at all
122
+ (0, TranscriptScanner_1.invalidateTranscriptCache)();
123
+ const result = (0, TranscriptScanner_1.scanTranscripts)();
124
+ expect(result).toBeUndefined();
125
+ });
126
+ it('caches results and does not re-scan within TTL', () => {
127
+ const projectDir = path.join(tmp, 'projects', '-tmp-cache');
128
+ writeJsonl(projectDir, 'sess.jsonl', [
129
+ { type: 'assistant', message: { model: 'claude-haiku-3', role: 'assistant' }, content: '' },
130
+ ]);
131
+ (0, TranscriptScanner_1.invalidateTranscriptCache)();
132
+ const first = (0, TranscriptScanner_1.scanTranscripts)();
133
+ expect(first?.model).toBe('claude-haiku-3');
134
+ // Now add a new file with a different model — but cache should return stale result
135
+ const projectDir2 = path.join(tmp, 'projects', '-tmp-cache2');
136
+ writeJsonl(projectDir2, 'sess2.jsonl', [
137
+ { type: 'assistant', message: { model: 'claude-opus-4', role: 'assistant' }, content: '' },
138
+ ]);
139
+ const second = (0, TranscriptScanner_1.scanTranscripts)(); // still within TTL
140
+ // Cache should return same result (the mtime of the new file might actually be newer
141
+ // in practice due to clock resolution — we verify the cache object is the same reference)
142
+ expect(second).toBe(first);
143
+ });
144
+ it('picks the most recently modified file when multiple sessions exist', async () => {
145
+ const projectDir = path.join(tmp, 'projects', '-tmp-multi');
146
+ fs.mkdirSync(projectDir, { recursive: true });
147
+ // Write older file first
148
+ const older = path.join(projectDir, 'old.jsonl');
149
+ fs.writeFileSync(older, JSON.stringify({ type: 'assistant', message: { model: 'claude-haiku-3' } }) + '\n');
150
+ // Set mtime to 1 second ago
151
+ const oldTime = new Date(Date.now() - 1000);
152
+ fs.utimesSync(older, oldTime, oldTime);
153
+ // Write newer file
154
+ const newer = path.join(projectDir, 'new.jsonl');
155
+ fs.writeFileSync(newer, JSON.stringify({ type: 'assistant', message: { model: 'claude-sonnet-4-5' } }) + '\n');
156
+ (0, TranscriptScanner_1.invalidateTranscriptCache)();
157
+ const result = (0, TranscriptScanner_1.scanTranscripts)();
158
+ expect(result?.model).toBe('claude-sonnet-4-5');
159
+ });
160
+ it('falls back to head scan when file is large and model_change is near start', () => {
161
+ const projectDir = path.join(tmp, 'projects', '-tmp-large');
162
+ fs.mkdirSync(projectDir, { recursive: true });
163
+ const filePath = path.join(projectDir, 'large.jsonl');
164
+ // First line: model_change event
165
+ const firstLine = JSON.stringify({ type: 'session.model_change', data: { newModel: 'claude-opus-4' } }) + '\n';
166
+ // Pad with >50KB of data so the tail read won't include the first line
167
+ const padding = JSON.stringify({ type: 'padding', data: 'x'.repeat(512) }) + '\n';
168
+ let content = firstLine;
169
+ // Add enough padding lines to exceed 51200 bytes
170
+ while (content.length < 55000) {
171
+ content += padding;
172
+ }
173
+ fs.writeFileSync(filePath, content, 'utf8');
174
+ (0, TranscriptScanner_1.invalidateTranscriptCache)();
175
+ const result = (0, TranscriptScanner_1.scanTranscripts)();
176
+ // The head fallback should find the model_change in the first line
177
+ expect(result).toBeDefined();
178
+ expect(result.model).toBe('claude-opus-4');
179
+ });
180
+ it('scopes claude-code scanning to the given cwd', () => {
181
+ // Write sessions for two different projects
182
+ const cwdA = '/fake/project-a';
183
+ const cwdB = '/fake/project-b';
184
+ const keyA = cwdA.replace(/\//g, '-');
185
+ const keyB = cwdB.replace(/\//g, '-');
186
+ const dirA = path.join(tmp, 'projects', keyA);
187
+ const dirB = path.join(tmp, 'projects', keyB);
188
+ writeJsonl(dirA, 'sess.jsonl', [
189
+ { type: 'assistant', message: { model: 'claude-haiku-3' } },
190
+ ]);
191
+ writeJsonl(dirB, 'sess.jsonl', [
192
+ { type: 'assistant', message: { model: 'claude-opus-4' } },
193
+ ]);
194
+ (0, TranscriptScanner_1.invalidateTranscriptCache)();
195
+ const resultA = (0, TranscriptScanner_1.scanTranscripts)(cwdA);
196
+ (0, TranscriptScanner_1.invalidateTranscriptCache)();
197
+ const resultB = (0, TranscriptScanner_1.scanTranscripts)(cwdB);
198
+ expect(resultA?.model).toBe('claude-haiku-3');
199
+ expect(resultB?.model).toBe('claude-opus-4');
200
+ });
201
+ });
@@ -193,6 +193,21 @@ class ApiClient {
193
193
  if (failed.length)
194
194
  throw new Error(`Push failed for chunks: ${failed.join(', ')}`);
195
195
  }
196
+ /**
197
+ * Fire-and-forget: reports which AI tools are detected on this machine.
198
+ * Used for market analysis — understanding which tools users have alongside OmniType.
199
+ * Never throws. Only sends if the user is signed in (respects opt-in via login).
200
+ */
201
+ reportToolEnvironment(tools) {
202
+ if (!this.isSignedIn)
203
+ return;
204
+ const body = { tools, platform: process.platform, ts: Date.now() };
205
+ fetch(`${this.apiUrl}/telemetry/tools`, {
206
+ method: 'POST',
207
+ headers: { 'Authorization': `Bearer ${this.config.token}`, 'Content-Type': 'application/json' },
208
+ body: JSON.stringify(body),
209
+ }).catch(() => { }); // best-effort, never surface errors
210
+ }
196
211
  async _postWithRetry(url, body, attempts, label) {
197
212
  const compressed = await _gzip(Buffer.from(JSON.stringify(body)));
198
213
  for (let i = 0; i < attempts; i++) {
@@ -40,9 +40,11 @@ const path = __importStar(require("path"));
40
40
  const child_process_1 = require("child_process");
41
41
  const TranscriptScanner_1 = require("./TranscriptScanner");
42
42
  const UNKNOWN = { model: 'unknown', tool: 'unknown', confidence: 'low' };
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');
43
+ const SENTINEL_MAX_AGE_MS = 30000;
44
+ const GLOBAL_SENTINEL_PATH = path.join(os.homedir(), '.omnitype', 'active-model.json');
45
+ function projectSentinelPath(cwd) {
46
+ return path.join(cwd ?? process.cwd(), '.omnitype', 'active-model.json');
47
+ }
46
48
  // Matches model identifier strings from all major providers.
47
49
  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
50
  // Each tool lists its OWN env vars only. Shared vars (OPENAI_MODEL) are NOT duplicated
@@ -72,18 +74,18 @@ const PROC_MAP = [
72
74
  { match: 'tabby', tool: 'tabby' },
73
75
  ];
74
76
  class ModelDetector {
75
- detect(changedFilePath) {
76
- return (this._sentinel(UNIVERSAL_PATH, changedFilePath) ??
77
- this._sentinel(HOOKS_PATH, changedFilePath) ??
77
+ detect(changedFilePath, cwd) {
78
+ return (this._sentinel(projectSentinelPath(cwd), changedFilePath) ??
79
+ this._sentinel(GLOBAL_SENTINEL_PATH, changedFilePath) ??
78
80
  this._fromEnv() ??
79
- this._fromTranscripts() ??
81
+ this._fromTranscripts(cwd) ??
80
82
  this._fromIdeConfigs() ??
81
83
  this._fromPs() ??
82
84
  (changedFilePath ? this._fromLsof(changedFilePath) : undefined) ??
83
85
  UNKNOWN);
84
86
  }
85
- _fromTranscripts() {
86
- const r = (0, TranscriptScanner_1.scanTranscripts)();
87
+ _fromTranscripts(cwd) {
88
+ const r = (0, TranscriptScanner_1.scanTranscripts)(cwd);
87
89
  if (!r)
88
90
  return undefined;
89
91
  return { model: r.model, tool: r.tool, confidence: 'high' };
@@ -130,6 +132,38 @@ class ModelDetector {
130
132
  if (scan)
131
133
  return { model: scan.model, tool: appName.toLowerCase(), confidence: scan.isAuto ? 'medium' : 'high' };
132
134
  }
135
+ // Copilot: no hook API, but model is readable from VS Code / fork settings.json
136
+ const copilot = this._fromCopilotConfig();
137
+ if (copilot)
138
+ return copilot;
139
+ return undefined;
140
+ }
141
+ _fromCopilotConfig() {
142
+ const COPILOT_KEYS = [
143
+ 'github.copilot.advanced.model',
144
+ 'github.copilot.selectedModel',
145
+ 'github.copilot.chat.models.default',
146
+ 'github.copilot.chat.agent.model',
147
+ ];
148
+ // Check VS Code and every known fork's settings.json
149
+ const vscodePaths = (() => {
150
+ switch (process.platform) {
151
+ case 'darwin': return [path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'settings.json')];
152
+ case 'win32': return [path.join(process.env.APPDATA ?? '', 'Code', 'User', 'settings.json')];
153
+ default: return [path.join(os.homedir(), '.config', 'Code', 'User', 'settings.json')];
154
+ }
155
+ })();
156
+ for (const settingsPath of vscodePaths) {
157
+ try {
158
+ const flat = this._flatten(JSON.parse(fs.readFileSync(settingsPath, 'utf8')));
159
+ for (const key of COPILOT_KEYS) {
160
+ const val = flat[key];
161
+ if (typeof val === 'string' && val && MODEL_PATTERN.test(val))
162
+ return { model: val.toLowerCase(), tool: 'copilot', confidence: 'high' };
163
+ }
164
+ }
165
+ catch { }
166
+ }
133
167
  return undefined;
134
168
  }
135
169
  _fromPs() {
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+ /**
3
+ * Detects every AI coding tool present on the machine, whether or not OmniType
4
+ * has a hook for it. Used by the doctor command and for market-analysis telemetry.
5
+ */
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.KNOWN_TOOLS = void 0;
41
+ exports.detectAllTools = detectAllTools;
42
+ exports.detectInstalledTools = detectInstalledTools;
43
+ const fs = __importStar(require("fs"));
44
+ const os = __importStar(require("os"));
45
+ const path = __importStar(require("path"));
46
+ const child_process_1 = require("child_process");
47
+ const HOME = os.homedir();
48
+ function exists(...parts) {
49
+ try {
50
+ fs.accessSync(path.join(...parts));
51
+ return true;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ function inPath(bin) {
58
+ try {
59
+ (0, child_process_1.execFileSync)('which', [bin], { timeout: 500, stdio: 'pipe' });
60
+ return true;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ function macApp(name) {
67
+ if (process.platform !== 'darwin')
68
+ return false;
69
+ return exists('/Applications', name) || exists(HOME, 'Applications', name);
70
+ }
71
+ exports.KNOWN_TOOLS = [
72
+ // ── Tools with OmniType hooks ────────────────────────────────────────────
73
+ {
74
+ id: 'claude-code',
75
+ name: 'Claude Code',
76
+ hookSupport: 'hooked',
77
+ detect: () => exists(HOME, '.claude') || inPath('claude'),
78
+ },
79
+ {
80
+ id: 'cursor',
81
+ name: 'Cursor',
82
+ hookSupport: 'hooked',
83
+ detect: () => exists(HOME, '.cursor') || macApp('Cursor.app'),
84
+ },
85
+ {
86
+ id: 'windsurf',
87
+ name: 'Windsurf',
88
+ hookSupport: 'hooked',
89
+ detect: () => exists(HOME, '.codeium') || macApp('Windsurf.app'),
90
+ },
91
+ {
92
+ id: 'codex',
93
+ name: 'Codex CLI',
94
+ hookSupport: 'hooked',
95
+ detect: () => exists(HOME, '.codex') || inPath('codex'),
96
+ },
97
+ {
98
+ id: 'cline',
99
+ name: 'Cline',
100
+ hookSupport: 'hooked',
101
+ detect: () => exists(HOME, 'Documents', 'Cline', 'Hooks') || exists(HOME, 'Documents', 'Cline'),
102
+ },
103
+ {
104
+ id: 'gemini-cli',
105
+ name: 'Gemini CLI',
106
+ hookSupport: 'hooked',
107
+ detect: () => inPath('gemini') || exists(HOME, '.gemini'),
108
+ },
109
+ {
110
+ id: 'droid',
111
+ name: 'Droid (Factory)',
112
+ hookSupport: 'hooked',
113
+ detect: () => exists(HOME, '.factory'),
114
+ },
115
+ {
116
+ id: 'firebender',
117
+ name: 'Firebender',
118
+ hookSupport: 'hooked',
119
+ detect: () => exists(HOME, '.firebender'),
120
+ },
121
+ {
122
+ id: 'amp',
123
+ name: 'Amp',
124
+ hookSupport: 'hooked',
125
+ detect: () => {
126
+ const xdg = process.env.XDG_DATA_HOME ?? path.join(HOME, '.local', 'share');
127
+ return inPath('amp') || exists(xdg, 'amp') ||
128
+ (process.platform === 'win32' && exists(process.env.LOCALAPPDATA ?? '', 'amp'));
129
+ },
130
+ },
131
+ {
132
+ id: 'opencode',
133
+ name: 'OpenCode',
134
+ hookSupport: 'hooked',
135
+ detect: () => exists(HOME, '.config', 'opencode') || inPath('opencode'),
136
+ },
137
+ {
138
+ id: 'pi',
139
+ name: 'Pi',
140
+ hookSupport: 'hooked',
141
+ detect: () => exists(HOME, '.pi'),
142
+ },
143
+ // ── Tools detected but no hook yet ──────────────────────────────────────
144
+ {
145
+ id: 'aider',
146
+ name: 'Aider',
147
+ hookSupport: 'no-hook',
148
+ detect: () => inPath('aider') || exists(HOME, '.aider.conf.yml') || exists(HOME, '.aider'),
149
+ },
150
+ {
151
+ id: 'continue',
152
+ name: 'Continue',
153
+ hookSupport: 'no-hook',
154
+ detect: () => exists(HOME, '.continue') || inPath('continue'),
155
+ },
156
+ {
157
+ id: 'antigravity',
158
+ name: 'Antigravity',
159
+ hookSupport: 'no-hook',
160
+ detect: () => macApp('Antigravity.app') || macApp('Google Antigravity.app'),
161
+ },
162
+ {
163
+ id: 'pearai',
164
+ name: 'PearAI',
165
+ hookSupport: 'no-hook',
166
+ detect: () => macApp('PearAI.app') || exists(HOME, '.pearai'),
167
+ },
168
+ {
169
+ id: 'void',
170
+ name: 'Void',
171
+ hookSupport: 'no-hook',
172
+ detect: () => macApp('Void.app') || exists(HOME, '.void'),
173
+ },
174
+ {
175
+ id: 'zed',
176
+ name: 'Zed',
177
+ hookSupport: 'no-hook',
178
+ detect: () => macApp('Zed.app') ||
179
+ (process.platform === 'linux' && (inPath('zed') || exists(HOME, '.config', 'zed'))),
180
+ },
181
+ {
182
+ id: 'roo-cline',
183
+ name: 'Roo Code',
184
+ hookSupport: 'no-hook',
185
+ detect: () => exists(HOME, '.roo') || exists(HOME, '.roo-cline'),
186
+ },
187
+ {
188
+ id: 'tabnine',
189
+ name: 'Tabnine',
190
+ hookSupport: 'no-hook',
191
+ detect: () => exists(HOME, '.tabnine') || exists(HOME, '.config', 'tabnine'),
192
+ },
193
+ {
194
+ id: 'supermaven',
195
+ name: 'Supermaven',
196
+ hookSupport: 'no-hook',
197
+ detect: () => exists(HOME, '.supermaven'),
198
+ },
199
+ {
200
+ id: 'codeium',
201
+ name: 'Codeium',
202
+ hookSupport: 'no-hook',
203
+ // .codeium overlaps with Windsurf — only count if Windsurf isn't the reason
204
+ detect: () => exists(HOME, '.codeium', 'codeium') || exists(HOME, '.codeium', 'config'),
205
+ },
206
+ {
207
+ id: 'copilot',
208
+ name: 'GitHub Copilot',
209
+ // Copilot supports PreToolUse/PostToolUse hooks at ~/.copilot/hooks/
210
+ hookSupport: 'hooked',
211
+ detect: () => exists(HOME, '.copilot') ||
212
+ exists(HOME, '.vscode') ||
213
+ exists(HOME, 'Library', 'Application Support', 'Code') ||
214
+ exists(HOME, '.config', 'github-copilot'),
215
+ },
216
+ {
217
+ id: 'trae',
218
+ name: 'Trae',
219
+ hookSupport: 'no-hook',
220
+ detect: () => macApp('Trae.app') || exists(HOME, '.trae'),
221
+ },
222
+ {
223
+ id: 'goose',
224
+ name: 'Goose',
225
+ hookSupport: 'no-hook',
226
+ detect: () => inPath('goose') || exists(HOME, '.config', 'goose'),
227
+ },
228
+ {
229
+ id: 'openai-codex-legacy',
230
+ name: 'OpenAI Codex (legacy)',
231
+ hookSupport: 'no-hook',
232
+ detect: () => !!process.env.OPENAI_API_KEY && !exists(HOME, '.codex'),
233
+ },
234
+ ];
235
+ /** Returns all known tools with a detected flag. */
236
+ function detectAllTools() {
237
+ return exports.KNOWN_TOOLS.map(tool => {
238
+ let detected = false;
239
+ try {
240
+ detected = tool.detect();
241
+ }
242
+ catch { }
243
+ return { tool, detected };
244
+ });
245
+ }
246
+ /** Returns only the tools that are actually installed. */
247
+ function detectInstalledTools() {
248
+ return detectAllTools().filter(r => r.detected).map(r => r.tool);
249
+ }