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