llm-switcher 1.2.6 → 1.2.8

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,132 @@
1
+ // Installing the launch hook into the two coding tools, without editing any configuration of theirs.
2
+ //
3
+ // Claude Code loads any folder under a skills directory that holds `.claude-plugin/plugin.json` as
4
+ // a plugin, on the next session, with no marketplace and no install step. Codex loads
5
+ // `$CODEX_HOME/hooks.json` by itself. So both tools get the hook from a file of their own, and
6
+ // `settings.json` and `config.toml` are never touched.
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+
14
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
+ const { renderClaudePlugin, renderClaudeHooks, renderCodexHooks, installPlugin, uninstallPlugin, pluginStatus } =
16
+ await import(pathToFileURL(path.join(ROOT, 'plugin.mjs')).href);
17
+
18
+ function dirs(t) {
19
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-plug-'));
20
+ t.after(() => fs.rmSync(home, { recursive: true, force: true }));
21
+ const claudeSkillsDir = path.join(home, '.claude', 'skills');
22
+ const codexHome = path.join(home, '.codex');
23
+ fs.mkdirSync(claudeSkillsDir, { recursive: true });
24
+ fs.mkdirSync(codexHome, { recursive: true });
25
+ return { home, claudeSkillsDir, codexHome, opts: { claudeSkillsDir, codexHome } };
26
+ }
27
+
28
+ const readJson = (f) => JSON.parse(fs.readFileSync(f, 'utf8'));
29
+
30
+ test('the Claude plugin manifest and hooks name the hook script and the tool claude', () => {
31
+ const manifest = JSON.parse(renderClaudePlugin());
32
+ assert.equal(typeof manifest.name, 'string');
33
+ assert.ok(manifest.name.length > 0, 'a plugin needs a name, it becomes the skills-dir id');
34
+ assert.ok(manifest.version, 'and a version');
35
+ const hooks = JSON.parse(renderClaudeHooks());
36
+ const entry = hooks.hooks.SessionStart[0].hooks[0];
37
+ assert.equal(entry.type, 'command');
38
+ assert.match(JSON.stringify(entry), /hook-status\.mjs/);
39
+ assert.match(JSON.stringify(entry), /"claude"/, 'the claude plugin asks about claude');
40
+ assert.doesNotMatch(JSON.stringify(entry), /codex/);
41
+ });
42
+
43
+ test('the Codex hooks file asks about codex and matches a fresh start or a resume', () => {
44
+ const hooks = JSON.parse(renderCodexHooks());
45
+ const group = hooks.hooks.SessionStart[0];
46
+ assert.match(group.matcher, /startup/, 'the first open of a session');
47
+ assert.match(group.matcher, /resume/, 'and a resumed session, which is the bypass this exists for');
48
+ const entry = group.hooks[0];
49
+ assert.equal(entry.type, 'command');
50
+ assert.match(entry.command, /hook-status\.mjs/);
51
+ assert.match(entry.command, /codex/);
52
+ });
53
+
54
+ test('install writes a plugin for Claude Code and a hooks file for Codex, and repeats cleanly', (t) => {
55
+ const { claudeSkillsDir, codexHome, opts } = dirs(t);
56
+ const first = installPlugin(opts);
57
+ assert.deepEqual(first.failed, [], 'nothing failed');
58
+ assert.deepEqual(first.installed.sort(), ['claude', 'codex']);
59
+
60
+ const manifest = path.join(claudeSkillsDir, 'llm-switcher-status', '.claude-plugin', 'plugin.json');
61
+ assert.ok(fs.existsSync(manifest), 'the manifest is what makes the folder a plugin');
62
+ assert.ok(fs.existsSync(path.join(claudeSkillsDir, 'llm-switcher-status', 'hooks', 'hooks.json')));
63
+ assert.ok(fs.existsSync(path.join(codexHome, 'hooks.json')));
64
+
65
+ // The whole point: neither tool's own configuration file is created or changed.
66
+ assert.equal(fs.existsSync(path.join(codexHome, 'config.toml')), false, 'config.toml is never written');
67
+
68
+ const second = installPlugin(opts);
69
+ assert.deepEqual(second.failed, [], 'a second install is a no-op that still succeeds');
70
+ assert.equal(JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8')).hooks.SessionStart.length, 1,
71
+ 'and it does not add a second copy of our entry');
72
+ });
73
+
74
+ test('install keeps a Codex hook that belongs to someone else', (t) => {
75
+ const { codexHome, opts } = dirs(t);
76
+ fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
77
+ description: 'the rule gate of this machine',
78
+ hooks: {
79
+ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'node rule-gate.js' }] }],
80
+ SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: 'node someone-else.js' }] }]
81
+ }
82
+ }, null, 2));
83
+
84
+ installPlugin(opts);
85
+ const after = readJson(path.join(codexHome, 'hooks.json'));
86
+ assert.equal(after.hooks.PreToolUse.length, 1, 'another event is untouched');
87
+ assert.match(JSON.stringify(after.hooks.PreToolUse), /rule-gate/);
88
+ const commands = after.hooks.SessionStart.flatMap(g => g.hooks.map(h => h.command));
89
+ assert.ok(commands.some(c => /someone-else\.js/.test(c)), 'the other SessionStart hook survives');
90
+ assert.ok(commands.some(c => /hook-status\.mjs/.test(c)), 'and ours is added beside it');
91
+
92
+ const removed = uninstallPlugin(opts);
93
+ assert.deepEqual(removed.failed, []);
94
+ const end = readJson(path.join(codexHome, 'hooks.json'));
95
+ const endCommands = JSON.stringify(end);
96
+ assert.match(endCommands, /someone-else\.js/, 'uninstall leaves the other hook in place');
97
+ assert.match(endCommands, /rule-gate/);
98
+ assert.doesNotMatch(endCommands, /hook-status\.mjs/, 'and takes only ours away');
99
+ });
100
+
101
+ test('install refuses a Codex hooks file it cannot parse, and changes nothing', (t) => {
102
+ const { codexHome, opts } = dirs(t);
103
+ const f = path.join(codexHome, 'hooks.json');
104
+ fs.writeFileSync(f, '{ not json at all');
105
+ const r = installPlugin(opts);
106
+ assert.equal(fs.readFileSync(f, 'utf8'), '{ not json at all', 'a file that may hold real hooks is never overwritten');
107
+ assert.ok(r.failed.some(x => x.tool === 'codex'), 'and the refusal is reported');
108
+ assert.ok(r.installed.includes('claude'), 'while the other tool still gets its plugin');
109
+ });
110
+
111
+ test('install leaves a folder that is not our plugin alone', (t) => {
112
+ const { claudeSkillsDir, opts } = dirs(t);
113
+ const dir = path.join(claudeSkillsDir, 'llm-switcher-status');
114
+ fs.mkdirSync(path.join(dir, '.claude-plugin'), { recursive: true });
115
+ fs.writeFileSync(path.join(dir, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'someone-elses-work' }));
116
+ const r = installPlugin(opts);
117
+ assert.match(fs.readFileSync(path.join(dir, '.claude-plugin', 'plugin.json'), 'utf8'), /someone-elses-work/);
118
+ assert.ok(r.failed.some(x => x.tool === 'claude'));
119
+ });
120
+
121
+ test('status reports each tool, and says plainly when nothing is installed', (t) => {
122
+ const { opts } = dirs(t);
123
+ const before = pluginStatus(opts);
124
+ assert.equal(before.claude.installed, false);
125
+ assert.equal(before.codex.installed, false);
126
+ installPlugin(opts);
127
+ const after = pluginStatus(opts);
128
+ assert.equal(after.claude.installed, true);
129
+ assert.equal(after.codex.installed, true);
130
+ assert.match(after.claude.path, /llm-switcher-status/);
131
+ assert.match(after.codex.path, /hooks\.json$/);
132
+ });
@@ -324,7 +324,7 @@ test('Real User Sim 3: Gemini / Vertex healing of empty & complex tool schemas',
324
324
  await post('/api/switch', { tool: 'claude', profile: 'chat' });
325
325
  });
