@omnitype-code/cli 0.1.3 → 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.
- package/.omnitype/active-model.json +1 -0
- package/dist/__tests__/HookIntegration.test.js +255 -0
- package/dist/__tests__/ModelDetector.test.js +240 -0
- package/dist/__tests__/ModelDetectorCoverage.test.js +167 -0
- package/dist/__tests__/ToolDetector.test.js +251 -0
- package/dist/__tests__/ToolHookInstallers.test.js +272 -0
- package/dist/__tests__/ToolHookInstallersCoverage.test.js +262 -0
- package/dist/__tests__/TranscriptScanner.test.js +201 -0
- package/dist/core/ToolHookInstallers.js +71 -29
- package/package.json +30 -2
- package/src/__tests__/HookIntegration.test.ts +261 -0
- package/src/__tests__/ModelDetector.test.ts +252 -0
- package/src/__tests__/ModelDetectorCoverage.test.ts +154 -0
- package/src/__tests__/ToolDetector.test.ts +238 -0
- package/src/__tests__/ToolHookInstallers.test.ts +281 -0
- package/src/__tests__/ToolHookInstallersCoverage.test.ts +237 -0
- package/src/__tests__/TranscriptScanner.test.ts +201 -0
- package/src/core/ToolHookInstallers.ts +73 -30
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ToolHookInstallers coverage tests — targets uncovered tool installers:
|
|
4
|
+
* Windsurf, Codex, Gemini, Droid, Firebender, Cline, Copilot, Amp, OpenCode, Pi
|
|
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
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const ToolHookInstallers_1 = require("../core/ToolHookInstallers");
|
|
44
|
+
const HOME = os.homedir();
|
|
45
|
+
function makeScratchDir(name) {
|
|
46
|
+
const d = path.join(HOME, `.omnitype-cov-test-${name}-${Date.now()}`);
|
|
47
|
+
fs.mkdirSync(d, { recursive: true });
|
|
48
|
+
return d;
|
|
49
|
+
}
|
|
50
|
+
function rmDir(d) { fs.rmSync(d, { recursive: true, force: true }); }
|
|
51
|
+
function readJson(p) { try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return {};
|
|
56
|
+
} }
|
|
57
|
+
// ── Universal hook command content ────────────────────────────────────────────
|
|
58
|
+
describe('ToolHookInstallers — universal hook command (all tools)', () => {
|
|
59
|
+
// Install into a scratch ~/.claude and read the command back
|
|
60
|
+
let cmd;
|
|
61
|
+
let scratchClaude;
|
|
62
|
+
const realClaude = path.join(HOME, '.claude');
|
|
63
|
+
const backup = path.join(HOME, `.claude-backup-cov-${Date.now()}`);
|
|
64
|
+
let hadReal = false;
|
|
65
|
+
beforeAll(() => {
|
|
66
|
+
scratchClaude = makeScratchDir('claude');
|
|
67
|
+
if (fs.existsSync(realClaude)) {
|
|
68
|
+
fs.renameSync(realClaude, backup);
|
|
69
|
+
hadReal = true;
|
|
70
|
+
}
|
|
71
|
+
fs.renameSync(scratchClaude, realClaude);
|
|
72
|
+
(0, ToolHookInstallers_1.installAllToolHooks)();
|
|
73
|
+
const s = readJson(path.join(realClaude, 'settings.json'));
|
|
74
|
+
cmd = s?.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command ?? '';
|
|
75
|
+
rmDir(realClaude);
|
|
76
|
+
if (hadReal)
|
|
77
|
+
fs.renameSync(backup, realClaude);
|
|
78
|
+
});
|
|
79
|
+
it('tries j.model (Cursor/Codex field)', () => {
|
|
80
|
+
expect(cmd).toContain('j.model');
|
|
81
|
+
});
|
|
82
|
+
it('tries j.model_name (Windsurf field)', () => {
|
|
83
|
+
expect(cmd).toContain('j.model_name');
|
|
84
|
+
});
|
|
85
|
+
it('tries j.modelName (camelCase variant)', () => {
|
|
86
|
+
expect(cmd).toContain('j.modelName');
|
|
87
|
+
});
|
|
88
|
+
it('tries j.modelID', () => {
|
|
89
|
+
expect(cmd).toContain('j.modelID');
|
|
90
|
+
});
|
|
91
|
+
it('tries j.data.model (nested variant)', () => {
|
|
92
|
+
expect(cmd).toContain('j?.data?.model');
|
|
93
|
+
});
|
|
94
|
+
it('falls back to transcript_path JSONL', () => {
|
|
95
|
+
expect(cmd).toContain('j.transcript_path');
|
|
96
|
+
expect(cmd).toContain('extractModelFromJsonl');
|
|
97
|
+
});
|
|
98
|
+
it('falls back to env var last', () => {
|
|
99
|
+
expect(cmd).toContain('CLAUDE_MODEL');
|
|
100
|
+
expect(cmd).toContain('ANTHROPIC_MODEL');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
// ── Windsurf installer ────────────────────────────────────────────────────────
|
|
104
|
+
describe('ToolHookInstallers — Windsurf', () => {
|
|
105
|
+
let scratchCodium;
|
|
106
|
+
const realCodium = path.join(HOME, '.codeium');
|
|
107
|
+
const backup = path.join(HOME, `.codeium-backup-${Date.now()}`);
|
|
108
|
+
let hadReal = false;
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
scratchCodium = makeScratchDir('codeium');
|
|
111
|
+
if (fs.existsSync(realCodium)) {
|
|
112
|
+
fs.renameSync(realCodium, backup);
|
|
113
|
+
hadReal = true;
|
|
114
|
+
}
|
|
115
|
+
fs.renameSync(scratchCodium, realCodium);
|
|
116
|
+
});
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
rmDir(realCodium);
|
|
119
|
+
if (hadReal) {
|
|
120
|
+
fs.renameSync(backup, realCodium);
|
|
121
|
+
hadReal = false;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
it('installs hook into windsurf/hooks.json', () => {
|
|
125
|
+
// The installer skips if neither hookPath nor its parent dir exists.
|
|
126
|
+
// Create the parent dir so it proceeds.
|
|
127
|
+
const windsurfDir = path.join(realCodium, 'windsurf');
|
|
128
|
+
fs.mkdirSync(windsurfDir, { recursive: true });
|
|
129
|
+
(0, ToolHookInstallers_1.installAllToolHooks)();
|
|
130
|
+
const hooks = readJson(path.join(windsurfDir, 'hooks.json'));
|
|
131
|
+
const cmds = (hooks?.hooks?.pre_write_code ?? []).map((h) => h?.command ?? '');
|
|
132
|
+
expect(cmds.some(c => c.includes(ToolHookInstallers_1.HOOK_VERSION))).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
it('checkHookStatus returns installed for windsurf', () => {
|
|
135
|
+
(0, ToolHookInstallers_1.installAllToolHooks)();
|
|
136
|
+
const statuses = (0, ToolHookInstallers_1.checkHookStatus)();
|
|
137
|
+
const ws = statuses.find(s => s.tool === 'windsurf');
|
|
138
|
+
expect(ws?.status).toBe('installed');
|
|
139
|
+
});
|
|
140
|
+
it('checkHookStatus returns stale when old hook present', () => {
|
|
141
|
+
const hooksDir = path.join(realCodium, 'windsurf');
|
|
142
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
143
|
+
fs.writeFileSync(path.join(hooksDir, 'hooks.json'), JSON.stringify({
|
|
144
|
+
hooks: { pre_write_code: [{ command: 'node -e "/*omnitype-hook-v3*/.omnitype"' }] }
|
|
145
|
+
}));
|
|
146
|
+
const statuses = (0, ToolHookInstallers_1.checkHookStatus)();
|
|
147
|
+
const ws = statuses.find(s => s.tool === 'windsurf');
|
|
148
|
+
expect(ws?.status).toBe('stale');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
// ── Gemini CLI installer ──────────────────────────────────────────────────────
|
|
152
|
+
describe('ToolHookInstallers — Gemini CLI', () => {
|
|
153
|
+
let scratchGemini;
|
|
154
|
+
const realGemini = path.join(HOME, '.gemini');
|
|
155
|
+
const backup = path.join(HOME, `.gemini-backup-${Date.now()}`);
|
|
156
|
+
let hadReal = false;
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
scratchGemini = makeScratchDir('gemini');
|
|
159
|
+
if (fs.existsSync(realGemini)) {
|
|
160
|
+
fs.renameSync(realGemini, backup);
|
|
161
|
+
hadReal = true;
|
|
162
|
+
}
|
|
163
|
+
fs.renameSync(scratchGemini, realGemini);
|
|
164
|
+
});
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
rmDir(realGemini);
|
|
167
|
+
if (hadReal) {
|
|
168
|
+
fs.renameSync(backup, realGemini);
|
|
169
|
+
hadReal = false;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
it('installs hook into gemini settings.json BeforeTool', () => {
|
|
173
|
+
(0, ToolHookInstallers_1.installAllToolHooks)();
|
|
174
|
+
const s = readJson(path.join(realGemini, 'settings.json'));
|
|
175
|
+
const cmds = (s?.BeforeTool ?? []).flatMap((e) => e?.hooks?.map((h) => h?.command ?? '') ?? []);
|
|
176
|
+
expect(cmds.some(c => c.includes(ToolHookInstallers_1.HOOK_VERSION))).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
it('checkHookStatus returns installed for gemini-cli', () => {
|
|
179
|
+
(0, ToolHookInstallers_1.installAllToolHooks)();
|
|
180
|
+
const statuses = (0, ToolHookInstallers_1.checkHookStatus)();
|
|
181
|
+
const g = statuses.find(s => s.tool === 'gemini-cli');
|
|
182
|
+
expect(g?.status).toBe('installed');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
// ── Codex installer ───────────────────────────────────────────────────────────
|
|
186
|
+
describe('ToolHookInstallers — Codex', () => {
|
|
187
|
+
let scratchCodex;
|
|
188
|
+
const realCodex = path.join(HOME, '.codex');
|
|
189
|
+
const backup = path.join(HOME, `.codex-backup-${Date.now()}`);
|
|
190
|
+
let hadReal = false;
|
|
191
|
+
beforeEach(() => {
|
|
192
|
+
scratchCodex = makeScratchDir('codex');
|
|
193
|
+
if (fs.existsSync(realCodex)) {
|
|
194
|
+
fs.renameSync(realCodex, backup);
|
|
195
|
+
hadReal = true;
|
|
196
|
+
}
|
|
197
|
+
fs.renameSync(scratchCodex, realCodex);
|
|
198
|
+
});
|
|
199
|
+
afterEach(() => {
|
|
200
|
+
rmDir(realCodex);
|
|
201
|
+
if (hadReal) {
|
|
202
|
+
fs.renameSync(backup, realCodex);
|
|
203
|
+
hadReal = false;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
it('installs hook into codex hooks.json PreToolUse', () => {
|
|
207
|
+
(0, ToolHookInstallers_1.installAllToolHooks)();
|
|
208
|
+
const s = readJson(path.join(realCodex, 'hooks.json'));
|
|
209
|
+
const cmds = (s?.PreToolUse ?? []).map((h) => h?.command ?? '');
|
|
210
|
+
expect(cmds.some(c => c.includes(ToolHookInstallers_1.HOOK_VERSION))).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
it('checkHookStatus returns installed for codex', () => {
|
|
213
|
+
(0, ToolHookInstallers_1.installAllToolHooks)();
|
|
214
|
+
const statuses = (0, ToolHookInstallers_1.checkHookStatus)();
|
|
215
|
+
const c = statuses.find(s => s.tool === 'codex');
|
|
216
|
+
expect(c?.status).toBe('installed');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
// ── Cline installer ───────────────────────────────────────────────────────────
|
|
220
|
+
describe('ToolHookInstallers — Cline hook script content', () => {
|
|
221
|
+
it('Cline script contains version token', () => {
|
|
222
|
+
// The CLINE_HOOK_SCRIPT is embedded — verify via installing if dir exists
|
|
223
|
+
// otherwise just verify the constant is referenced in the module
|
|
224
|
+
const clineDir = path.join(HOME, 'Documents', 'Cline', 'Hooks');
|
|
225
|
+
if (!fs.existsSync(clineDir)) {
|
|
226
|
+
// Can't install, but we can verify the exported status
|
|
227
|
+
const statuses = (0, ToolHookInstallers_1.checkHookStatus)();
|
|
228
|
+
const c = statuses.find(s => s.tool === 'cline');
|
|
229
|
+
expect(c?.status).toBe('tool-absent');
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
(0, ToolHookInstallers_1.installAllToolHooks)();
|
|
233
|
+
const script = fs.readFileSync(path.join(clineDir, 'PreToolUse'), 'utf8');
|
|
234
|
+
expect(script).toContain(ToolHookInstallers_1.HOOK_VERSION);
|
|
235
|
+
expect(script).toContain('extractModelFromJsonl');
|
|
236
|
+
expect(script).toContain('session.model_change');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
// ── installAllToolHooks — silent on missing dirs ──────────────────────────────
|
|
240
|
+
describe('ToolHookInstallers — installAllToolHooks resilience', () => {
|
|
241
|
+
it('does not throw even when all tool dirs are absent', () => {
|
|
242
|
+
// All real tool dirs may or may not exist — the function is always silent
|
|
243
|
+
expect(() => (0, ToolHookInstallers_1.installAllToolHooks)()).not.toThrow();
|
|
244
|
+
});
|
|
245
|
+
it('checkHookStatus returns array with entries for every tracked tool', () => {
|
|
246
|
+
const statuses = (0, ToolHookInstallers_1.checkHookStatus)();
|
|
247
|
+
const tools = statuses.map(s => s.tool);
|
|
248
|
+
expect(tools).toContain('claude-code');
|
|
249
|
+
expect(tools).toContain('cursor');
|
|
250
|
+
expect(tools).toContain('windsurf');
|
|
251
|
+
expect(tools).toContain('codex');
|
|
252
|
+
expect(tools).toContain('cline');
|
|
253
|
+
expect(tools).toContain('gemini-cli');
|
|
254
|
+
});
|
|
255
|
+
it('every status entry has valid status value', () => {
|
|
256
|
+
const valid = new Set(['installed', 'stale', 'not-installed', 'tool-absent']);
|
|
257
|
+
const statuses = (0, ToolHookInstallers_1.checkHookStatus)();
|
|
258
|
+
for (const s of statuses) {
|
|
259
|
+
expect(valid.has(s.status)).toBe(true);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -54,10 +54,8 @@ const path = __importStar(require("path"));
|
|
|
54
54
|
exports.GLOBAL_OMNITYPE_DIR = path.join(os.homedir(), '.omnitype');
|
|
55
55
|
// Version token embedded in every hook command.
|
|
56
56
|
// Bump whenever hook logic changes so stale installs are replaced automatically.
|
|
57
|
-
exports.HOOK_VERSION = 'omnitype-hook-
|
|
57
|
+
exports.HOOK_VERSION = 'omnitype-hook-v5';
|
|
58
58
|
// Shared sentinel-write snippet: resolves dir from process.cwd(), falls back to ~/.omnitype/.
|
|
59
|
-
// `modelExpr` is a JS expression that evaluates to the model string (already extracted).
|
|
60
|
-
// `tool` is the literal tool name string.
|
|
61
59
|
const WRITE_SENTINEL = (tool) => `const fs=require('fs'),p=require('path'),os=require('os');` +
|
|
62
60
|
`const dir=p.join(process.cwd(),'.omnitype');` +
|
|
63
61
|
`const fbDir=p.join(os.homedir(),'.omnitype');` +
|
|
@@ -66,29 +64,49 @@ const WRITE_SENTINEL = (tool) => `const fs=require('fs'),p=require('path'),os=re
|
|
|
66
64
|
`const payload=JSON.stringify(Object.assign({model:m,tool:'${tool}',ts:Date.now()},file&&{file}));` +
|
|
67
65
|
`fs.writeFileSync(p.join(dir,'active-model.json'),payload);` +
|
|
68
66
|
`fs.writeFileSync(p.join(fbDir,'active-model.json'),payload);`;
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
// Extracts model from a JSONL transcript by reading the tail (last 50KB).
|
|
68
|
+
// Looks for message.model on assistant turns, or session.model_change events.
|
|
69
|
+
// Same approach as git-ai's extract_model_from_jsonl_tail.
|
|
70
|
+
const EXTRACT_MODEL_FROM_JSONL = `function extractModelFromJsonl(tp){` +
|
|
71
|
+
`try{` +
|
|
72
|
+
`const _fs=require('fs');` +
|
|
73
|
+
`const stat=_fs.statSync(tp);` +
|
|
74
|
+
`const readSize=Math.min(51200,stat.size);` +
|
|
75
|
+
`const buf=Buffer.alloc(readSize);` +
|
|
76
|
+
`const fd=_fs.openSync(tp,'r');` +
|
|
77
|
+
`_fs.readSync(fd,buf,0,readSize,stat.size-readSize);` +
|
|
78
|
+
`_fs.closeSync(fd);` +
|
|
79
|
+
`const lines=buf.toString('utf8').split('\\n').reverse();` +
|
|
80
|
+
`for(const line of lines){` +
|
|
81
|
+
`if(!line.trim())continue;` +
|
|
82
|
+
`try{const o=JSON.parse(line);` +
|
|
83
|
+
`if(o.type==='session.model_change'&&o.data&&o.data.newModel)return o.data.newModel;` +
|
|
84
|
+
`const m=o?.message?.model||o?.model;` +
|
|
85
|
+
`if(m&&m!=='<synthetic>')return m;` +
|
|
86
|
+
`}catch{}` +
|
|
87
|
+
`}` +
|
|
88
|
+
`}catch{}` +
|
|
89
|
+
`return null;` +
|
|
90
|
+
`}`;
|
|
91
|
+
// Universal hook: works for every tool without per-tool customization.
|
|
92
|
+
// Resolution order:
|
|
93
|
+
// 1. Direct payload fields (covers Cursor, Windsurf, Codex, Cline, Gemini, etc.)
|
|
94
|
+
// 2. transcript_path JSONL tail (covers Claude Code, Gemini — same as git-ai)
|
|
95
|
+
// 3. Env vars as last resort
|
|
96
|
+
function buildHookCommand(tool) {
|
|
97
|
+
return (`node -e "/*${exports.HOOK_VERSION}*/` +
|
|
98
|
+
EXTRACT_MODEL_FROM_JSONL +
|
|
99
|
+
`let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{` +
|
|
73
100
|
`try{const j=JSON.parse(b);` +
|
|
74
|
-
`let m=j['${modelField}']||j.model;` +
|
|
75
|
-
`if(!m)return;` +
|
|
76
101
|
`let file;try{file=j?.tool_input?.path||j?.tool_input?.file_path||j?.toolInput?.path;}catch{}` +
|
|
102
|
+
`let m=j.model||j.model_name||j.modelName||j.modelID||j?.data?.model||null;` +
|
|
103
|
+
`if(!m&&j.transcript_path)m=extractModelFromJsonl(j.transcript_path);` +
|
|
104
|
+
`if(!m)m=process.env.CLAUDE_MODEL||process.env.ANTHROPIC_MODEL||null;` +
|
|
105
|
+
`if(!m)return;` +
|
|
77
106
|
WRITE_SENTINEL(tool) +
|
|
78
107
|
`}catch{}})"`);
|
|
79
108
|
}
|
|
80
|
-
|
|
81
|
-
const CLAUDE_HOOK_CMD = `node -e "/*${exports.HOOK_VERSION}*/let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{` +
|
|
82
|
-
`try{` +
|
|
83
|
-
`let m,file;` +
|
|
84
|
-
`try{const j=JSON.parse(b);m=j.model;file=j?.tool_input?.path||j?.tool_input?.file_path||j?.toolInput?.path;}catch{}` +
|
|
85
|
-
`if(!m)m=process.env.CLAUDE_MODEL||process.env.ANTHROPIC_MODEL;` +
|
|
86
|
-
`if(!m){try{const _fs=require('fs'),_p=require('path'),_os=require('os');` +
|
|
87
|
-
`const s=JSON.parse(_fs.readFileSync(_p.join(_os.homedir(),'.claude','settings.json'),'utf8'));` +
|
|
88
|
-
`m=s.model||s.defaultModel;}catch{}}` +
|
|
89
|
-
`if(!m)return;` +
|
|
90
|
-
WRITE_SENTINEL('claude-code') +
|
|
91
|
-
`}catch{}})"`;
|
|
109
|
+
const CLAUDE_HOOK_CMD = buildHookCommand('claude-code');
|
|
92
110
|
function readJson(filePath) {
|
|
93
111
|
try {
|
|
94
112
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
@@ -138,7 +156,7 @@ function installClaudeHooks() {
|
|
|
138
156
|
}
|
|
139
157
|
// ── Cursor ────────────────────────────────────────────────────────────────────
|
|
140
158
|
const CURSOR_HOOKS_PATH = path.join(os.homedir(), '.cursor', 'hooks.json');
|
|
141
|
-
const CURSOR_CMD = buildHookCommand('cursor'
|
|
159
|
+
const CURSOR_CMD = buildHookCommand('cursor');
|
|
142
160
|
function installCursorHooks() {
|
|
143
161
|
if (!fs.existsSync(path.join(os.homedir(), '.cursor')))
|
|
144
162
|
return;
|
|
@@ -166,7 +184,7 @@ const WINDSURF_HOOK_PATHS = [
|
|
|
166
184
|
path.join(os.homedir(), '.codeium', 'hooks.json'),
|
|
167
185
|
];
|
|
168
186
|
const WINDSURF_EVENTS = ['pre_write_code', 'post_write_code', 'pre_run_command', 'post_run_command'];
|
|
169
|
-
const WINDSURF_CMD = buildHookCommand('windsurf'
|
|
187
|
+
const WINDSURF_CMD = buildHookCommand('windsurf');
|
|
170
188
|
function installWindsurfHooks() {
|
|
171
189
|
if (!fs.existsSync(path.join(os.homedir(), '.codeium')))
|
|
172
190
|
return;
|
|
@@ -192,7 +210,7 @@ function installWindsurfHooks() {
|
|
|
192
210
|
}
|
|
193
211
|
// ── Codex ─────────────────────────────────────────────────────────────────────
|
|
194
212
|
const CODEX_HOOKS_PATH = path.join(os.homedir(), '.codex', 'hooks.json');
|
|
195
|
-
const CODEX_CMD = buildHookCommand('codex'
|
|
213
|
+
const CODEX_CMD = buildHookCommand('codex');
|
|
196
214
|
function installCodexHooks() {
|
|
197
215
|
if (!fs.existsSync(path.join(os.homedir(), '.codex')))
|
|
198
216
|
return;
|
|
@@ -218,13 +236,37 @@ const CLINE_HOOKS_DIR = path.join(os.homedir(), 'Documents', 'Cline', 'Hooks');
|
|
|
218
236
|
const CLINE_HOOK_SCRIPT = `#!/usr/bin/env node
|
|
219
237
|
// ${exports.HOOK_VERSION}
|
|
220
238
|
const FILE_WRITE_TOOLS = new Set(['write_to_file','apply_diff','insert_content','search_and_replace']);
|
|
239
|
+
function extractModelFromJsonl(tp) {
|
|
240
|
+
try {
|
|
241
|
+
const fs = require('fs');
|
|
242
|
+
const stat = fs.statSync(tp);
|
|
243
|
+
const readSize = Math.min(51200, stat.size);
|
|
244
|
+
const buf = Buffer.alloc(readSize);
|
|
245
|
+
const fd = fs.openSync(tp, 'r');
|
|
246
|
+
fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
|
|
247
|
+
fs.closeSync(fd);
|
|
248
|
+
const lines = buf.toString('utf8').split('\\n').reverse();
|
|
249
|
+
for (const line of lines) {
|
|
250
|
+
if (!line.trim()) continue;
|
|
251
|
+
try {
|
|
252
|
+
const o = JSON.parse(line);
|
|
253
|
+
if (o.type === 'session.model_change' && o.data?.newModel) return o.data.newModel;
|
|
254
|
+
const m = o?.message?.model || o?.model;
|
|
255
|
+
if (m && m !== '<synthetic>') return m;
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
} catch {}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
221
261
|
let buf = '';
|
|
222
262
|
process.stdin.on('data', c => buf += c);
|
|
223
263
|
process.stdin.on('end', () => {
|
|
224
264
|
try {
|
|
225
265
|
const j = JSON.parse(buf);
|
|
226
266
|
if (!FILE_WRITE_TOOLS.has(j.toolName)) return;
|
|
227
|
-
const m = j.model || j.
|
|
267
|
+
const m = j.model || j.model_name || j.modelName || j.modelID || j?.data?.model
|
|
268
|
+
|| (j.transcript_path ? extractModelFromJsonl(j.transcript_path) : null)
|
|
269
|
+
|| j.preToolUse?.model || '';
|
|
228
270
|
const file = j.toolInput?.path || j.toolInput?.file_path || j.tool_input?.path || undefined;
|
|
229
271
|
const fs = require('fs'), p = require('path'), os = require('os');
|
|
230
272
|
const dir = p.join(process.cwd(), '.omnitype');
|
|
@@ -255,7 +297,7 @@ function installClineHooks() {
|
|
|
255
297
|
// ── Gemini CLI ────────────────────────────────────────────────────────────────
|
|
256
298
|
// ~/.gemini/settings.json — BeforeTool / AfterTool with nested hooks array
|
|
257
299
|
const GEMINI_SETTINGS_PATH = path.join(os.homedir(), '.gemini', 'settings.json');
|
|
258
|
-
const GEMINI_CMD = buildHookCommand('gemini-cli'
|
|
300
|
+
const GEMINI_CMD = buildHookCommand('gemini-cli');
|
|
259
301
|
const GEMINI_HOOK_ENTRY = { matcher: '*', hooks: [{ type: 'command', command: GEMINI_CMD }] };
|
|
260
302
|
function installGeminiHooks() {
|
|
261
303
|
if (!fs.existsSync(path.join(os.homedir(), '.gemini')))
|
|
@@ -277,7 +319,7 @@ function installGeminiHooks() {
|
|
|
277
319
|
// ── Droid (Factory) ───────────────────────────────────────────────────────────
|
|
278
320
|
// ~/.factory/settings.json — PreToolUse with nested hooks array
|
|
279
321
|
const DROID_SETTINGS_PATH = path.join(os.homedir(), '.factory', 'settings.json');
|
|
280
|
-
const DROID_CMD = buildHookCommand('droid'
|
|
322
|
+
const DROID_CMD = buildHookCommand('droid');
|
|
281
323
|
const DROID_HOOK_ENTRY = { matcher: '*', hooks: [{ type: 'command', command: DROID_CMD }] };
|
|
282
324
|
function installDroidHooks() {
|
|
283
325
|
if (!fs.existsSync(path.join(os.homedir(), '.factory')))
|
|
@@ -294,7 +336,7 @@ function installDroidHooks() {
|
|
|
294
336
|
// ── Firebender ────────────────────────────────────────────────────────────────
|
|
295
337
|
// ~/.firebender/hooks.json — preToolUse / postToolUse flat command arrays
|
|
296
338
|
const FIREBENDER_HOOKS_PATH = path.join(os.homedir(), '.firebender', 'hooks.json');
|
|
297
|
-
const FIREBENDER_CMD = buildHookCommand('firebender'
|
|
339
|
+
const FIREBENDER_CMD = buildHookCommand('firebender');
|
|
298
340
|
function installFirebenderHooks() {
|
|
299
341
|
if (!fs.existsSync(path.join(os.homedir(), '.firebender')))
|
|
300
342
|
return;
|
|
@@ -438,7 +480,7 @@ function installPiPlugin() {
|
|
|
438
480
|
// Copilot supports a hooks file at ~/.copilot/hooks/omnitype.json with
|
|
439
481
|
// PreToolUse / PostToolUse arrays — same shape as Claude Code.
|
|
440
482
|
const COPILOT_HOOKS_PATH = path.join(os.homedir(), '.copilot', 'hooks', 'omnitype.json');
|
|
441
|
-
const COPILOT_CMD = buildHookCommand('copilot'
|
|
483
|
+
const COPILOT_CMD = buildHookCommand('copilot');
|
|
442
484
|
function installCopilotHooks() {
|
|
443
485
|
// Require either ~/.copilot or ~/.vscode or VS Code settings to exist
|
|
444
486
|
const hasVscode = fs.existsSync(path.join(os.homedir(), '.vscode')) ||
|