@omnitype-code/cli 0.1.2 → 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.
@@ -131,12 +131,119 @@ function extractAmp(filePath) {
131
131
  catch { }
132
132
  return undefined;
133
133
  }
134
+ /**
135
+ * Claude Code encodes a project path as the absolute path with '/' replaced by '-'.
136
+ * e.g. /Users/foo/myproject → -Users-foo-myproject
137
+ */
138
+ function claudeProjectDir(cwd, base) {
139
+ return path.join(base, 'projects', cwd.replace(/\//g, '-'));
140
+ }
141
+ /**
142
+ * Returns the VS Code workspaceStorage root, then filters subdirs to those
143
+ * whose workspace.json `folder` URI matches the given cwd.
144
+ * Falls back to returning all copilot transcript dirs if cwd is unknown.
145
+ */
146
+ function copilotDirs(cwd) {
147
+ const roots = [];
148
+ switch (process.platform) {
149
+ case 'darwin':
150
+ roots.push(path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage'));
151
+ break;
152
+ case 'win32':
153
+ if (process.env.APPDATA)
154
+ roots.push(path.join(process.env.APPDATA, 'Code', 'User', 'workspaceStorage'));
155
+ break;
156
+ default:
157
+ roots.push(path.join(HOME, '.config', 'Code', 'User', 'workspaceStorage'));
158
+ break;
159
+ }
160
+ // Also check Insiders
161
+ if (process.platform === 'darwin')
162
+ roots.push(path.join(HOME, 'Library', 'Application Support', 'Code - Insiders', 'User', 'workspaceStorage'));
163
+ const result = [];
164
+ for (const root of roots) {
165
+ if (!fs.existsSync(root))
166
+ continue;
167
+ let entries;
168
+ try {
169
+ entries = fs.readdirSync(root, { withFileTypes: true });
170
+ }
171
+ catch {
172
+ continue;
173
+ }
174
+ for (const e of entries) {
175
+ if (!e.isDirectory())
176
+ continue;
177
+ const transcriptsDir = path.join(root, e.name, 'GitHub.copilot-chat', 'transcripts');
178
+ if (!fs.existsSync(transcriptsDir))
179
+ continue;
180
+ if (cwd) {
181
+ // Read workspace.json to check if this workspace matches the cwd
182
+ try {
183
+ const wj = JSON.parse(fs.readFileSync(path.join(root, e.name, 'workspace.json'), 'utf8'));
184
+ const folder = (wj?.folder ?? '').replace(/^file:\/\//, '').replace(/%20/g, ' ');
185
+ if (folder !== cwd && !folder.startsWith(cwd + '/'))
186
+ continue;
187
+ }
188
+ catch {
189
+ continue;
190
+ }
191
+ }
192
+ result.push(transcriptsDir);
193
+ }
194
+ }
195
+ return result;
196
+ }
197
+ /** Extract model from a Copilot JSONL event stream line. */
198
+ function extractCopilotJsonl(filePath) {
199
+ let fd;
200
+ try {
201
+ fd = fs.openSync(filePath, 'r');
202
+ const size = fs.fstatSync(fd).size;
203
+ if (!size) {
204
+ fs.closeSync(fd);
205
+ return undefined;
206
+ }
207
+ const readSize = Math.min(TAIL_BYTES, size);
208
+ const buf = Buffer.alloc(readSize);
209
+ fs.readSync(fd, buf, 0, readSize, size - readSize);
210
+ fs.closeSync(fd);
211
+ fd = undefined;
212
+ const lines = buf.toString('utf8').split('\n');
213
+ for (let i = lines.length - 1; i >= 0; i--) {
214
+ const t = lines[i].trim();
215
+ if (!t)
216
+ continue;
217
+ try {
218
+ const j = JSON.parse(t);
219
+ // assistant.message carries modelId in data
220
+ const m = j?.data?.modelId ?? j?.data?.model ?? j?.model ?? j?.message?.model;
221
+ if (typeof m === 'string' && m)
222
+ return m;
223
+ }
224
+ catch { }
225
+ }
226
+ }
227
+ catch { }
228
+ finally {
229
+ if (fd !== undefined)
230
+ try {
231
+ fs.closeSync(fd);
232
+ }
233
+ catch { }
234
+ }
235
+ return undefined;
236
+ }
134
237
  const SPECS = [
135
238
  {
136
239
  tool: 'claude-code',
137
- dirs: () => {
240
+ dirs: (cwd) => {
138
241
  const base = process.env.CLAUDE_CONFIG_DIR ?? path.join(HOME, '.claude');
139
242
  const alt = path.join(HOME, '.config', 'claude');
243
+ if (cwd) {
244
+ // Scope to the current workspace so other projects don't bleed in.
245
+ return [claudeProjectDir(cwd, base), claudeProjectDir(cwd, alt)];
246
+ }
140
247
  return [path.join(base, 'projects'), path.join(alt, 'projects')];
141
248
  },
142
249
  ext: '.jsonl',
@@ -166,12 +273,48 @@ const SPECS = [
166
273
  ext: '.json',
167
274
  extract: extractAmp,
168
275
  },
276
+ {
277
+ tool: 'copilot',
278
+ dirs: (cwd) => copilotDirs(cwd),
279
+ ext: '.jsonl',
280
+ extract: extractCopilotJsonl,
281
+ },
169
282
  {
170
283
  tool: 'windsurf',
171
284
  dirs: () => [path.join(HOME, '.codeium', 'windsurf', 'conversations')],
172
285
  ext: '.jsonl',
173
286
  extract: extractJsonl,
174
287
  },
288
+ {
289
+ tool: 'continue',
290
+ dirs: () => [path.join(HOME, '.continue', 'sessions')],
291
+ ext: '.json',
292
+ extract: (filePath) => {
293
+ try {
294
+ const msgs = JSON.parse(fs.readFileSync(filePath, 'utf8'))?.history ?? [];
295
+ for (let i = msgs.length - 1; i >= 0; i--) {
296
+ const m = msgs[i]?.message?.model ?? msgs[i]?.model;
297
+ if (typeof m === 'string' && m)
298
+ return m;
299
+ }
300
+ }
301
+ catch { }
302
+ return undefined;
303
+ },
304
+ },
305
+ {
306
+ // Copilot CLI: ~/.copilot/session-state/*/events.jsonl
307
+ tool: 'copilot-cli',
308
+ dirs: () => [path.join(HOME, '.copilot', 'session-state')],
309
+ ext: '.jsonl',
310
+ extract: extractJsonl,
311
+ },
312
+ {
313
+ tool: 'droid',
314
+ dirs: () => [path.join(HOME, '.factory', 'sessions')],
315
+ ext: '.jsonl',
316
+ extract: extractJsonl,
317
+ },
175
318
  ];
176
319
  // ── Directory scanner: finds the newest file with the given extension ─────────
177
320
  function newestFile(dirs, ext) {
@@ -207,16 +350,19 @@ function newestFile(dirs, ext) {
207
350
  // ── Public API ────────────────────────────────────────────────────────────────
208
351
  let _cache;
209
352
  let _cacheAt = 0;
353
+ let _cacheCwd;
210
354
  /**
211
- * Returns the model from the most recently modified AI session transcript,
212
- * across all supported tools. Result is cached for 60 s.
355
+ * Returns the model from the most recently modified AI session transcript.
356
+ * When `cwd` is provided, claude-code scanning is scoped to that workspace
357
+ * so activity from other projects doesn't bleed into the result.
358
+ * Result is cached for 60 s (cache is busted when cwd changes).
213
359
  */
214
- function scanTranscripts() {
215
- if (_cache && Date.now() - _cacheAt < CACHE_TTL)
360
+ function scanTranscripts(cwd) {
361
+ if (_cache && _cacheCwd === cwd && Date.now() - _cacheAt < CACHE_TTL)
216
362
  return _cache;
217
363
  let best;
218
364
  for (const spec of SPECS) {
219
- const file = newestFile(spec.dirs(), spec.ext);
365
+ const file = newestFile(spec.dirs(cwd), spec.ext);
220
366
  if (!file)
221
367
  continue;
222
368
  if (best && file.mtime <= best.mtime)
@@ -228,6 +374,7 @@ function scanTranscripts() {
228
374
  }
229
375
  _cache = best;
230
376
  _cacheAt = Date.now();
377
+ _cacheCwd = cwd;
231
378
  return best;
232
379
  }
233
380
  function invalidateTranscriptCache() { _cache = undefined; _cacheAt = 0; }
package/dist/daemon.js CHANGED
@@ -91,9 +91,23 @@ function startDaemon(opts) {
91
91
  UI_1.UI.error('Not signed in. Run: omnitype login');
92
92
  process.exit(1);
93
93
  }
94
- // Auto-install preToolUse hooks into Cursor, Windsurf, Codex if present.
94
+ // Auto-install preToolUse hooks into all supported tools.
95
95
  (0, ToolHookInstallers_1.installAllToolHooks)();
96
96
  const watchPath = path.resolve(opts.watchPath);
97
+ // Initialize per-project .omnitype/ dir and ensure it is in .gitignore.
98
+ const omniDir = path.join(watchPath, '.omnitype');
99
+ try {
100
+ fs.mkdirSync(omniDir, { recursive: true });
101
+ }
102
+ catch { }
103
+ try {
104
+ const gitignore = path.join(watchPath, '.gitignore');
105
+ const current = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, 'utf8') : '';
106
+ if (!current.split('\n').some(l => l.trim() === '.omnitype' || l.trim() === '.omnitype/')) {
107
+ fs.appendFileSync(gitignore, (current.length && !current.endsWith('\n') ? '\n' : '') + '.omnitype/\n');
108
+ }
109
+ }
110
+ catch { }
97
111
  const projectName = opts.projectName;
98
112
  const branch = opts.branch ?? gitBranch(watchPath);
99
113
  console.log(UI_1.UI.box(`${chalk_1.default.bold('Project:')} ${chalk_1.default.cyan(projectName)}\n` +
package/dist/index.js CHANGED
@@ -50,6 +50,8 @@ const daemon_1 = require("./daemon");
50
50
  const blame_1 = require("./blame");
51
51
  const GitNotes_1 = require("./core/GitNotes");
52
52
  const UI_1 = require("./core/UI");
53
+ const ToolHookInstallers_1 = require("./core/ToolHookInstallers");
54
+ const ToolDetector_1 = require("./core/ToolDetector");
53
55
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
54
56
  const program = new commander_1.Command();
55
57
  program
@@ -109,40 +111,135 @@ program
109
111
  .description('Show current auth and model detection status')
110
112
  .action(() => {
111
113
  const api = new ApiClient_1.ApiClient();
112
- const detection = new ModelDetector_1.ModelDetector().detect();
113
- let content = '';
114
- // Account Section
114
+ const cwd = process.cwd();
115
+ const detection = new ModelDetector_1.ModelDetector().detect(undefined, cwd);
116
+ const lines = [];
117
+ const col1 = 10;
118
+ const lbl = (k) => chalk_1.default.bold(chalk_1.default.hex(UI_1.COLORS.primary)(k.padEnd(col1)));
119
+ // Account
115
120
  if (api.isSignedIn) {
116
- content += `${chalk_1.default.bold('Account:')} ${chalk_1.default.cyan(api.username)}\n`;
117
- content += `${chalk_1.default.bold('Server:')} ${UI_1.UI.dim(api.apiUrl)}\n`;
121
+ lines.push(`${lbl('Account')} ${chalk_1.default.cyan(api.username)}`);
122
+ lines.push(`${lbl('Server')} ${UI_1.UI.dim(api.apiUrl)}`);
118
123
  }
119
124
  else {
120
- content += `${chalk_1.default.bold('Account:')} ${chalk_1.default.red('Not signed in')} ${UI_1.UI.dim('(run: omnitype login)')}\n`;
121
- }
122
- content += `\n`;
123
- // Detection Section
124
- content += `${chalk_1.default.bold('Current Context:')}\n`;
125
- const modelColor = detection.model.includes('claude') ? '#D97757' : (detection.model.includes('gpt') ? '#10A37F' : UI_1.COLORS.ai);
126
- content += ` ${chalk_1.default.bold('Model:')} ${chalk_1.default.hex(modelColor)(detection.model)}\n`;
127
- content += ` ${chalk_1.default.bold('Tool:')} ${chalk_1.default.white(detection.tool)}\n`;
128
- const confColors = {
129
- deterministic: chalk_1.default.green('Deterministic'),
130
- high: chalk_1.default.green('High'),
131
- medium: chalk_1.default.yellow('Medium'),
132
- low: chalk_1.default.red('Low'),
125
+ lines.push(`${lbl('Account')} ${chalk_1.default.red('not signed in')} ${UI_1.UI.dim(' omnitype login')}`);
126
+ }
127
+ lines.push('');
128
+ // AI context
129
+ const toolColor = (t) => t.includes('claude') ? '#D97757' : t.includes('gpt') || t.includes('openai') ? '#10A37F'
130
+ : t.includes('gemini') ? '#4285F4' : t.includes('copilot') ? '#6E40C9' : UI_1.COLORS.ai;
131
+ const modelStr = detection.model === 'unknown'
132
+ ? chalk_1.default.gray('none detected')
133
+ : chalk_1.default.bold(chalk_1.default.hex(toolColor(detection.model))(detection.model));
134
+ const toolStr = detection.tool === 'unknown'
135
+ ? chalk_1.default.gray('')
136
+ : chalk_1.default.hex(toolColor(detection.tool))(detection.tool);
137
+ const confBadge = {
138
+ deterministic: chalk_1.default.bgGreen.black(' HOOK '),
139
+ high: chalk_1.default.bgCyan.black(' HIGH '),
140
+ medium: chalk_1.default.bgYellow.black(' MED '),
141
+ low: chalk_1.default.bgRed.black(' LOW '),
133
142
  };
134
- content += ` ${chalk_1.default.bold('Conf:')} ${confColors[detection.confidence] || detection.confidence}\n`;
135
- // Repo Section
143
+ lines.push(`${lbl('Model')} ${modelStr}`);
144
+ lines.push(`${lbl('Tool')} ${toolStr}`);
145
+ lines.push(`${lbl('Confidence')} ${confBadge[detection.confidence] ?? detection.confidence}`);
146
+ // Repo
136
147
  try {
137
- const gitBranch = require('child_process').execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
138
- const gitRepo = path.basename(process.cwd());
139
- content += `\n`;
140
- content += `${chalk_1.default.bold('Repository:')}\n`;
141
- content += ` ${chalk_1.default.bold('Project:')} ${gitRepo}\n`;
142
- content += ` ${chalk_1.default.bold('Branch:')} ${chalk_1.default.magenta(gitBranch)}`;
148
+ const cp = require('child_process');
149
+ const branch = cp.execSync('git rev-parse --abbrev-ref HEAD', { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
150
+ const remote = (() => { try {
151
+ return cp.execSync('git remote get-url origin', { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, '');
152
+ }
153
+ catch {
154
+ return '';
155
+ } })();
156
+ lines.push('');
157
+ lines.push(`${lbl('Project')} ${chalk_1.default.white(path.basename(cwd))}`);
158
+ lines.push(`${lbl('Branch')} ${chalk_1.default.magenta(branch)}`);
159
+ if (remote)
160
+ lines.push(`${lbl('Remote')} ${UI_1.UI.dim(remote)}`);
143
161
  }
144
162
  catch { }
145
- console.log(UI_1.UI.box(content, `${UI_1.UI.logo()} Status`));
163
+ console.log(UI_1.UI.box(lines.join('\n'), `${UI_1.UI.logo()} Status`));
164
+ });
165
+ // ── omnitype doctor ─────────────────────────────────────────────────────────
166
+ program
167
+ .command('doctor')
168
+ .description('Check hook status for all detected AI tools')
169
+ .option('--fix', 'Auto-install or upgrade any missing/stale hooks')
170
+ .action((opts) => {
171
+ const api = new ApiClient_1.ApiClient();
172
+ const installed = (0, ToolDetector_1.detectInstalledTools)();
173
+ const hookStatuses = (0, ToolHookInstallers_1.checkHookStatus)();
174
+ const hookMap = new Map(hookStatuses.map(s => [s.tool, s.status]));
175
+ const col = 20;
176
+ const lbl = (k) => chalk_1.default.bold(k.padEnd(col));
177
+ const hookBadge = {
178
+ 'installed': chalk_1.default.bgGreen.black(' HOOKED '),
179
+ 'stale': chalk_1.default.bgYellow.black(' STALE '),
180
+ 'not-installed': chalk_1.default.bgRed.black(' NOT HOOKED'),
181
+ 'tool-absent': '',
182
+ };
183
+ const lines = [];
184
+ let needsFix = false;
185
+ const hookedTools = installed.filter(t => t.hookSupport === 'hooked');
186
+ const configOnlyTools = installed.filter(t => t.hookSupport === 'config-only');
187
+ const unhooked = installed.filter(t => t.hookSupport === 'no-hook');
188
+ // ── Tools with sentinel hook support ──
189
+ if (hookedTools.length > 0) {
190
+ lines.push(chalk_1.default.bold(chalk_1.default.hex(UI_1.COLORS.primary)('Sentinel hooks:')));
191
+ for (const tool of hookedTools) {
192
+ const status = hookMap.get(tool.id) ?? 'not-installed';
193
+ lines.push(` ${lbl(tool.name)} ${hookBadge[status]}`);
194
+ if (status === 'stale' || status === 'not-installed')
195
+ needsFix = true;
196
+ }
197
+ }
198
+ else {
199
+ lines.push(chalk_1.default.gray('No hookable AI tools detected on this machine.'));
200
+ }
201
+ // ── Config-only tools (model readable, no hook API) ──
202
+ if (configOnlyTools.length > 0) {
203
+ lines.push('');
204
+ lines.push(chalk_1.default.bold(chalk_1.default.hex(UI_1.COLORS.secondary)('Config-readable (no hook API):')));
205
+ for (const tool of configOnlyTools) {
206
+ lines.push(` ${lbl(tool.name)} ${chalk_1.default.hex(UI_1.COLORS.secondary)(' DETECTED ')} ${UI_1.UI.dim('model readable from config')}`);
207
+ }
208
+ }
209
+ // ── Detected tools we don't support yet ──
210
+ if (unhooked.length > 0) {
211
+ lines.push('');
212
+ lines.push(chalk_1.default.bold(chalk_1.default.hex(UI_1.COLORS.warning)('Detected (not yet supported):')));
213
+ for (const tool of unhooked) {
214
+ lines.push(` ${lbl(tool.name)} ${chalk_1.default.gray('— support coming soon')}`);
215
+ }
216
+ }
217
+ // ── Nothing detected at all ──
218
+ if (installed.length === 0) {
219
+ lines.push(chalk_1.default.gray('No AI coding tools detected on this machine.'));
220
+ lines.push(UI_1.UI.dim('Install Claude Code, Cursor, Windsurf, or another tool and re-run.'));
221
+ }
222
+ // ── Fix prompt ──
223
+ if (needsFix) {
224
+ lines.push('');
225
+ if (opts.fix) {
226
+ (0, ToolHookInstallers_1.installAllToolHooks)();
227
+ lines.push(chalk_1.default.hex(UI_1.COLORS.success)('✔ Hooks installed/upgraded. Run doctor again to verify.'));
228
+ }
229
+ else {
230
+ lines.push(chalk_1.default.yellow('→ Run with --fix to install or upgrade missing/stale hooks.'));
231
+ lines.push(UI_1.UI.dim(' Hooks give deterministic (100% accurate) model attribution.'));
232
+ }
233
+ }
234
+ else if (hookedTools.length > 0) {
235
+ lines.push('');
236
+ lines.push(chalk_1.default.hex(UI_1.COLORS.success)('✔ All hooks up to date.'));
237
+ }
238
+ console.log(UI_1.UI.box(lines.join('\n'), `${UI_1.UI.logo()} Doctor`));
239
+ // Market analysis telemetry — fire and forget
240
+ if (installed.length > 0) {
241
+ api.reportToolEnvironment(installed.map(t => ({ id: t.id, name: t.name, hookSupport: t.hookSupport })));
242
+ }
146
243
  });
147
244
  // ── omnitype daemon ─────────────────────────────────────────────────────────
148
245
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnitype-code/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "OmniType CLI — editor-agnostic code provenance tracking",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -10,7 +10,8 @@
10
10
  "scripts": {
11
11
  "build": "tsc",
12
12
  "dev": "ts-node src/index.ts",
13
- "prepublishOnly": "npm run build"
13
+ "prepublishOnly": "npm run build",
14
+ "postinstall": "node scripts/postinstall.js"
14
15
  },
15
16
  "dependencies": {
16
17
  "boxen": "^5.1.2",
@@ -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');
@@ -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