@omnitype-code/cli 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/dist/core/ApiClient.js +15 -0
- package/dist/core/ModelDetector.js +116 -111
- package/dist/core/ToolDetector.js +249 -0
- package/dist/core/ToolHookInstallers.js +703 -0
- package/dist/core/TranscriptScanner.js +380 -0
- package/dist/daemon.js +17 -0
- package/dist/index.js +124 -27
- package/package.json +3 -2
- package/scripts/postinstall.js +94 -0
- package/src/core/ApiClient.ts +15 -0
- package/src/core/ModelDetector.ts +118 -126
- package/src/core/ToolDetector.ts +227 -0
- package/src/core/ToolHookInstallers.ts +580 -0
- package/src/core/TranscriptScanner.ts +320 -0
- package/src/daemon.ts +16 -1
- package/src/index.ts +134 -31
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Runs after `npm install -g @omnitype-code/cli`.
|
|
5
|
+
// Silently installs sentinel hooks into any AI tools already on the machine,
|
|
6
|
+
// then prints a short welcome guide so the user knows what just happened.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
|
|
12
|
+
// ── Silent hook install + telemetry ───────────────────────────────────────
|
|
13
|
+
// We call the compiled output directly so this script works without ts-node.
|
|
14
|
+
let detectedTools = [];
|
|
15
|
+
try {
|
|
16
|
+
const { installAllToolHooks } = require('../dist/core/ToolHookInstallers');
|
|
17
|
+
installAllToolHooks();
|
|
18
|
+
} catch { /* dist not built yet (e.g. dev install) — skip */ }
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const { detectInstalledTools } = require('../dist/core/ToolDetector');
|
|
22
|
+
detectedTools = detectInstalledTools();
|
|
23
|
+
} catch {}
|
|
24
|
+
|
|
25
|
+
// Fire-and-forget telemetry (only if user is already signed in from a prior install)
|
|
26
|
+
try {
|
|
27
|
+
const { ApiClient } = require('../dist/core/ApiClient');
|
|
28
|
+
const api = new ApiClient();
|
|
29
|
+
if (detectedTools.length > 0) {
|
|
30
|
+
api.reportToolEnvironment(detectedTools.map(t => ({ id: t.id, name: t.name, hookSupport: t.hookSupport })));
|
|
31
|
+
}
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
34
|
+
// ── Welcome guide ──────────────────────────────────────────────────────────
|
|
35
|
+
const dim = s => `\x1b[2m${s}\x1b[0m`;
|
|
36
|
+
const bold = s => `\x1b[1m${s}\x1b[0m`;
|
|
37
|
+
const cyan = s => `\x1b[36m${s}\x1b[0m`;
|
|
38
|
+
const green = s => `\x1b[32m${s}\x1b[0m`;
|
|
39
|
+
const gray = s => `\x1b[90m${s}\x1b[0m`;
|
|
40
|
+
|
|
41
|
+
const box = (lines, title) => {
|
|
42
|
+
const width = Math.max(...lines.map(l => stripAnsi(l).length), stripAnsi(title).length) + 4;
|
|
43
|
+
const hr = '─'.repeat(width - 2);
|
|
44
|
+
const pad = s => {
|
|
45
|
+
const visible = stripAnsi(s).length;
|
|
46
|
+
return s + ' '.repeat(width - 2 - visible);
|
|
47
|
+
};
|
|
48
|
+
return [
|
|
49
|
+
`╭${hr}╮`,
|
|
50
|
+
`│ ${bold(cyan(title))}${' '.repeat(width - 2 - stripAnsi(title).length - 1)}│`,
|
|
51
|
+
`├${hr}┤`,
|
|
52
|
+
...lines.map(l => `│ ${pad(l)}│`),
|
|
53
|
+
`╰${hr}╯`,
|
|
54
|
+
].join('\n');
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function stripAnsi(str) {
|
|
58
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const hookedTools = detectedTools.filter(t => t.hookSupport === 'hooked').map(t => t.name);
|
|
62
|
+
const unhookedTools = detectedTools.filter(t => t.hookSupport === 'no-hook').map(t => t.name);
|
|
63
|
+
|
|
64
|
+
const lines = [
|
|
65
|
+
bold(green('✔ OmniType installed!')),
|
|
66
|
+
'',
|
|
67
|
+
bold('What it does:'),
|
|
68
|
+
` Tracks which AI model wrote each line of code across`,
|
|
69
|
+
` any editor — Claude Code, Cursor, Windsurf, Cline, etc.`,
|
|
70
|
+
'',
|
|
71
|
+
bold('Hooks installed for:'),
|
|
72
|
+
hookedTools.length
|
|
73
|
+
? hookedTools.map(t => ` ${green('✔')} ${t}`).join('\n')
|
|
74
|
+
: ` ${gray('No hookable AI tools detected yet.')}`,
|
|
75
|
+
...(unhookedTools.length ? [
|
|
76
|
+
'',
|
|
77
|
+
bold('Also detected (hook support coming):'),
|
|
78
|
+
unhookedTools.map(t => ` ${gray('○')} ${t}`).join('\n'),
|
|
79
|
+
] : []),
|
|
80
|
+
'',
|
|
81
|
+
bold('Next steps:'),
|
|
82
|
+
` ${cyan('omnitype login')} Sign in to sync provenance to cloud`,
|
|
83
|
+
` ${cyan('omnitype doctor')} Check hook status for all tools`,
|
|
84
|
+
` ${cyan('omnitype doctor --fix')} Install/upgrade any missing hooks`,
|
|
85
|
+
` ${cyan('omnitype status')} Show active model in current workspace`,
|
|
86
|
+
'',
|
|
87
|
+
dim('Hooks give deterministic (100% accurate) model attribution.'),
|
|
88
|
+
dim('Without them attribution falls back to heuristics.'),
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// Flatten nested arrays from the tools join
|
|
92
|
+
const flat = lines.flatMap(l => typeof l === 'string' ? [l] : l.split('\n'));
|
|
93
|
+
|
|
94
|
+
console.log('\n' + box(flat, 'OmniType — Code Provenance') + '\n');
|
package/src/core/ApiClient.ts
CHANGED
|
@@ -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++) {
|
|
@@ -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,141 @@ export interface ModelDetectionResult {
|
|
|
22
11
|
}
|
|
23
12
|
|
|
24
13
|
const UNKNOWN: ModelDetectionResult = { model: 'unknown', tool: 'unknown', confidence: 'low' };
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
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
|
+
}
|
|
28
20
|
|
|
29
|
-
|
|
21
|
+
// Matches model identifier strings from all major providers.
|
|
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;
|
|
30
23
|
|
|
31
|
-
//
|
|
24
|
+
// Each tool lists its OWN env vars only. Shared vars (OPENAI_MODEL) are NOT duplicated
|
|
25
|
+
// across tools — the first matching entry wins, so ambiguous vars are assigned to
|
|
26
|
+
// the most common owner (openai). Tools with a dedicated var (CODEX_MODEL) are checked
|
|
27
|
+
// earlier so they can claim edits even when OPENAI_MODEL is also set.
|
|
32
28
|
const ENV_VARS: Array<{ vars: string[]; tool: string }> = [
|
|
33
|
-
{ vars: ['
|
|
29
|
+
{ vars: ['CLAUDE_CODE_MODEL', 'CLAUDE_MODEL', 'ANTHROPIC_MODEL'], tool: 'claude-code' },
|
|
34
30
|
{ vars: ['AIDER_MODEL'], tool: 'aider' },
|
|
31
|
+
{ vars: ['CODEX_MODEL'], tool: 'codex' }, // before generic OPENAI_MODEL
|
|
35
32
|
{ vars: ['OPENAI_MODEL', 'OPENAI_API_MODEL'], tool: 'openai' },
|
|
36
|
-
{ vars: ['GEMINI_MODEL'
|
|
37
|
-
{ vars: ['OLLAMA_MODEL'],
|
|
38
|
-
{ vars: ['COPILOT_MODEL'],
|
|
39
|
-
{ vars: ['LLM_MODEL'],
|
|
40
|
-
{ vars: ['TABBY_MODEL'],
|
|
33
|
+
{ vars: ['GEMINI_MODEL'], tool: 'gemini-cli' },
|
|
34
|
+
{ vars: ['OLLAMA_MODEL'], tool: 'ollama' },
|
|
35
|
+
{ vars: ['COPILOT_MODEL'], tool: 'copilot' },
|
|
36
|
+
{ vars: ['LLM_MODEL'], tool: 'openhands' },
|
|
37
|
+
{ vars: ['TABBY_MODEL'], tool: 'tabby' },
|
|
41
38
|
];
|
|
42
39
|
|
|
43
|
-
const KNOWN_FORKS = [
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
const KNOWN_FORKS = ['Cursor', 'Windsurf', 'PearAI', 'Void', 'Trae', 'Zed', 'Antigravity'];
|
|
41
|
+
const AUTO_SENTINELS = new Set(['auto', 'cursor-auto', 'windsurf-auto', 'default', 'best']);
|
|
42
|
+
|
|
43
|
+
// Host IDE processes: always running — their presence does NOT mean they caused the edit.
|
|
44
|
+
const HOST_IDE_PROCS = new Set(['cursor', 'windsurf', 'antigravity', 'pearai', 'void', 'trae', 'zed']);
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
{ match: '
|
|
50
|
-
{ match: '
|
|
51
|
-
{ match: '
|
|
52
|
-
{ match: '
|
|
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' },
|
|
46
|
+
const PROC_MAP: Array<{ match: string; tool: string }> = [
|
|
47
|
+
{ match: 'claude', tool: 'claude-code' },
|
|
48
|
+
{ match: 'aider', tool: 'aider' },
|
|
49
|
+
{ match: 'goose', tool: 'goose' },
|
|
50
|
+
{ match: 'codex', tool: 'codex' },
|
|
51
|
+
{ match: 'tabby', tool: 'tabby' },
|
|
60
52
|
];
|
|
61
53
|
|
|
62
54
|
export class ModelDetector {
|
|
63
|
-
detect(changedFilePath?: string): ModelDetectionResult {
|
|
55
|
+
detect(changedFilePath?: string, cwd?: string): ModelDetectionResult {
|
|
64
56
|
return (
|
|
65
|
-
this.
|
|
66
|
-
this.
|
|
67
|
-
this._fromIdeConfigs() ??
|
|
57
|
+
this._sentinel(projectSentinelPath(cwd), changedFilePath) ??
|
|
58
|
+
this._sentinel(GLOBAL_SENTINEL_PATH, changedFilePath) ??
|
|
68
59
|
this._fromEnv() ??
|
|
69
|
-
this.
|
|
60
|
+
this._fromTranscripts(cwd) ??
|
|
61
|
+
this._fromIdeConfigs() ??
|
|
62
|
+
this._fromPs() ??
|
|
70
63
|
(changedFilePath ? this._fromLsof(changedFilePath) : undefined) ??
|
|
71
64
|
UNKNOWN
|
|
72
65
|
);
|
|
73
66
|
}
|
|
74
67
|
|
|
75
|
-
private
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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; }
|
|
68
|
+
private _fromTranscripts(cwd?: string): ModelDetectionResult | undefined {
|
|
69
|
+
const r = scanTranscripts(cwd);
|
|
70
|
+
if (!r) return undefined;
|
|
71
|
+
return { model: r.model, tool: r.tool, confidence: 'high' };
|
|
83
72
|
}
|
|
84
73
|
|
|
85
|
-
private
|
|
74
|
+
private _sentinel(filePath: string, changedPath?: string): ModelDetectionResult | undefined {
|
|
86
75
|
try {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
76
|
+
const mtime = fs.statSync(filePath).mtimeMs;
|
|
77
|
+
const d = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
78
|
+
if (!d?.model || d.model === 'unknown') return undefined;
|
|
79
|
+
|
|
80
|
+
if (d.file && changedPath) {
|
|
81
|
+
// File-path gated: trust only if the sentinel targets this exact file.
|
|
82
|
+
// Use a generous TTL since path specificity eliminates cross-file contamination.
|
|
83
|
+
if (d.file !== changedPath) return undefined;
|
|
84
|
+
if (Date.now() - mtime > 120_000) return undefined; // 2 min max
|
|
85
|
+
} else {
|
|
86
|
+
// No path info: fall back to strict TTL to minimise false attribution.
|
|
87
|
+
if (Date.now() - mtime > SENTINEL_MAX_AGE_MS) return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!d?.model || d.model === 'unknown') return undefined;
|
|
91
|
+
return { model: d.model, tool: d.tool ?? 'unknown-tool', confidence: 'deterministic' };
|
|
92
92
|
} catch { return undefined; }
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
private _fromEnv(): ModelDetectionResult | undefined {
|
|
96
|
+
for (const { vars, tool } of ENV_VARS)
|
|
97
|
+
for (const v of vars) { const val = process.env[v]; if (val && MODEL_PATTERN.test(val)) return { model: val, tool, confidence: 'high' }; }
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
95
101
|
private _fromIdeConfigs(): ModelDetectionResult | undefined {
|
|
96
102
|
for (const appName of KNOWN_FORKS) {
|
|
97
103
|
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
|
-
}
|
|
104
|
+
if (scan) return { model: scan.model, tool: appName.toLowerCase(), confidence: scan.isAuto ? 'medium' : 'high' };
|
|
105
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;
|
|
106
109
|
return undefined;
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
private
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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')];
|
|
116
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 {}
|
|
117
136
|
}
|
|
118
137
|
return undefined;
|
|
119
138
|
}
|
|
120
139
|
|
|
121
|
-
private
|
|
140
|
+
private _fromPs(): ModelDetectionResult | undefined {
|
|
122
141
|
if (process.platform === 'win32') return undefined;
|
|
123
142
|
try {
|
|
124
143
|
const lines = execFileSync('ps', ['ax', '-o', 'args='], { timeout: 800, encoding: 'utf8' }).split('\n');
|
|
125
|
-
for (const line of lines)
|
|
126
|
-
for (const
|
|
127
|
-
if (line.toLowerCase().includes(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
} catch { /* ps unavailable */ }
|
|
144
|
+
for (const line of lines)
|
|
145
|
+
for (const { match, tool } of PROC_MAP)
|
|
146
|
+
if (line.toLowerCase().includes(match) && !HOST_IDE_PROCS.has(tool))
|
|
147
|
+
return { model: `${tool}-default`, tool, confidence: 'low' };
|
|
148
|
+
} catch {}
|
|
134
149
|
return undefined;
|
|
135
150
|
}
|
|
136
151
|
|
|
@@ -141,57 +156,36 @@ export class ModelDetector {
|
|
|
141
156
|
for (const line of out.split('\n')) {
|
|
142
157
|
if (!line.startsWith('c')) continue;
|
|
143
158
|
const cmd = line.slice(1).toLowerCase();
|
|
144
|
-
for (const
|
|
145
|
-
if (cmd.includes(
|
|
146
|
-
return { model: `${
|
|
147
|
-
}
|
|
148
|
-
}
|
|
159
|
+
for (const { match, tool } of PROC_MAP)
|
|
160
|
+
if (cmd.includes(match) && !HOST_IDE_PROCS.has(tool))
|
|
161
|
+
return { model: `${tool}-default`, tool, confidence: 'low' };
|
|
149
162
|
}
|
|
150
|
-
} catch {
|
|
163
|
+
} catch {}
|
|
151
164
|
return undefined;
|
|
152
165
|
}
|
|
153
166
|
|
|
154
167
|
private _scanIdeSettings(appName: string): { model: string; isAuto: boolean } | undefined {
|
|
155
|
-
for (const p of this.
|
|
168
|
+
for (const p of this._idePaths(appName)) {
|
|
156
169
|
try {
|
|
157
|
-
const
|
|
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']);
|
|
170
|
+
const flat = this._flatten(JSON.parse(fs.readFileSync(p, 'utf8')));
|
|
162
171
|
const candidates: Array<{ key: string; value: string }> = [];
|
|
163
|
-
|
|
164
172
|
for (const [key, value] of Object.entries(flat)) {
|
|
165
|
-
if (typeof value !== 'string' || !
|
|
166
|
-
if (
|
|
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') };
|
|
173
|
+
if (typeof value !== 'string' || !key.toLowerCase().includes('model')) continue;
|
|
174
|
+
if (AUTO_SENTINELS.has(value.toLowerCase())) { candidates.push({ key, value: `${appName.toLowerCase()}-auto` }); continue; }
|
|
175
|
+
if (MODEL_PATTERN.test(value)) candidates.push({ key, value: value.toLowerCase() });
|
|
184
176
|
}
|
|
185
|
-
|
|
177
|
+
if (!candidates.length) continue;
|
|
178
|
+
const real = candidates.filter(c => !c.value.endsWith('-auto'));
|
|
179
|
+
const pick = (real.length ? real : candidates).sort((a, b) => b.key.length - a.key.length)[0];
|
|
180
|
+
return { model: pick.value, isAuto: pick.value.endsWith('-auto') };
|
|
181
|
+
} catch {}
|
|
186
182
|
}
|
|
187
183
|
return undefined;
|
|
188
184
|
}
|
|
189
185
|
|
|
190
|
-
private
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return dirCandidates.map(dir => {
|
|
186
|
+
private _idePaths(appName: string): string[] {
|
|
187
|
+
const id = appName.toLowerCase().replace(/\s+/g, '-');
|
|
188
|
+
return [...new Set([appName, id])].map(dir => {
|
|
195
189
|
switch (process.platform) {
|
|
196
190
|
case 'darwin': return path.join(os.homedir(), 'Library', 'Application Support', dir, 'User', 'settings.json');
|
|
197
191
|
case 'win32': return path.join(process.env.APPDATA ?? '', dir, 'User', 'settings.json');
|
|
@@ -200,16 +194,14 @@ export class ModelDetector {
|
|
|
200
194
|
});
|
|
201
195
|
}
|
|
202
196
|
|
|
203
|
-
private
|
|
197
|
+
private _flatten(obj: Record<string, unknown>, prefix = '', depth = 0): Record<string, unknown> {
|
|
204
198
|
if (depth > 5) return {};
|
|
205
199
|
const out: Record<string, unknown> = {};
|
|
206
200
|
for (const [k, v] of Object.entries(obj)) {
|
|
207
201
|
const key = prefix ? `${prefix}.${k}` : k;
|
|
208
|
-
if (v !== null && typeof v === 'object' && !Array.isArray(v))
|
|
209
|
-
Object.assign(out, this.
|
|
210
|
-
|
|
211
|
-
out[key] = v;
|
|
212
|
-
}
|
|
202
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v))
|
|
203
|
+
Object.assign(out, this._flatten(v as Record<string, unknown>, key, depth + 1));
|
|
204
|
+
else out[key] = v;
|
|
213
205
|
}
|
|
214
206
|
return out;
|
|
215
207
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects every AI coding tool present on the machine, whether or not OmniType
|
|
3
|
+
* has a hook for it. Used by the doctor command and for market-analysis telemetry.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { execFileSync } from 'child_process';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* hooked — OmniType has a sentinel hook (deterministic attribution)
|
|
13
|
+
* config-only — No hook API, but model is readable from config/extension (high confidence)
|
|
14
|
+
* no-hook — Detected but no support yet
|
|
15
|
+
*/
|
|
16
|
+
export type HookSupport = 'hooked' | 'config-only' | 'no-hook';
|
|
17
|
+
|
|
18
|
+
export interface KnownTool {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
hookSupport: HookSupport;
|
|
22
|
+
/** Returns true if the tool appears to be installed on this machine. */
|
|
23
|
+
detect: () => boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HOME = os.homedir();
|
|
27
|
+
|
|
28
|
+
function exists(...parts: string[]): boolean {
|
|
29
|
+
try { fs.accessSync(path.join(...parts)); return true; } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function inPath(bin: string): boolean {
|
|
33
|
+
try { execFileSync('which', [bin], { timeout: 500, stdio: 'pipe' }); return true; } catch { return false; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function macApp(name: string): boolean {
|
|
37
|
+
if (process.platform !== 'darwin') return false;
|
|
38
|
+
return exists('/Applications', name) || exists(HOME, 'Applications', name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const KNOWN_TOOLS: KnownTool[] = [
|
|
42
|
+
// ── Tools with OmniType hooks ────────────────────────────────────────────
|
|
43
|
+
{
|
|
44
|
+
id: 'claude-code',
|
|
45
|
+
name: 'Claude Code',
|
|
46
|
+
hookSupport: 'hooked',
|
|
47
|
+
detect: () => exists(HOME, '.claude') || inPath('claude'),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'cursor',
|
|
51
|
+
name: 'Cursor',
|
|
52
|
+
hookSupport: 'hooked',
|
|
53
|
+
detect: () => exists(HOME, '.cursor') || macApp('Cursor.app'),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'windsurf',
|
|
57
|
+
name: 'Windsurf',
|
|
58
|
+
hookSupport: 'hooked',
|
|
59
|
+
detect: () => exists(HOME, '.codeium') || macApp('Windsurf.app'),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'codex',
|
|
63
|
+
name: 'Codex CLI',
|
|
64
|
+
hookSupport: 'hooked',
|
|
65
|
+
detect: () => exists(HOME, '.codex') || inPath('codex'),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'cline',
|
|
69
|
+
name: 'Cline',
|
|
70
|
+
hookSupport: 'hooked',
|
|
71
|
+
detect: () => exists(HOME, 'Documents', 'Cline', 'Hooks') || exists(HOME, 'Documents', 'Cline'),
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
id: 'gemini-cli',
|
|
76
|
+
name: 'Gemini CLI',
|
|
77
|
+
hookSupport: 'hooked',
|
|
78
|
+
detect: () => inPath('gemini') || exists(HOME, '.gemini'),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'droid',
|
|
82
|
+
name: 'Droid (Factory)',
|
|
83
|
+
hookSupport: 'hooked',
|
|
84
|
+
detect: () => exists(HOME, '.factory'),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'firebender',
|
|
88
|
+
name: 'Firebender',
|
|
89
|
+
hookSupport: 'hooked',
|
|
90
|
+
detect: () => exists(HOME, '.firebender'),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'amp',
|
|
94
|
+
name: 'Amp',
|
|
95
|
+
hookSupport: 'hooked',
|
|
96
|
+
detect: () => {
|
|
97
|
+
const xdg = process.env.XDG_DATA_HOME ?? path.join(HOME, '.local', 'share');
|
|
98
|
+
return inPath('amp') || exists(xdg, 'amp') ||
|
|
99
|
+
(process.platform === 'win32' && exists(process.env.LOCALAPPDATA ?? '', 'amp'));
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'opencode',
|
|
104
|
+
name: 'OpenCode',
|
|
105
|
+
hookSupport: 'hooked',
|
|
106
|
+
detect: () => exists(HOME, '.config', 'opencode') || inPath('opencode'),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'pi',
|
|
110
|
+
name: 'Pi',
|
|
111
|
+
hookSupport: 'hooked',
|
|
112
|
+
detect: () => exists(HOME, '.pi'),
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// ── Tools detected but no hook yet ──────────────────────────────────────
|
|
116
|
+
{
|
|
117
|
+
id: 'aider',
|
|
118
|
+
name: 'Aider',
|
|
119
|
+
hookSupport: 'no-hook',
|
|
120
|
+
detect: () => inPath('aider') || exists(HOME, '.aider.conf.yml') || exists(HOME, '.aider'),
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'continue',
|
|
124
|
+
name: 'Continue',
|
|
125
|
+
hookSupport: 'no-hook',
|
|
126
|
+
detect: () => exists(HOME, '.continue') || inPath('continue'),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'antigravity',
|
|
130
|
+
name: 'Antigravity',
|
|
131
|
+
hookSupport: 'no-hook',
|
|
132
|
+
detect: () => macApp('Antigravity.app') || macApp('Google Antigravity.app'),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'pearai',
|
|
136
|
+
name: 'PearAI',
|
|
137
|
+
hookSupport: 'no-hook',
|
|
138
|
+
detect: () => macApp('PearAI.app') || exists(HOME, '.pearai'),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'void',
|
|
142
|
+
name: 'Void',
|
|
143
|
+
hookSupport: 'no-hook',
|
|
144
|
+
detect: () => macApp('Void.app') || exists(HOME, '.void'),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'zed',
|
|
148
|
+
name: 'Zed',
|
|
149
|
+
hookSupport: 'no-hook',
|
|
150
|
+
detect: () =>
|
|
151
|
+
macApp('Zed.app') ||
|
|
152
|
+
(process.platform === 'linux' && (inPath('zed') || exists(HOME, '.config', 'zed'))),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'roo-cline',
|
|
156
|
+
name: 'Roo Code',
|
|
157
|
+
hookSupport: 'no-hook',
|
|
158
|
+
detect: () => exists(HOME, '.roo') || exists(HOME, '.roo-cline'),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: 'tabnine',
|
|
162
|
+
name: 'Tabnine',
|
|
163
|
+
hookSupport: 'no-hook',
|
|
164
|
+
detect: () => exists(HOME, '.tabnine') || exists(HOME, '.config', 'tabnine'),
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: 'supermaven',
|
|
168
|
+
name: 'Supermaven',
|
|
169
|
+
hookSupport: 'no-hook',
|
|
170
|
+
detect: () => exists(HOME, '.supermaven'),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 'codeium',
|
|
174
|
+
name: 'Codeium',
|
|
175
|
+
hookSupport: 'no-hook',
|
|
176
|
+
// .codeium overlaps with Windsurf — only count if Windsurf isn't the reason
|
|
177
|
+
detect: () => exists(HOME, '.codeium', 'codeium') || exists(HOME, '.codeium', 'config'),
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: 'copilot',
|
|
181
|
+
name: 'GitHub Copilot',
|
|
182
|
+
// Copilot supports PreToolUse/PostToolUse hooks at ~/.copilot/hooks/
|
|
183
|
+
hookSupport: 'hooked',
|
|
184
|
+
detect: () =>
|
|
185
|
+
exists(HOME, '.copilot') ||
|
|
186
|
+
exists(HOME, '.vscode') ||
|
|
187
|
+
exists(HOME, 'Library', 'Application Support', 'Code') ||
|
|
188
|
+
exists(HOME, '.config', 'github-copilot'),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'trae',
|
|
192
|
+
name: 'Trae',
|
|
193
|
+
hookSupport: 'no-hook',
|
|
194
|
+
detect: () => macApp('Trae.app') || exists(HOME, '.trae'),
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: 'goose',
|
|
198
|
+
name: 'Goose',
|
|
199
|
+
hookSupport: 'no-hook',
|
|
200
|
+
detect: () => inPath('goose') || exists(HOME, '.config', 'goose'),
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: 'openai-codex-legacy',
|
|
204
|
+
name: 'OpenAI Codex (legacy)',
|
|
205
|
+
hookSupport: 'no-hook',
|
|
206
|
+
detect: () => !!process.env.OPENAI_API_KEY && !exists(HOME, '.codex'),
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
export interface DetectedTool {
|
|
211
|
+
tool: KnownTool;
|
|
212
|
+
detected: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Returns all known tools with a detected flag. */
|
|
216
|
+
export function detectAllTools(): DetectedTool[] {
|
|
217
|
+
return KNOWN_TOOLS.map(tool => {
|
|
218
|
+
let detected = false;
|
|
219
|
+
try { detected = tool.detect(); } catch {}
|
|
220
|
+
return { tool, detected };
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Returns only the tools that are actually installed. */
|
|
225
|
+
export function detectInstalledTools(): KnownTool[] {
|
|
226
|
+
return detectAllTools().filter(r => r.detected).map(r => r.tool);
|
|
227
|
+
}
|