326
326
 
327
- test('Real User Sim 4: Codex WebSocket turns (warmup -> prompt -> apply_patch -> continue)', async () => {
327
+ test('Real User Sim 4: Codex WebSocket turns (warmup -> prompt -> apply_patch -> continue)', { skip: typeof WebSocket === 'undefined' && 'global WebSocket needs Node 22+' }, async () => {
328
328
  const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/v1/responses`);
329
329
  const messages = [];
330
330
 
@@ -387,7 +387,7 @@ test('Real User Sim 4: Codex WebSocket turns (warmup -> prompt -> apply_patch ->
387
387
  ws.close();
388
388
  });
389
389
 
390
- test('Real User Sim 5: High-concurrency dual-tool execution (Claude streaming + Codex WS simultaneously)', async () => {
390
+ test('Real User Sim 5: High-concurrency dual-tool execution (Claude streaming + Codex WS simultaneously)', { skip: typeof WebSocket === 'undefined' && 'global WebSocket needs Node 22+' }, async () => {
391
391
  const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/v1/responses`);
392
392
  await new Promise(r => { ws.onopen = r; });
393
393
  const wsMessages = [];
@@ -16,6 +16,8 @@ const HAS_XMLLINT = (() => { try { execFileSync('xmllint', ['--version'], { stdi
16
16
  test('serviceEnv passes only the variables that change where the gateway reads and writes', () => {
17
17
  const env = serviceEnv({ CLAUDE_CONFIG_DIR: '/c', LLM_SWITCHER_CONFIG: '/cfg.json', LLM_SWITCHER_BLINDFOLD_CERTS: '', HOME: '/h', PATH: '/bin' });
18
18
  assert.deepEqual(env, [['CLAUDE_CONFIG_DIR', '/c'], ['LLM_SWITCHER_CONFIG', '/cfg.json']]);
19
+ // The data folder override must reach the service too, or it reads another config.json.
20
+ assert.deepEqual(serviceEnv({ LLM_SWITCHER_HOME: '/data' }), [['LLM_SWITCHER_HOME', '/data']]);
19
21
  });
20
22
 
21
23
  test('systemd unit quotes the command and carries the environment', () => {
@@ -282,6 +282,22 @@ test('the POSIX shims source launch files only when this account owns them', ()
282
282
 
283
283
  // ISS-CC-LS-002: on zsh a later line in .zshrc or .zprofile can prepend npm-global or Homebrew
284
284
  // ahead of the shim. The hint names both files and says the line must come last.
285
+ test('pathOrderHint gives a fish user the fish file and fish syntax', () => {
286
+ // `export PATH=...` is not fish syntax. A fish user who pasted the old hint got an error, and
287
+ // the hint named ~/.profile, which fish does not read.
288
+ const shell = process.env.SHELL;
289
+ process.env.SHELL = '/usr/bin/fish';
290
+ try {
291
+ assert.deepEqual(suggestedRcFiles('linux'), [path.join(os.homedir(), '.config', 'fish', 'config.fish')]);
292
+ const text = pathOrderHint('linux').join(' ');
293
+ assert.ok(text.includes('config.fish'), text);
294
+ assert.ok(text.includes('fish_add_path'), text);
295
+ assert.ok(!text.includes('export PATH='), text);
296
+ } finally {
297
+ if (shell === undefined) delete process.env.SHELL; else process.env.SHELL = shell;
298
+ }
299
+ });
300
+
285
301
  test('pathOrderHint tells a zsh user to put the export last in .zshrc and .zprofile', () => {
286
302
  const shell = process.env.SHELL;
287
303
  process.env.SHELL = '/bin/zsh';
@@ -340,3 +356,99 @@ test('A8: the Windows shim scrubs a loopback base URL and resolves every subrout
340
356
  assert.match(child.stdout || '', /HTTPS=\[http:\/\/127\.0\.0\.1:3457\]/,
341
357
  'R2: the proxy comes from the env file of this tool');
342
358
  });
359
+
360
+ // ---- The route notice: the launcher tells the person the switcher took the traffic -------------
361
+
362
+ // A person opens `claude`, the switcher routes it to another provider, and nothing on screen says
363
+ // so. The shim therefore raises a notice at launch. It is guarded by TOOL_ACTIVE, so a tool whose
364
+ // profile is off stays silent: the guard is the same one that decides whether to source the env.
365
+ test('the route notice is guarded by TOOL_ACTIVE and names only this tool route file', () => {
366
+ for (const platform of ['win32', 'linux']) {
367
+ const claude = renderShim('claude', platform);
368
+ const codex = renderShim('codex', platform);
369
+ assert.match(claude, /route-claude\.txt/, `${platform}: the claude shim reads its own route file`);
370
+ assert.doesNotMatch(claude, /route-codex/, `${platform}: and never the other tool file`);
371
+ assert.match(codex, /route-codex\.txt/);
372
+ assert.doesNotMatch(codex, /route-claude/);
373
+ // Everything that raises the notice sits after the TOOL_ACTIVE decision, never before it.
374
+ const guard = platform === 'win32' ? claude.indexOf('set "TOOL_ACTIVE=1"') : claude.indexOf('TOOL_ACTIVE=1');
375
+ assert.ok(guard > 0, `${platform}: the guard exists`);
376
+ assert.ok(claude.indexOf('route-claude.txt') > guard, `${platform}: the notice comes after the guard`);
377
+ }
378
+ });
379
+
380
+ test('the POSIX shim prints the route line for an active tool and nothing for one that is off', (t) => {
381
+ if (process.platform === 'win32') return t.skip('posix only');
382
+ const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimfake-'));
383
+ t.after(() => fs.rmSync(fakeDir, { recursive: true, force: true }));
384
+ fs.writeFileSync(path.join(fakeDir, 'claude'), '#!/bin/sh\necho ran\n');
385
+ fs.chmodSync(path.join(fakeDir, 'claude'), 0o755);
386
+
387
+ const run = ({ active, route }) => {
388
+ for (const f of fs.readdirSync(STATE)) fs.rmSync(path.join(STATE, f), { force: true });
389
+ if (active) {
390
+ fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
391
+ fs.writeFileSync(path.join(STATE, 'env-claude.sh'), "export HTTPS_PROXY='http://127.0.0.1:3457'\n");
392
+ }
393
+ fs.writeFileSync(path.join(STATE, 'route-claude.txt'), route);
394
+ return spawnSync(path.join(RENDER_DIR, 'claude'), [], {
395
+ encoding: 'utf8', timeout: 15000,
396
+ env: { ...process.env, PATH: [RENDER_DIR, fakeDir, '/usr/bin:/bin', NODE_DIR].join(path.delimiter) }
397
+ });
398
+ };
399
+
400
+ const on = run({ active: true, route: 'claude -> cl | intact.example.io | gemini | 1M\n' });
401
+ assert.match(on.stderr, /intact\.example\.io/, 'an active tool shows where its traffic goes');
402
+ assert.match(on.stdout, /ran/, 'and the real binary still runs');
403
+
404
+ const off = run({ active: false, route: 'claude -> cl | intact.example.io | gemini | 1M\n' });
405
+ assert.doesNotMatch(off.stderr, /intact\.example\.io/, 'a tool that is off says nothing, even with a stale route file');
406
+ assert.match(off.stdout, /ran/);
407
+ });
408
+
409
+ // The Windows notice is a detached toast, so the only way to prove it fires is to put a fake
410
+ // `powershell` on PATH and read what the shim asked it to do.
411
+ test('the Windows shim raises the toast only while this tool is routed', (t) => {
412
+ if (process.platform !== 'win32') return t.skip('cmd.exe only');
413
+ const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimwintoast-'));
414
+ const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimfaketoast-'));
415
+ t.after(() => {
416
+ fs.rmSync(shimDir, { recursive: true, force: true });
417
+ fs.rmSync(fakeDir, { recursive: true, force: true });
418
+ });
419
+ const log = path.join(fakeDir, 'toast.log');
420
+ fs.writeFileSync(path.join(fakeDir, 'claude.cmd'), '@echo off\r\necho ran\r\n');
421
+ fs.writeFileSync(path.join(fakeDir, 'powershell.cmd'), `@echo off\r\necho %*>>"${log}"\r\n`);
422
+ const shim = path.join(shimDir, 'shim.cmd');
423
+ fs.writeFileSync(shim, renderShim('claude', 'win32'), 'utf8');
424
+
425
+ const run = ({ active }) => {
426
+ for (const f of fs.readdirSync(STATE)) fs.rmSync(path.join(STATE, f), { force: true });
427
+ fs.rmSync(log, { force: true });
428
+ if (active) {
429
+ fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
430
+ fs.writeFileSync(path.join(STATE, 'env-claude.cmd'), 'SET "HTTPS_PROXY=http://127.0.0.1:3457"\r\n');
431
+ }
432
+ fs.writeFileSync(path.join(STATE, 'route-claude.txt'), 'claude -> cl | intact.example.io | gemini | 1M\r\n');
433
+ const child = spawnSync('cmd.exe', ['/d', '/c', shim, '--version'], {
434
+ encoding: 'utf8', timeout: 30000,
435
+ env: {
436
+ ...process.env,
437
+ PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)]
438
+ .join(path.delimiter)
439
+ }
440
+ });
441
+ // `start /b` detaches, so the fake may still be writing when the shim has already exited.
442
+ for (let i = 0; i < 60 && !fs.existsSync(log); i++) execFileSync(process.execPath, ['-e', 'setTimeout(()=>{},50)']);
443
+ return { out: child.stdout || '', asked: fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '' };
444
+ };
445
+
446
+ const on = run({ active: true });
447
+ assert.match(on.out, /ran/, 'the real binary still runs');
448
+ assert.match(on.asked, /route-claude\.txt/, 'the toast is asked for, with this tool route file');
449
+ assert.match(on.asked, /notify-route\.ps1/, 'through the notifier that ships with the switcher');
450
+
451
+ const off = run({ active: false });
452
+ assert.match(off.out, /ran/);
453
+ assert.equal(off.asked, '', 'a tool that is off raises no toast');
454
+ });
@@ -7,7 +7,7 @@ import {
7
7
  modelSlotsForProfile, modelForSlot, primaryModel, codexPublicModel,
8
8
  certCoversHost, blindfoldPreflight, ROOT_DIR, openLog, probeGateway, isSafeModelName,
9
9
  getMigrationCollision, getMigrationError, getConfigLoadError, saveConfig, loadConfig,
10
- migrateConfigInMemory, saveConfigAtomicCAS, TOOLS, validateProfileInput, needsMigration
10
+ migrateConfigInMemory, saveConfigAtomicCAS, TOOLS, validateProfileInput, needsMigration, hasPlaceholder
11
11
  } from '../state.mjs';
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
@@ -52,6 +52,76 @@ test('activateProfile only assigns compatible tools; deactivateProfile leaves th
52
52
  assert.deepEqual(cfg.activeProfiles, { claude: null, codex: null });
53
53
  });
54
54
 
55
+ test('switching a migrated config does not make it need migration again', () => {
56
+ // A legacy pointer written on every switch re-ran the migration on the next load and left one
57
+ // more config.json.bak-* (with the API keys) in the data folder each time.
58
+ const cfg = {
59
+ port: 4000,
60
+ activeProfiles: { claude: null, codex: null },
61
+ profiles: {
62
+ router: { tool: 'claude', apiKey: 'sk-1', baseURL: 'https://r/v1', defaultModels: { opus: 'o' } },
63
+ codexOnly: { tool: 'codex', apiKey: 'sk-2', baseURL: 'https://c/v1', defaultModels: { main: 'gpt-main' } }
64
+ }
65
+ };
66
+ assert.equal(needsMigration(cfg), false, 'fixture is already migrated');
67
+ assert.equal(activateProfile(cfg, 'router'), null);
68
+ assert.equal(setTargetProfile(cfg, 'codex', 'codexOnly'), null);
69
+ assert.equal(Object.hasOwn(cfg, 'activeProfile'), false);
70
+ assert.equal(needsMigration(cfg), false);
71
+ });
72
+
73
+ test('hasPlaceholder spots the example config values', () => {
74
+ assert.equal(hasPlaceholder({ baseURL: 'https://YOUR-ROUTER-HOST/v1', apiKey: 'sk-real' }), true);
75
+ assert.equal(hasPlaceholder({ baseURL: 'https://r/v1', apiKey: 'REPLACE-ME' }), true);
76
+ assert.equal(hasPlaceholder({ apiKey: 'sk-real' }), true, 'no baseURL');
77
+ assert.equal(hasPlaceholder({ baseURL: 'https://r/v1', apiKey: 'sk-real' }), false);
78
+ assert.equal(hasPlaceholder({ baseURL: 'http://127.0.0.1:11434/v1' }), false, 'a keyless local backend is fine');
79
+ });
80
+
81
+ test('the interceptor port follows LLM_SWITCHER_BLINDFOLD_PORT and never lands on the gateway port', () => {
82
+ // The gateway reads --port and LLM_SWITCHER_PORT through resolvePort. The interceptor read only
83
+ // config.json, so a second instance collided and HTTPS_PROXY named a port nothing listened on.
84
+ const saved = process.env.LLM_SWITCHER_BLINDFOLD_PORT;
85
+ delete process.env.LLM_SWITCHER_BLINDFOLD_PORT;
86
+ const cfg = {
87
+ port: 4000,
88
+ activeProfiles: { claude: 'router', codex: null },
89
+ profiles: { router: { tool: 'claude', apiKey: 'sk-1', baseURL: 'https://r/v1', defaultModels: { opus: 'o' } } }
90
+ };
91
+ try {
92
+ assert.equal(computeLaunchState(cfg, 4000).blindfold.port, 3457, 'the default');
93
+ assert.equal(computeLaunchState({ ...cfg, blindfold: { port: 4444 } }, 4000).blindfold.port, 4444, 'the file');
94
+ process.env.LLM_SWITCHER_BLINDFOLD_PORT = '5555';
95
+ assert.equal(computeLaunchState({ ...cfg, blindfold: { port: 4444 } }, 4000).blindfold.port, 5555, 'the environment wins');
96
+ delete process.env.LLM_SWITCHER_BLINDFOLD_PORT;
97
+ // A gateway moved onto 3457 by --port or by the environment must push the interceptor off it.
98
+ const st = computeLaunchState(cfg, 3457);
99
+ assert.notEqual(st.blindfold.port, 3457, 'the interceptor moved off the gateway port');
100
+ const proxy = st.envClaude.find(([k]) => k === 'HTTPS_PROXY');
101
+ assert.equal(proxy[1], `http://127.0.0.1:${st.blindfold.port}`, 'HTTPS_PROXY names the real port');
102
+ } finally {
103
+ if (saved === undefined) delete process.env.LLM_SWITCHER_BLINDFOLD_PORT;
104
+ else process.env.LLM_SWITCHER_BLINDFOLD_PORT = saved;
105
+ }
106
+ });
107
+
108
+ test('deleteProfile does not make a migrated config need migration again', () => {
109
+ // Reachable from the dashboard Delete Profile button via POST /api/delete-profile, which
110
+ // persists. A legacy pointer written here revives the backup leak the switch path just lost.
111
+ const cfg = {
112
+ port: 4000,
113
+ activeProfiles: { claude: null, codex: 'codexOnly' },
114
+ profiles: {
115
+ router: { tool: 'claude', apiKey: 'sk-1', baseURL: 'https://r/v1', defaultModels: { opus: 'o' } },
116
+ codexOnly: { tool: 'codex', apiKey: 'sk-2', baseURL: 'https://c/v1', defaultModels: { main: 'gpt-main' } }
117
+ }
118
+ };
119
+ assert.equal(needsMigration(cfg), false, 'fixture is already migrated');
120
+ assert.equal(deleteProfile(cfg, 'codexOnly'), null);
121
+ assert.equal(Object.hasOwn(cfg, 'activeProfile'), false);
122
+ assert.equal(needsMigration(cfg), false);
123
+ });
124
+
55
125
  test('deleteProfile unassigns targets that pointed at it', () => {
56
126
  const cfg = makeCfg();
57
127
  activateProfile(cfg, 'codexOnly');
@@ -602,15 +672,21 @@ test('isSafeModelName refuses names that TOML reads as something other than a st
602
672
  test('launch-file writers take turns through a lock, and a dead holder does not block them', async (t) => {
603
673
  const { dir, env } = tmpDirs(t);
604
674
  const lock = path.join(dir, '.launch.lock');
675
+ const startedFile = path.join(dir, 'writer.started');
605
676
  // A live holder: this test process. The child must wait until the lock is gone.
606
677
  fs.writeFileSync(lock, String(process.pid));
607
- setTimeout(() => fs.rmSync(lock, { force: true }), 400);
608
- const started = Date.now();
609
- const r = await new Promise((resolve, reject) => {
678
+ const done = new Promise((resolve, reject) => {
610
679
  execFileCb(process.execPath, ['--input-type=module', '-e',
611
- `const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
680
+ `const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); (await import('node:fs')).writeFileSync(${JSON.stringify(startedFile)}, ''); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
612
681
  { env: { ...process.env, LLM_SWITCHER_PORT: '', ...env }, encoding: 'utf8' }, (err, out) => (err ? reject(err) : resolve(Number(out.trim()))));
613
682
  });
683
+ // The 400 ms count from the writer's start, the same way the next test does it. A spawn plus an
684
+ // import measured 163 to 541 ms on Node 18, so a fixed timer left under 100 ms of wait and the
685
+ // assertion below failed on a loaded machine.
686
+ for (let i = 0; i < 1000 && !fs.existsSync(startedFile); i++) await new Promise(r => setTimeout(r, 10));
687
+ const started = Date.now();
688
+ setTimeout(() => fs.rmSync(lock, { force: true }), 400);
689
+ const r = await done;
614
690
  // Without the lock the write takes a few ms; with it the child waits for the release at 400 ms.
615
691
  assert.ok(r >= 100, `the writer waited ${r} ms for the live holder`);
616
692
  assert.ok(Date.now() - started >= 380);
@@ -1496,3 +1572,52 @@ test('A5: config.example.json loads and passes validation without requiring migr
1496
1572
  }
1497
1573
  }
1498
1574
  });
1575
+
1576
+ // ---- Route lines: what the launcher shows when it routes a tool -------------------------------
1577
+
1578
+ // A person opens `claude` and cannot see that the switcher took over the traffic. The launcher
1579
+ // therefore reads one line per tool, written beside that tool's env file. The line exists exactly
1580
+ // when that tool is routed, so a tool whose profile is off stays silent with no extra condition.
1581
+ test('each routed tool gets a route line; a tool that is off gets an empty one', (t) => {
1582
+ const { dir, env } = tmpDirs(t);
1583
+ fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({
1584
+ port: 4000,
1585
+ activeProfiles: { claude: 'cl', codex: 'cx' },
1586
+ profiles: {
1587
+ cl: {
1588
+ name: 'Intact Claude', baseURL: 'https://intact.example.io/v1', apiKey: 'sk-secret-claude',
1589
+ defaultModels: { opus: 'gemini-3.8-flash' }, model1M: { opus: true }
1590
+ },
1591
+ cx: {
1592
+ inFormat: 'responses', baseURL: 'https://other.example.io/v1', apiKey: 'sk-secret-codex',
1593
+ defaultModels: { main: 'gpt-5.6-sol' }
1594
+ }
1595
+ }
1596
+ }), { mode: 0o600 });
1597
+
1598
+ const r = runState(env, `
1599
+ const read = (f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } };
1600
+ s.applyLaunchState(s.loadConfig(), 4000);
1601
+ const on = { claude: read(s.paths.routeClaude), codex: read(s.paths.routeCodex) };
1602
+ s.emptyToolEnvFiles('claude');
1603
+ const oneOff = { claude: read(s.paths.routeClaude), codex: read(s.paths.routeCodex) };
1604
+ s.applyLaunchState(s.loadConfig(), 4000);
1605
+ s.clearLaunchState(4000);
1606
+ const allOff = { claude: read(s.paths.routeClaude), codex: read(s.paths.routeCodex) };
1607
+ return { on, oneOff, allOff };
1608
+ `);
1609
+
1610
+ assert.match(r.on.claude, /\bcl\b/, 'the line names the profile that serves claude');
1611
+ assert.match(r.on.claude, /intact\.example\.io/, 'and the host the traffic goes to');
1612
+ assert.match(r.on.claude, /gemini-3\.8-flash/, 'and the model it maps to');
1613
+ assert.match(r.on.claude, /1M/, 'and the 1M window, because that changes what the session costs');
1614
+ assert.match(r.on.codex, /\bcx\b/);
1615
+ assert.match(r.on.codex, /other\.example\.io/);
1616
+ assert.doesNotMatch(r.on.codex, /intact\.example\.io/, 'one tool never shows the other tool route');
1617
+ assert.doesNotMatch(`${r.on.claude}${r.on.codex}`, /sk-secret/, 'a route line never carries the API key');
1618
+
1619
+ assert.equal(r.oneOff.claude.trim(), '', 'the tool switched off has no route line');
1620
+ assert.notEqual(r.oneOff.codex.trim(), '', 'the other tool keeps its own');
1621
+ assert.equal(r.allOff.claude.trim(), '', 'switch off leaves no route line for either tool');
1622
+ assert.equal(r.allOff.codex.trim(), '');
1623
+ });
package/tests/ui.test.mjs CHANGED
@@ -41,7 +41,7 @@ test('Enter in a profile field does not submit the form', () => {
41
41
 
42
42
  test('the overlay closes the dialog only for a press that started on the overlay', () => {
43
43
  assert.match(html, /id="profile-modal-overlay"[^>]*onmousedown="overlayPressed = event\.target === this"/);
44
- assert.match(html, /id="profile-modal-overlay"[^>]*onclick="if \(overlayPressed && event\.target === this\) closeProfileModal\(\)"/);
44
+ assert.match(html, /id="profile-modal-overlay"[^>]*onclick="if \(overlayPressed && event\.target === this\) requestCloseProfileModal\(\)"/);
45
45
  });
46
46
 
47
47
  test('Escape inside a dialog field is left to the field', () => {