llm-switcher 1.2.12 → 1.3.1

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/state.mjs CHANGED
@@ -35,8 +35,8 @@ if (DATA_DIR !== ROOT_DIR) {
35
35
  const LEGACY_TARGET_TOOL = { anthropic: 'claude', responses: 'codex' };
36
36
  // Migration input only: the pointer keys an unmigrated config may still carry.
37
37
  const LEGACY_TARGETS = ['anthropic', 'responses', 'openai-chat', 'vertex'];
38
- export const TOOLS = ['claude', 'codex'];
39
- export const COMMAND_WORDS = new Set(['claude', 'codex', 'on', 'off', 'status', 'doctor', 'ui']);
38
+ export const TOOLS = ['claude', 'codex', 'agy'];
39
+ export const COMMAND_WORDS = new Set(['claude', 'codex', 'agy', 'on', 'off', 'status', 'doctor', 'ui']);
40
40
  export const DEFAULT_PORT = 3456;
41
41
  export const DEFAULT_BLINDFOLD_PORT = 3457;
42
42
  // Codex with ChatGPT sign-in calls https://chatgpt.com/backend-api/codex.
@@ -53,6 +53,9 @@ export const CLAUDE_MODEL_SLOTS = ['opus', 'sonnet', 'haiku', 'fable'];
53
53
  // - subagent <-> `agents.default_subagent_model` (spawned agents)
54
54
  // No fast/fallback in the docs — those are custom keys, read only for backward compatibility.
55
55
  export const CODEX_MODEL_SLOTS = ['main', 'review', 'subagent'];
56
+ // The Antigravity CLI (agy) picks its own model per call. One slot names the upstream model of its
57
+ // agent calls; a '*' in the value stands for the model id that agy sent (antigravity/* = Bifrost).
58
+ export const AGY_MODEL_SLOTS = ['main'];
56
59
  export const CHAT_MODEL_SLOTS = ['default'];
57
60
  export const VERTEX_MODEL_SLOTS = ['default'];
58
61
 
@@ -134,10 +137,13 @@ export const paths = {
134
137
  envClaudeSh: path.join(STATE_DIR, 'env-claude.sh'),
135
138
  envCodexCmd: path.join(STATE_DIR, 'env-codex.cmd'),
136
139
  envCodexSh: path.join(STATE_DIR, 'env-codex.sh'),
140
+ envAgyCmd: path.join(STATE_DIR, 'env-agy.cmd'),
141
+ envAgySh: path.join(STATE_DIR, 'env-agy.sh'),
137
142
  // One line per tool, written with that tool's env file: what the launcher shows the person at
138
143
  // launch. Empty exactly when the tool is not routed, so the launcher needs no second condition.
139
144
  routeClaude: path.join(STATE_DIR, 'route-claude.txt'),
140
145
  routeCodex: path.join(STATE_DIR, 'route-codex.txt'),
146
+ routeAgy: path.join(STATE_DIR, 'route-agy.txt'),
141
147
  // The gateway port of the last switch on. The shim scrubs a stale loopback base URL
142
148
  // against it, so a shell opened before a port change still reaches the right gateway.
143
149
  gatewayPort: path.join(STATE_DIR, 'gateway.port'),
@@ -447,7 +453,7 @@ function createCodexHalf(p) {
447
453
 
448
454
  export function validateProfileInput(p) {
449
455
  if (!p || typeof p !== 'object' || Array.isArray(p)) return 'Profile must be an object';
450
- if (p.inFormat !== undefined) return 'inFormat is not supported; use "tool": "claude" or "tool": "codex"';
456
+ if (p.inFormat !== undefined) return 'inFormat is not supported; use "tool": "claude", "codex" or "agy"';
451
457
  if (p.blindfold !== undefined) return 'blindfold per-profile setting is deprecated';
452
458
  if (p.blindfoldPort !== undefined) return 'blindfoldPort per-profile setting is deprecated';
453
459
  if (p.blindfoldHost !== undefined) return 'blindfoldHost per-profile setting is deprecated';
@@ -637,9 +643,15 @@ export function migrateConfigInMemory(rawConfig) {
637
643
  }
638
644
 
639
645
  // Step 3: Pointer migration (R7b, Item 2)
646
+ // A present pointer follows its profile through a Step 1 rename: a profile keyed "agy" became
647
+ // "agy-profile" when agy turned into a command word, and a pointer left on "agy" would dangle.
648
+ const presentPointer = (tool) => {
649
+ const v = rawConfig.activeProfiles[tool];
650
+ return v ? (renameMap.get(v) || v) : v;
651
+ };
640
652
  const newActiveProfiles = {};
641
653
  if (rawConfig.activeProfiles && Object.hasOwn(rawConfig.activeProfiles, 'claude')) {
642
- newActiveProfiles.claude = rawConfig.activeProfiles.claude;
654
+ newActiveProfiles.claude = presentPointer('claude');
643
655
  } else {
644
656
  let oldClaudeTarget = null;
645
657
  if (rawConfig.activeProfiles && Object.hasOwn(rawConfig.activeProfiles, 'anthropic')) {
@@ -664,7 +676,7 @@ export function migrateConfigInMemory(rawConfig) {
664
676
  }
665
677
 
666
678
  if (rawConfig.activeProfiles && Object.hasOwn(rawConfig.activeProfiles, 'codex')) {
667
- newActiveProfiles.codex = rawConfig.activeProfiles.codex;
679
+ newActiveProfiles.codex = presentPointer('codex');
668
680
  } else {
669
681
  let oldCodexTarget = null;
670
682
  if (rawConfig.activeProfiles && Object.hasOwn(rawConfig.activeProfiles, 'responses')) {
@@ -688,6 +700,11 @@ export function migrateConfigInMemory(rawConfig) {
688
700
  }
689
701
  }
690
702
 
703
+ // agy has no legacy key and no single-pointer fallback: only its own pointer, and only to a
704
+ // profile made for agy.
705
+ const agyKey = rawConfig.activeProfiles && Object.hasOwn(rawConfig.activeProfiles, 'agy') ? presentPointer('agy') : null;
706
+ newActiveProfiles.agy = agyKey && cfg.profiles[agyKey]?.tool === 'agy' ? agyKey : null;
707
+
691
708
  cfg.activeProfiles = newActiveProfiles;
692
709
  delete cfg.activeProfile;
693
710
 
@@ -993,6 +1010,8 @@ export function findProfileKey(cfg, name) {
993
1010
  }
994
1011
 
995
1012
  export function profileAcceptsTarget(profile, target) {
1013
+ // agy has no legacy spelling and no inFormat: only a profile made for it serves it.
1014
+ if (target === 'agy') return profile?.tool === 'agy';
996
1015
  if (profile?.tool) {
997
1016
  if (target === 'claude' || target === 'anthropic') return profile.tool === 'claude';
998
1017
  if (target === 'codex' || target === 'responses') return profile.tool === 'codex';
@@ -1008,6 +1027,7 @@ export function profileAcceptsTarget(profile, target) {
1008
1027
  export function modelSlotsForProfile(profile) {
1009
1028
  if (profile?.tool === 'claude') return CLAUDE_MODEL_SLOTS;
1010
1029
  if (profile?.tool === 'codex') return CODEX_MODEL_SLOTS;
1030
+ if (profile?.tool === 'agy') return AGY_MODEL_SLOTS;
1011
1031
  return MODEL_SLOTS_BY_FORMAT[profile?.inFormat] || MODEL_SLOTS_BY_FORMAT.auto;
1012
1032
  }
1013
1033
 
@@ -1185,7 +1205,8 @@ export function getActiveMap(cfg) {
1185
1205
  : Object.hasOwn(ap, legacy) ? ap[legacy]
1186
1206
  : cfg?.activeProfile ?? null
1187
1207
  );
1188
- return { claude: pointer('claude', 'anthropic'), codex: pointer('codex', 'responses') };
1208
+ // agy is newer than the single legacy pointer, so only its own key can turn it on.
1209
+ return { claude: pointer('claude', 'anthropic'), codex: pointer('codex', 'responses'), agy: ap.agy ?? null };
1189
1210
  }
1190
1211
 
1191
1212
  /**
@@ -1325,7 +1346,11 @@ export function computeLaunchState(cfg, port) {
1325
1346
  const pick = (t) => (hasProfile(cfg, map[t]) ? cfg.profiles[map[t]] : null);
1326
1347
  const claude = pick('claude');
1327
1348
  const codex = pick('codex');
1328
- const active = Boolean(claude || codex);
1349
+ const agy = pick('agy');
1350
+ // The interceptor serves Claude Code and Codex only. agy takes its endpoint from CLOUD_CODE_URL, so
1351
+ // an agy-only switch on writes the launch files and starts no interceptor.
1352
+ const intercepted = Boolean(claude || codex);
1353
+ const active = Boolean(intercepted || agy);
1329
1354
 
1330
1355
  // One interceptor serves both tools (R3) and its port lives at the top level of config.json
1331
1356
  // (R3b). The per-profile port went with the per-profile host and prefix.
@@ -1354,7 +1379,7 @@ export function computeLaunchState(cfg, port) {
1354
1379
  // and the tool set. The tool set travels sorted as one string (Finding 6), which is also what
1355
1380
  // lets a running interceptor be updated in place instead of restarted.
1356
1381
  // null when no tool is active, which is how reconcileBlindfold knows to stop it.
1357
- blindfold: active
1382
+ blindfold: intercepted
1358
1383
  ? {
1359
1384
  port: bfPort,
1360
1385
  activeTools: deriveActiveTools(cfg).join(','),
@@ -1367,15 +1392,20 @@ export function computeLaunchState(cfg, port) {
1367
1392
  // of the other tool, and a restart cannot leave a stale override behind.
1368
1393
  envClaude: [],
1369
1394
  envCodex: [],
1395
+ envAgy: [],
1370
1396
  // The launcher notice, one line per tool. Empty for a tool that is not routed (A1).
1371
1397
  routeClaude: claude ? routeLine('claude', map.claude, claude) : '',
1372
- routeCodex: codex ? routeLine('codex', map.codex, codex) : ''
1398
+ routeCodex: codex ? routeLine('codex', map.codex, codex) : '',
1399
+ routeAgy: agy ? routeLine('agy', map.agy, agy) : ''
1373
1400
  };
1374
1401
 
1375
1402
  // NODE_EXTRA_CA_CERTS is deliberately absent from env-claude: the shim decides it at launch,
1376
1403
  // because only then does it know whether the user brought a CA of their own (R2).
1377
1404
  if (claude) state.envClaude = proxyPairs();
1378
1405
  if (codex) state.envCodex = [...proxyPairs(), ['CODEX_CA_CERTIFICATE', paths.blindfoldCA]];
1406
+ // agy reaches the gateway directly over plain http: no proxy and no CA (Go on Windows reads only
1407
+ // the system store). Remote control keeps its own host, so it never crosses the switcher.
1408
+ if (agy) state.envAgy = [['CLOUD_CODE_URL', `http://127.0.0.1:${port}`]];
1379
1409
  return state;
1380
1410
  }
1381
1411
 
@@ -1470,13 +1500,17 @@ function writeToolFiles(pairs, shPath, cmdPath) {
1470
1500
  // tools from activeProfiles, and a colliding config is one this build will not migrate. This empties
1471
1501
  // only the file pair of the tool that was switched off, under the same lock, so the other tool's
1472
1502
  // launcher files are never opened (A13: emptied, never deleted).
1503
+ const TOOL_FILES = {
1504
+ claude: () => [paths.envClaudeCmd, paths.envClaudeSh, paths.routeClaude],
1505
+ codex: () => [paths.envCodexCmd, paths.envCodexSh, paths.routeCodex],
1506
+ agy: () => [paths.envAgyCmd, paths.envAgySh, paths.routeAgy]
1507
+ };
1508
+
1473
1509
  export function emptyToolEnvFiles(tool) {
1474
1510
  const codex = tool === 'codex';
1475
1511
  const codexRouteChanged = withLaunchLock(() => {
1476
1512
  const before = codex ? readOrEmpty(paths.envCodexSh) : '';
1477
- writeAtomic(codex ? paths.envCodexCmd : paths.envClaudeCmd, '');
1478
- writeAtomic(codex ? paths.envCodexSh : paths.envClaudeSh, '');
1479
- writeAtomic(codex ? paths.routeCodex : paths.routeClaude, '');
1513
+ for (const f of (TOOL_FILES[tool] || TOOL_FILES.claude)()) writeAtomic(f, '');
1480
1514
  return before !== '';
1481
1515
  });
1482
1516
  return { codexRouteChanged, codexDaemon: codexRouteChanged ? restartCodexDaemon([]) : undefined };
@@ -1544,9 +1578,12 @@ function writeLaunchState(cfg, port) {
1544
1578
  try {
1545
1579
  writeToolFiles(st.envClaude, paths.envClaudeSh, paths.envClaudeCmd);
1546
1580
  writeToolFiles(st.envCodex, paths.envCodexSh, paths.envCodexCmd);
1581
+ writeToolFiles(st.envAgy, paths.envAgySh, paths.envAgyCmd);
1547
1582
  writeAtomic(paths.routeClaude, st.routeClaude ? `${st.routeClaude}
1548
1583
  ` : '');
1549
1584
  writeAtomic(paths.routeCodex, st.routeCodex ? `${st.routeCodex}
1585
+ ` : '');
1586
+ writeAtomic(paths.routeAgy, st.routeAgy ? `${st.routeAgy}
1550
1587
  ` : '');
1551
1588
  st.codexRouteChanged = readOrEmpty(paths.envCodexSh) !== codexBefore;
1552
1589
  // Recorded on switch on so a shell opened while the gateway ran can still recognize its own
@@ -1573,7 +1610,7 @@ export function clearLaunchState(port) {
1573
1610
  writeAtomic(paths.envCmd, STUB_CMD);
1574
1611
  writeAtomic(paths.envSh, STUB_SH);
1575
1612
  for (const f of [paths.envClaudeCmd, paths.envClaudeSh, paths.envCodexCmd, paths.envCodexSh,
1576
- paths.routeClaude, paths.routeCodex]) {
1613
+ paths.envAgyCmd, paths.envAgySh, paths.routeClaude, paths.routeCodex, paths.routeAgy]) {
1577
1614
  writeAtomic(f, '');
1578
1615
  }
1579
1616
  return codexBefore !== '';
package/switch.mjs CHANGED
@@ -44,7 +44,7 @@ if (!config) {
44
44
  }
45
45
 
46
46
  // R5: two commands, one per tool. The openai and vertex commands went with their input routes.
47
- const TARGET_ALIASES = { claude: 'claude', codex: 'codex' };
47
+ const TARGET_ALIASES = { claude: 'claude', codex: 'codex', agy: 'agy' };
48
48
 
49
49
  // Strip --port/-p and their value from positional args. -p is the global option everywhere, as in
50
50
  // resolvePort; `switch port <n>` is the command that changes the port.
@@ -337,7 +337,7 @@ async function changePort(newPortStr) {
337
337
  }
338
338
 
339
339
  function printProfile(profile) {
340
- console.log(`Input Target: ${show(profile.inFormat || 'auto').toUpperCase()}`);
340
+ console.log(`Input Target: ${show(profile.tool || profile.inFormat || 'auto').toUpperCase()}`);
341
341
  console.log(`Routing: ${profile.outFormat ? `out=${show(profile.outFormat)}` : `mode=${show(profile.mode || 'hybrid')}`}`);
342
342
  console.log(`Upstream: ${show(profile.baseURL || '(not set)')}`);
343
343
  for (const slot of modelSlotsForProfile(profile)) {
@@ -347,7 +347,7 @@ function printProfile(profile) {
347
347
  }
348
348
 
349
349
  function printTargets(activeMap) {
350
- const labels = { claude: 'Claude Code', codex: 'Codex' };
350
+ const labels = { claude: 'Claude Code', codex: 'Codex', agy: 'Antigravity' };
351
351
  for (const t of TOOLS) {
352
352
  console.log(` ${labels[t].padEnd(12)} (${t.padEnd(11)}) -> ${activeMap[t] ? show(activeMap[t]) : 'OFF (official)'}`);
353
353
  }
@@ -529,7 +529,7 @@ async function turnOff(targetArg) {
529
529
  if (targetArg) {
530
530
  const target = TARGET_ALIASES[targetArg.toLowerCase()];
531
531
  if (!target) {
532
- console.error(`[Error] Unknown target "${targetArg}". Use one of: claude, codex`);
532
+ console.error(`[Error] Unknown target "${targetArg}". Use one of: claude, codex, agy`);
533
533
  process.exit(1);
534
534
  }
535
535
  if (refused) {
@@ -564,7 +564,7 @@ async function turnOff(targetArg) {
564
564
  console.log('Deactivating Proxy and restoring official endpoints...');
565
565
  if (refused) {
566
566
  // Both pointers through the same writer. Nothing below runs until those bytes are on disk (R7b).
567
- casOff(['claude', 'codex']);
567
+ casOff(TOOLS);
568
568
  reportSettings(clearLaunchState(port));
569
569
  const result = await stopProxy(port);
570
570
  reportStopped(result, port);
@@ -591,7 +591,7 @@ async function turnOff(targetArg) {
591
591
  // Only a CLI whose target is active is expected to go through the gateway.
592
592
  function auditActiveClis() {
593
593
  const map = getActiveMap(config);
594
- return auditRunningProcesses([map.claude && 'claude', map.codex && 'codex'].filter(Boolean));
594
+ return auditRunningProcesses(TOOLS.filter(t => map[t]));
595
595
  }
596
596
 
597
597
  // settings.json edits are never silent: name every value the switcher removed.
@@ -799,13 +799,15 @@ async function managePlugin(action = 'status') {
799
799
 
800
800
  if (act === 'install' || act === 'on') {
801
801
  const { CURRENT_VERSION } = await import('./version.mjs');
802
- const { installed, failed, paths } = installPlugin({}, CURRENT_VERSION);
802
+ const { installed, skipped, failed, paths } = installPlugin({}, CURRENT_VERSION);
803
803
  for (const tool of installed) console.log(`[OK] ${tool}: ${paths[tool]}`);
804
+ for (const k of skipped) console.log(`[Skip] ${k.tool}: ${k.reason}`);
804
805
  for (const f of failed) console.error(`[Error] ${f.tool}: ${f.reason}`);
805
806
  if (installed.length) {
806
807
  console.log('\nNeither settings.json nor config.toml was opened. Each tool reads a file of its own.');
807
808
  console.log(`Claude Code loads it on the next session as ${PLUGIN_NAME}@skills-dir.`);
808
809
  console.log('Codex asks you once to trust a new hook. Accept it, or the hook stays off.');
810
+ if (installed.includes('agy')) console.log(`agy: ${pluginStatus().agy.note}.`);
809
811
  console.log('\nTo remove it: switch plugin uninstall');
810
812
  }
811
813
  if (failed.length) process.exit(1);
@@ -826,7 +828,10 @@ async function managePlugin(action = 'status') {
826
828
  console.log(` ${st.claude.path}`);
827
829
  console.log(`Codex : ${st.codex.installed ? 'INSTALLED' : 'not installed'}`);
828
830
  console.log(` ${st.codex.path}`);
829
- if (!st.claude.installed || !st.codex.installed) {
831
+ console.log(`Antigravity: ${st.agy.installed ? 'INSTALLED' : 'not installed'}`);
832
+ console.log(` ${st.agy.path}${st.agy.error ? ` (${st.agy.error})` : ''}`);
833
+ console.log(` ${st.agy.note}`);
834
+ if (!st.claude.installed || !st.codex.installed || !st.agy.installed) {
830
835
  console.log(`\nThe notice is optional. Without it the gateway still routes every request,`);
831
836
  console.log(`and the shim still raises its own notice at launch.`);
832
837
  console.log(`To add it: switch plugin install`);
@@ -1268,13 +1273,14 @@ if (cmd === 'off' || cmd === 'stop') {
1268
1273
  console.log(' switch <profile> # Activate profile for all compatible targets');
1269
1274
  console.log(' switch claude <profile> # Set active profile for Claude Code');
1270
1275
  console.log(' switch codex <profile> # Set active profile for Codex');
1276
+ console.log(' switch agy <profile> # Set active profile for the Antigravity CLI (agy)');
1271
1277
  console.log(' switch port <number> # Change gateway port');
1272
1278
  console.log(' switch service install # Install OS background autostart service (auto-updates on logon)');
1273
1279
  console.log(' switch service uninstall # Uninstall background autostart service');
1274
1280
  console.log(' switch shim install # Auto-inject env into resumed sessions (claude --resume)');
1275
1281
  console.log(' switch shim status # Check shims + detect sessions bypassing the gateway');
1276
1282
  console.log(' switch shim uninstall # Remove launcher shims');
1277
- console.log(' switch plugin install # Optional launch notice inside Claude Code & Codex');
1283
+ console.log(' switch plugin install # Optional launch notice inside Claude Code, Codex & agy');
1278
1284
  console.log(' switch plugin status # Is that notice installed');
1279
1285
  console.log(' switch plugin uninstall # Remove that notice');
1280
1286
  console.log(' switch off [target] # Restore official endpoints (all, or one target)');