@omnitype-code/cli 0.1.0

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.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Heartbeat coordination between the VS Code extension and the CLI daemon.
3
+ *
4
+ * The extension writes ~/.omnitype/vscode-heartbeat.json periodically.
5
+ * The daemon reads it before pushing to decide whether to yield.
6
+ *
7
+ * Schema: { pid: number, workspacePaths: string[], ts: number }
8
+ */
9
+
10
+ import * as fs from 'fs';
11
+ import * as os from 'os';
12
+ import * as path from 'path';
13
+
14
+ const HEARTBEAT_PATH = path.join(os.homedir(), '.omnitype', 'vscode-heartbeat.json');
15
+ const YIELD_WINDOW_MS = 30_000; // daemon yields if extension was seen within 30s
16
+
17
+ interface HeartbeatData {
18
+ pid: number;
19
+ workspacePaths: string[];
20
+ ts: number;
21
+ }
22
+
23
+ export function readExtensionHeartbeat(): HeartbeatData | null {
24
+ try {
25
+ const raw = fs.readFileSync(HEARTBEAT_PATH, 'utf8');
26
+ return JSON.parse(raw) as HeartbeatData;
27
+ } catch { return null; }
28
+ }
29
+
30
+ /**
31
+ * Returns true if the VS Code extension is actively tracking the given workspace.
32
+ * When true, the daemon should skip pushing for files in that workspace.
33
+ */
34
+ export function extensionIsActiveFor(workspacePath: string): boolean {
35
+ const hb = readExtensionHeartbeat();
36
+ if (!hb) return false;
37
+ if (Date.now() - hb.ts > YIELD_WINDOW_MS) return false;
38
+ return hb.workspacePaths.some(p =>
39
+ workspacePath.startsWith(p) || p.startsWith(workspacePath)
40
+ );
41
+ }
42
+
43
+ /** Write our own daemon heartbeat so the extension can also yield to us if needed. */
44
+ export function writeDaemonHeartbeat(watchPath: string): void {
45
+ try {
46
+ const data = { pid: process.pid, watchPath, ts: Date.now() };
47
+ fs.mkdirSync(path.dirname(HEARTBEAT_PATH), { recursive: true });
48
+ fs.writeFileSync(
49
+ path.join(os.homedir(), '.omnitype', 'daemon-heartbeat.json'),
50
+ JSON.stringify(data),
51
+ );
52
+ } catch { /* non-fatal */ }
53
+ }
@@ -0,0 +1,216 @@
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
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+ import { execFileSync } from 'child_process';
17
+
18
+ export interface ModelDetectionResult {
19
+ model: string;
20
+ tool: string;
21
+ confidence: 'deterministic' | 'high' | 'medium' | 'low';
22
+ }
23
+
24
+ const UNKNOWN: ModelDetectionResult = { model: 'unknown', tool: 'unknown', confidence: 'low' };
25
+ const HOOKS_SENTINEL_PATH = path.join(os.homedir(), '.claude', 'provenance-hook.json');
26
+ const UNIVERSAL_SENTINEL_PATH = path.join(os.homedir(), '.omnitype', 'active-model.json');
27
+ const SENTINEL_MAX_AGE_MS = 10_000;
28
+
29
+ const MODEL_PATTERN = /\b(claude-[\w.-]+|gpt-[\w.-]+|o[1234](?:-[\w.-]+)?|gemini-[\w.-]+|gemma[\w.-]*|llama-[\w.-]+|mistral[\w.-]*|codestral[\w.-]*|deepseek[\w.-]*|qwen[\w.-]+|command[\w.-]*|phi[\w.-]+|grok[\w.-]*|kimi[\w.-]*|moonshot[\w.-]*)\b/i;
30
+
31
+ // Maps known CLI/IDE env vars to their tool
32
+ const ENV_VARS: Array<{ vars: string[]; tool: string }> = [
33
+ { vars: ['CLAUDE_MODEL', 'CLAUDE_CODE_MODEL', 'ANTHROPIC_MODEL'], tool: 'claude-code' },
34
+ { vars: ['AIDER_MODEL'], tool: 'aider' },
35
+ { vars: ['OPENAI_MODEL', 'OPENAI_API_MODEL'], tool: 'openai' },
36
+ { vars: ['GEMINI_MODEL', 'GEMINI_API_KEY'], tool: 'gemini-cli' },
37
+ { vars: ['OLLAMA_MODEL'], tool: 'ollama' },
38
+ { vars: ['COPILOT_MODEL'], tool: 'copilot' },
39
+ { vars: ['LLM_MODEL'], tool: 'openhands' },
40
+ { vars: ['TABBY_MODEL'], tool: 'tabby' },
41
+ ];
42
+
43
+ const KNOWN_FORKS = [
44
+ 'Cursor', 'Windsurf', 'PearAI', 'Void', 'Trae', 'Zed', 'Antigravity'
45
+ ];
46
+
47
+ // lsof command-name → tool mapping
48
+ const LSOF_CMD_MAP: Array<{ match: string; tool: string }> = [
49
+ { match: 'claude', tool: 'claude-code' },
50
+ { match: 'aider', tool: 'aider' },
51
+ { match: 'cursor', tool: 'cursor' },
52
+ { match: 'windsurf', tool: 'windsurf' },
53
+ { match: 'zed', tool: 'zed' },
54
+ { match: 'pearai', tool: 'pearai' },
55
+ { match: 'void', tool: 'void' },
56
+ { match: 'tabby', tool: 'tabby' },
57
+ { match: 'goose', tool: 'goose' },
58
+ { match: 'node', tool: 'unknown-cli' },
59
+ { match: 'python', tool: 'unknown-cli' },
60
+ ];
61
+
62
+ export class ModelDetector {
63
+ detect(changedFilePath?: string): ModelDetectionResult {
64
+ return (
65
+ this._fromUniversalSentinel() ??
66
+ this._fromHooksSentinel() ??
67
+ this._fromIdeConfigs() ??
68
+ this._fromEnv() ??
69
+ this._fromPsPatterns() ??
70
+ (changedFilePath ? this._fromLsof(changedFilePath) : undefined) ??
71
+ UNKNOWN
72
+ );
73
+ }
74
+
75
+ private _fromUniversalSentinel(): ModelDetectionResult | undefined {
76
+ try {
77
+ const stat = fs.statSync(UNIVERSAL_SENTINEL_PATH);
78
+ if (Date.now() - stat.mtimeMs > SENTINEL_MAX_AGE_MS) return undefined;
79
+ const data = JSON.parse(fs.readFileSync(UNIVERSAL_SENTINEL_PATH, 'utf8'));
80
+ if (!data?.model || data.model === 'unknown') return undefined;
81
+ return { model: data.model, tool: data.tool ?? 'unknown-tool', confidence: 'deterministic' };
82
+ } catch { return undefined; }
83
+ }
84
+
85
+ private _fromHooksSentinel(): ModelDetectionResult | undefined {
86
+ try {
87
+ const stat = fs.statSync(HOOKS_SENTINEL_PATH);
88
+ if (Date.now() - stat.mtimeMs > SENTINEL_MAX_AGE_MS) return undefined;
89
+ const data = JSON.parse(fs.readFileSync(HOOKS_SENTINEL_PATH, 'utf8'));
90
+ if (!data?.model || data.model === 'unknown') return undefined;
91
+ return { model: data.model, tool: data.tool ?? 'claude-code', confidence: 'deterministic' };
92
+ } catch { return undefined; }
93
+ }
94
+
95
+ private _fromIdeConfigs(): ModelDetectionResult | undefined {
96
+ for (const appName of KNOWN_FORKS) {
97
+ const scan = this._scanIdeSettings(appName);
98
+ if (scan) {
99
+ return {
100
+ model: scan.model,
101
+ tool: appName.toLowerCase(),
102
+ confidence: scan.isAuto ? 'medium' : 'high'
103
+ };
104
+ }
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ private _fromEnv(): ModelDetectionResult | undefined {
110
+ for (const { vars, tool } of ENV_VARS) {
111
+ for (const v of vars) {
112
+ const val = process.env[v];
113
+ if (val && MODEL_PATTERN.test(val)) {
114
+ return { model: val, tool, confidence: 'high' };
115
+ }
116
+ }
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ private _fromPsPatterns(): ModelDetectionResult | undefined {
122
+ if (process.platform === 'win32') return undefined;
123
+ try {
124
+ const lines = execFileSync('ps', ['ax', '-o', 'args='], { timeout: 800, encoding: 'utf8' }).split('\n');
125
+ for (const line of lines) {
126
+ for (const entry of LSOF_CMD_MAP) {
127
+ if (line.toLowerCase().includes(entry.match)) {
128
+ // Fallback model name
129
+ return { model: `${entry.tool}-default`, tool: entry.tool, confidence: 'low' };
130
+ }
131
+ }
132
+ }
133
+ } catch { /* ps unavailable */ }
134
+ return undefined;
135
+ }
136
+
137
+ private _fromLsof(filePath: string): ModelDetectionResult | undefined {
138
+ if (process.platform === 'win32') return undefined;
139
+ try {
140
+ const out = execFileSync('lsof', ['-F', 'c', filePath], { timeout: 800, encoding: 'utf8' });
141
+ for (const line of out.split('\n')) {
142
+ if (!line.startsWith('c')) continue;
143
+ const cmd = line.slice(1).toLowerCase();
144
+ for (const entry of LSOF_CMD_MAP) {
145
+ if (cmd.includes(entry.match)) {
146
+ return { model: `${entry.tool}-default`, tool: entry.tool, confidence: 'low' };
147
+ }
148
+ }
149
+ }
150
+ } catch { /* lsof unavailable */ }
151
+ return undefined;
152
+ }
153
+
154
+ private _scanIdeSettings(appName: string): { model: string; isAuto: boolean } | undefined {
155
+ for (const p of this._getIdeSettingsPaths(appName)) {
156
+ try {
157
+ const raw = fs.readFileSync(p, 'utf8');
158
+ const json = JSON.parse(raw);
159
+ const flat = this._flattenObject(json);
160
+
161
+ const AUTO_SENTINELS = new Set(['auto', 'cursor-auto', 'windsurf-auto', 'default', 'best']);
162
+ const candidates: Array<{ key: string; value: string }> = [];
163
+
164
+ for (const [key, value] of Object.entries(flat)) {
165
+ if (typeof value !== 'string' || !value) continue;
166
+ if (!key.toLowerCase().includes('model')) continue;
167
+
168
+ if (AUTO_SENTINELS.has(value.toLowerCase())) {
169
+ candidates.push({ key, value: `${appName.toLowerCase()}-auto` });
170
+ continue;
171
+ }
172
+
173
+ if (MODEL_PATTERN.test(value)) {
174
+ candidates.push({ key, value: value.toLowerCase() });
175
+ }
176
+ }
177
+
178
+ if (candidates.length > 0) {
179
+ const real = candidates.filter(c => !c.value.endsWith('-auto'));
180
+ const pick = real.length > 0
181
+ ? real.sort((a, b) => b.key.length - a.key.length)[0]
182
+ : candidates.sort((a, b) => b.key.length - a.key.length)[0];
183
+ return { model: pick.value, isAuto: pick.value.endsWith('-auto') };
184
+ }
185
+ } catch { /* absent */ }
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ private _getIdeSettingsPaths(appName: string): string[] {
191
+ const toolId = appName.toLowerCase().replace(/\s+/g, '-');
192
+ const dirCandidates = [...new Set([appName, toolId])];
193
+
194
+ return dirCandidates.map(dir => {
195
+ switch (process.platform) {
196
+ case 'darwin': return path.join(os.homedir(), 'Library', 'Application Support', dir, 'User', 'settings.json');
197
+ case 'win32': return path.join(process.env.APPDATA ?? '', dir, 'User', 'settings.json');
198
+ default: return path.join(os.homedir(), '.config', dir, 'User', 'settings.json');
199
+ }
200
+ });
201
+ }
202
+
203
+ private _flattenObject(obj: Record<string, unknown>, prefix = '', depth = 0): Record<string, unknown> {
204
+ if (depth > 5) return {};
205
+ const out: Record<string, unknown> = {};
206
+ for (const [k, v] of Object.entries(obj)) {
207
+ const key = prefix ? `${prefix}.${k}` : k;
208
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
209
+ Object.assign(out, this._flattenObject(v as Record<string, unknown>, key, depth + 1));
210
+ } else {
211
+ out[key] = v;
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+ }