llm-switcher 1.2.11 → 1.3.0

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/switch.mjs CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  suggestedRcFiles, auditRunningProcesses
21
21
  } from './shim.mjs';
22
22
  import {
23
- serviceEnv, systemdUnit, launchdPlist, scheduledTaskXml, decodeConsoleText, portFromServiceText, writeServiceFile
23
+ serviceEnv, systemdUnit, launchdPlist, scheduledTaskXml, decodeConsoleText, portFromServiceText, autoupdateFromServiceText, writeServiceFile
24
24
  } from './service.mjs';
25
25
 
26
26
  const proxyScript = path.join(ROOT_DIR, 'proxy.mjs');
@@ -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.
@@ -272,6 +272,9 @@ async function changePort(newPortStr) {
272
272
  const target = await probeGateway(p);
273
273
  if (isHeld(target)) refuseForeignPort(p, 'this switcher', target);
274
274
  const svc = installedService();
275
+ // The unit is rewritten below. It keeps the choice that `service install --no-autoupdate` made.
276
+ const svcText = svc ? serviceText(svc) : null;
277
+ const autoupdate = svcText === null || autoupdateFromServiceText(svcText);
275
278
  const wasRunning = await checkProxyRunning(oldPort);
276
279
  // Stop the old gateway even when a service is installed: it may be a copy started outside the unit.
277
280
  if (wasRunning) {
@@ -301,7 +304,7 @@ async function changePort(newPortStr) {
301
304
  } catch (err) {
302
305
  console.error(`[Error] Failed to restore config.json during rollback: ${err.message}`);
303
306
  }
304
- if (svc) installService(oldPort);
307
+ if (svc) installService(oldPort, { autoupdate });
305
308
  else if (wasRunning) startProxyBackground(oldPort);
306
309
  let up = !svc && !wasRunning;
307
310
  for (let i = 0; i < 20 && !up; i++) { await sleep(250); up = await checkProxyRunning(oldPort); }
@@ -311,7 +314,7 @@ async function changePort(newPortStr) {
311
314
  if (svc) {
312
315
  // The unit fixes the port on its command line, so it is rewritten and restarted, not fought.
313
316
  console.log(`Reinstalling the ${svc} service on port ${p}...`);
314
- if (!installService(p)) await rollBack(`The ${svc} service could not be installed for port ${p}.`);
317
+ if (!installService(p, { autoupdate })) await rollBack(`The ${svc} service could not be installed for port ${p}.`);
315
318
  let up = false;
316
319
  for (let i = 0; i < 20 && !up; i++) { await sleep(250); up = await checkProxyRunning(p); }
317
320
  if (!up) await rollBack(`The ${svc} service did not come up on port ${p}. See ${proxyLogPath}.`);
@@ -334,7 +337,7 @@ async function changePort(newPortStr) {
334
337
  }
335
338
 
336
339
  function printProfile(profile) {
337
- console.log(`Input Target: ${show(profile.inFormat || 'auto').toUpperCase()}`);
340
+ console.log(`Input Target: ${show(profile.tool || profile.inFormat || 'auto').toUpperCase()}`);
338
341
  console.log(`Routing: ${profile.outFormat ? `out=${show(profile.outFormat)}` : `mode=${show(profile.mode || 'hybrid')}`}`);
339
342
  console.log(`Upstream: ${show(profile.baseURL || '(not set)')}`);
340
343
  for (const slot of modelSlotsForProfile(profile)) {
@@ -344,7 +347,7 @@ function printProfile(profile) {
344
347
  }
345
348
 
346
349
  function printTargets(activeMap) {
347
- const labels = { claude: 'Claude Code', codex: 'Codex' };
350
+ const labels = { claude: 'Claude Code', codex: 'Codex', agy: 'Antigravity' };
348
351
  for (const t of TOOLS) {
349
352
  console.log(` ${labels[t].padEnd(12)} (${t.padEnd(11)}) -> ${activeMap[t] ? show(activeMap[t]) : 'OFF (official)'}`);
350
353
  }
@@ -526,7 +529,7 @@ async function turnOff(targetArg) {
526
529
  if (targetArg) {
527
530
  const target = TARGET_ALIASES[targetArg.toLowerCase()];
528
531
  if (!target) {
529
- console.error(`[Error] Unknown target "${targetArg}". Use one of: claude, codex`);
532
+ console.error(`[Error] Unknown target "${targetArg}". Use one of: claude, codex, agy`);
530
533
  process.exit(1);
531
534
  }
532
535
  if (refused) {
@@ -561,7 +564,7 @@ async function turnOff(targetArg) {
561
564
  console.log('Deactivating Proxy and restoring official endpoints...');
562
565
  if (refused) {
563
566
  // Both pointers through the same writer. Nothing below runs until those bytes are on disk (R7b).
564
- casOff(['claude', 'codex']);
567
+ casOff(TOOLS);
565
568
  reportSettings(clearLaunchState(port));
566
569
  const result = await stopProxy(port);
567
570
  reportStopped(result, port);
@@ -588,7 +591,7 @@ async function turnOff(targetArg) {
588
591
  // Only a CLI whose target is active is expected to go through the gateway.
589
592
  function auditActiveClis() {
590
593
  const map = getActiveMap(config);
591
- return auditRunningProcesses([map.claude && 'claude', map.codex && 'codex'].filter(Boolean));
594
+ return auditRunningProcesses(TOOLS.filter(t => map[t]));
592
595
  }
593
596
 
594
597
  // settings.json edits are never silent: name every value the switcher removed.
@@ -669,16 +672,22 @@ function serviceStart(kind) {
669
672
  } catch {}
670
673
  }
671
674
 
672
- /** The port on the service's command line, or null when it cannot be read. */
673
- function servicePort(kind) {
675
+ /** The installed service definition, or null when it cannot be read. */
676
+ function serviceText(kind) {
674
677
  try {
675
- if (kind === 'systemd') return portFromServiceText(fs.readFileSync(SYSTEMD_UNIT, 'utf8'));
676
- if (kind === 'launchd') return portFromServiceText(fs.readFileSync(LAUNCHD_PLIST, 'utf8'));
677
- if (kind === 'schtasks') return portFromServiceText(decodeConsoleText(execFileSync('schtasks', ['/Query', '/TN', 'LLMSwitcher', '/XML'])));
678
+ if (kind === 'systemd') return fs.readFileSync(SYSTEMD_UNIT, 'utf8');
679
+ if (kind === 'launchd') return fs.readFileSync(LAUNCHD_PLIST, 'utf8');
680
+ if (kind === 'schtasks') return decodeConsoleText(execFileSync('schtasks', ['/Query', '/TN', 'LLMSwitcher', '/XML']));
678
681
  } catch {}
679
682
  return null;
680
683
  }
681
684
 
685
+ /** The port on the service's command line, or null when it cannot be read. */
686
+ function servicePort(kind) {
687
+ const text = serviceText(kind);
688
+ return text === null ? null : portFromServiceText(text);
689
+ }
690
+
682
691
  function reportBackup(backup) {
683
692
  if (backup) console.log(`[Service] The previous definition differed and is kept as ${backup}.`);
684
693
  }
@@ -693,7 +702,7 @@ function serviceStop(kind) {
693
702
  }
694
703
 
695
704
  // Writes the service for `port` and (re)starts it, so a running unit picks up the new command line.
696
- function installService(port) {
705
+ function installService(port, { autoupdate = true } = {}) {
697
706
  const nodeBin = process.execPath;
698
707
  const env = serviceEnv();
699
708
  if (process.platform === 'win32') {
@@ -702,12 +711,12 @@ function installService(port) {
702
711
  const userId = process.env.USERDOMAIN && process.env.USERNAME ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : os.userInfo().username;
703
712
  const xmlPath = path.join(dir, 'task.xml');
704
713
  // Task Scheduler reads the XML as UTF-16, the encoding it declares.
705
- fs.writeFileSync(xmlPath, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(scheduledTaskXml({ nodeBin, script: proxyScript, port, userId }), 'utf16le')]));
714
+ fs.writeFileSync(xmlPath, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(scheduledTaskXml({ nodeBin, script: proxyScript, port, userId, autoupdate }), 'utf16le')]));
706
715
  execFileSync('schtasks', ['/Create', '/TN', 'LLMSwitcher', '/XML', xmlPath, '/F'], { stdio: 'inherit' });
707
716
  try { execFileSync('schtasks', ['/End', '/TN', 'LLMSwitcher'], { stdio: 'ignore' }); } catch {}
708
717
  execFileSync('schtasks', ['/Run', '/TN', 'LLMSwitcher'], { stdio: 'ignore' });
709
718
  if (env.length) console.log(`[WARN] The scheduled task does not receive ${env.map(([k]) => k).join(', ')}. Set them as User environment variables.`);
710
- console.log('[SUCCESS] Installed and started Windows Scheduled Task "LLMSwitcher" (auto-starts on logon).');
719
+ console.log(`[SUCCESS] Installed and started Windows Scheduled Task "LLMSwitcher" (${autoupdate ? 'auto-updates on logon and starts' : 'auto-starts on logon'}).`);
711
720
  return true;
712
721
  } catch (err) {
713
722
  console.error('[Error] Failed to register the scheduled task:', err.message);
@@ -718,7 +727,7 @@ function installService(port) {
718
727
  }
719
728
  if (process.platform === 'darwin') {
720
729
  try {
721
- reportBackup(writeServiceFile(LAUNCHD_PLIST, launchdPlist({ nodeBin, script: proxyScript, port, logPath: proxyLogPath, env })));
730
+ reportBackup(writeServiceFile(LAUNCHD_PLIST, launchdPlist({ nodeBin, script: proxyScript, port, logPath: proxyLogPath, env, autoupdate })));
722
731
  try { execFileSync('launchctl', ['unload', LAUNCHD_PLIST], { stdio: 'ignore' }); } catch {}
723
732
  execFileSync('launchctl', ['load', LAUNCHD_PLIST], { stdio: 'inherit' });
724
733
  console.log('[SUCCESS] Installed and started macOS launchd service.');
@@ -729,7 +738,7 @@ function installService(port) {
729
738
  }
730
739
  }
731
740
  try {
732
- reportBackup(writeServiceFile(SYSTEMD_UNIT, systemdUnit({ nodeBin, script: proxyScript, port, env })));
741
+ reportBackup(writeServiceFile(SYSTEMD_UNIT, systemdUnit({ nodeBin, script: proxyScript, port, env, autoupdate })));
733
742
  systemctlUser(['daemon-reload'], { stdio: 'inherit' });
734
743
  systemctlUser(['enable', 'llm-switcher'], { stdio: 'inherit' });
735
744
  systemctlUser(['restart', 'llm-switcher'], { stdio: 'inherit' });
@@ -745,7 +754,8 @@ async function manageService(action) {
745
754
  const port = getTargetPort();
746
755
 
747
756
  if (action === 'install') {
748
- if (!installService(port)) process.exit(1);
757
+ const autoupdate = !process.argv.includes('--no-autoupdate');
758
+ if (!installService(port, { autoupdate })) process.exit(1);
749
759
  return;
750
760
  }
751
761
 
@@ -789,13 +799,15 @@ async function managePlugin(action = 'status') {
789
799
 
790
800
  if (act === 'install' || act === 'on') {
791
801
  const { CURRENT_VERSION } = await import('./version.mjs');
792
- const { installed, failed, paths } = installPlugin({}, CURRENT_VERSION);
802
+ const { installed, skipped, failed, paths } = installPlugin({}, CURRENT_VERSION);
793
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}`);
794
805
  for (const f of failed) console.error(`[Error] ${f.tool}: ${f.reason}`);
795
806
  if (installed.length) {
796
807
  console.log('\nNeither settings.json nor config.toml was opened. Each tool reads a file of its own.');
797
808
  console.log(`Claude Code loads it on the next session as ${PLUGIN_NAME}@skills-dir.`);
798
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}.`);
799
811
  console.log('\nTo remove it: switch plugin uninstall');
800
812
  }
801
813
  if (failed.length) process.exit(1);
@@ -816,7 +828,10 @@ async function managePlugin(action = 'status') {
816
828
  console.log(` ${st.claude.path}`);
817
829
  console.log(`Codex : ${st.codex.installed ? 'INSTALLED' : 'not installed'}`);
818
830
  console.log(` ${st.codex.path}`);
819
- 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) {
820
835
  console.log(`\nThe notice is optional. Without it the gateway still routes every request,`);
821
836
  console.log(`and the shim still raises its own notice at launch.`);
822
837
  console.log(`To add it: switch plugin install`);
@@ -1106,6 +1121,78 @@ async function showVersion() {
1106
1121
  else console.log('This is the latest version.');
1107
1122
  }
1108
1123
 
1124
+ async function gatewayPid(port) {
1125
+ try {
1126
+ const r = await fetch(`http://127.0.0.1:${port}/health?challenge=update`, { signal: AbortSignal.timeout(2000) });
1127
+ return (await r.json()).pid ?? null;
1128
+ } catch {
1129
+ return null;
1130
+ }
1131
+ }
1132
+
1133
+ // A running gateway updates itself and hands over to the new code, so the CLI and the dashboard
1134
+ // share one update path. With no gateway up, the files are updated here and the next start runs them.
1135
+ async function runUpdate() {
1136
+ const port = getTargetPort();
1137
+ if (!(await checkProxyRunning(port))) {
1138
+ const { applyUpdate } = await import('./update.mjs');
1139
+ try {
1140
+ const r = await applyUpdate({ logger: (text) => console.log(` ${text}`) });
1141
+ console.log(r.updated ? `[SUCCESS] Updated v${r.from} -> v${r.to}.` : r.reason);
1142
+ } catch (err) {
1143
+ console.error(`[Error] ${err.message}`);
1144
+ process.exit(1);
1145
+ }
1146
+ return;
1147
+ }
1148
+ const oldPid = await gatewayPid(port);
1149
+ const res = await fetch(`http://127.0.0.1:${port}/api/update`, {
1150
+ method: 'POST',
1151
+ headers: { 'Content-Type': 'application/json', 'x-llm-switcher-token': readAdminToken() || '' },
1152
+ body: '{}'
1153
+ });
1154
+ if (!res.ok) {
1155
+ console.error(`[Error] ${(await res.json().catch(() => ({}))).error || `HTTP ${res.status}`}`);
1156
+ process.exit(1);
1157
+ }
1158
+ let result = null;
1159
+ let failed = null;
1160
+ let buf = '';
1161
+ const decoder = new TextDecoder();
1162
+ for await (const chunk of res.body) {
1163
+ buf += decoder.decode(chunk, { stream: true });
1164
+ let i;
1165
+ while ((i = buf.indexOf('\n\n')) !== -1) {
1166
+ const block = buf.slice(0, i);
1167
+ buf = buf.slice(i + 2);
1168
+ const event = /^event: (.*)$/m.exec(block)?.[1];
1169
+ const data = JSON.parse(/^data: (.*)$/m.exec(block)?.[1] || 'null');
1170
+ if (event === 'log') console.log(` ${show(data.text)}`);
1171
+ else if (event === 'result') result = data;
1172
+ else if (event === 'error') failed = data.message;
1173
+ }
1174
+ }
1175
+ if (failed || !result) {
1176
+ console.error(`[Error] ${show(failed || 'The gateway closed the update stream without a result.')}`);
1177
+ process.exit(1);
1178
+ }
1179
+ if (!result.updated) {
1180
+ console.log(show(result.reason));
1181
+ return;
1182
+ }
1183
+ console.log(`Updated v${show(result.from)} -> v${show(result.to)}. Waiting for the new gateway...`);
1184
+ for (let i = 0; i < 60; i++) {
1185
+ await sleep(250);
1186
+ const pid = await gatewayPid(port);
1187
+ if (pid && pid !== oldPid && (await checkProxyRunning(port))) {
1188
+ console.log(`[SUCCESS] The gateway on port ${port} runs v${show(result.to)}.`);
1189
+ return;
1190
+ }
1191
+ }
1192
+ console.error(`[Error] The new gateway did not answer on port ${port}. See ${proxyLogPath}.`);
1193
+ process.exit(1);
1194
+ }
1195
+
1109
1196
  async function showModels(target) {
1110
1197
  const { syncLocalCatalog, refreshCatalog } = await import('./catalog.mjs');
1111
1198
  if (optionValue('--refresh') || process.argv.includes('--refresh')) {
@@ -1169,6 +1256,8 @@ if (cmd === 'off' || cmd === 'stop') {
1169
1256
  await showModels(subArg);
1170
1257
  } else if (cmd === 'status' || cmd === 'st') {
1171
1258
  await showStatus();
1259
+ } else if (cmd === 'update' || cmd === 'upgrade') {
1260
+ await runUpdate();
1172
1261
  } else if (cmd === 'on' || cmd === 'start') {
1173
1262
  await turnOn(subArg);
1174
1263
  } else if (cmd && findProfileKey(config, rawCmd)) {
@@ -1179,17 +1268,19 @@ if (cmd === 'off' || cmd === 'stop') {
1179
1268
  console.log(' switch status # Show multi-CLI active status');
1180
1269
  console.log(' switch version # Show the version and check for a newer one');
1181
1270
  console.log(' switch doctor # Audit environment, settings & routing');
1271
+ console.log(' switch update # Install the newest release and restart the gateway');
1182
1272
  console.log(' switch on [profile] # Start gateway & activate profile for all compatible targets');
1183
1273
  console.log(' switch <profile> # Activate profile for all compatible targets');
1184
1274
  console.log(' switch claude <profile> # Set active profile for Claude Code');
1185
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)');
1186
1277
  console.log(' switch port <number> # Change gateway port');
1187
- console.log(' switch service install # Install OS background autostart service');
1278
+ console.log(' switch service install # Install OS background autostart service (auto-updates on logon)');
1188
1279
  console.log(' switch service uninstall # Uninstall background autostart service');
1189
1280
  console.log(' switch shim install # Auto-inject env into resumed sessions (claude --resume)');
1190
1281
  console.log(' switch shim status # Check shims + detect sessions bypassing the gateway');
1191
1282
  console.log(' switch shim uninstall # Remove launcher shims');
1192
- 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');
1193
1284
  console.log(' switch plugin status # Is that notice installed');
1194
1285
  console.log(' switch plugin uninstall # Remove that notice');
1195
1286
  console.log(' switch off [target] # Restore official endpoints (all, or one target)');