@event4u/agent-switch 1.5.1 → 1.6.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/dist/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import { spawn, spawnSync } from "node:child_process";
17
- import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readSwitchStrategy, setSwitchStrategy, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, ROOT, } from "./profiles.js";
17
+ import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readSwitchStrategy, setSwitchStrategy, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readBinaryPaths, setBinaryPath, ROOT, } from "./profiles.js";
18
18
  import { PROVIDER_IDS, provider, isProviderInstalled, resolveBinary } from "./providers.js";
19
19
  import { parseArgs, parseRun, resolveProviderValue } from "./args.js";
20
20
  import { extractBrief, writeBrief, sweepBriefs, seedPrompt } from "./handoff.js";
@@ -59,9 +59,9 @@ function launch(p, name, args) {
59
59
  ensureAgyKeychain(home);
60
60
  env.CFFIXED_USER_HOME = home;
61
61
  }
62
- const res = spawnSync(resolveBinary(p.binary), args, { env, stdio: "inherit" });
62
+ const res = spawnSync(resolveBinary(p.binary, readBinaryPath(p.id)), args, { env, stdio: "inherit" });
63
63
  if (res.error && res.error.code === "ENOENT") {
64
- die(`\`${p.binary}\` binary not found on PATH. Install ${p.id} first.`);
64
+ die(`\`${p.binary}\` not found. Install ${p.id}, or link its binary: agent-switch providers link --provider ${p.id} --path <path> (Providers tab in the GUI).`);
65
65
  }
66
66
  return res.status ?? 1;
67
67
  }
@@ -952,7 +952,7 @@ function sessionsRmCodex(sessionId, flags, purge, json) {
952
952
  const action = purge ? "delete" : "archive";
953
953
  const p = provider("codex");
954
954
  const env = { ...process.env, [p.envVar]: cfg };
955
- const r = spawnSync(resolveBinary(p.binary), codexSessionCommand(action, sessionId), { env, encoding: "utf8" });
955
+ const r = spawnSync(resolveBinary(p.binary, readBinaryPath(p.id)), codexSessionCommand(action, sessionId), { env, encoding: "utf8" });
956
956
  const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
957
957
  if (/no .*session.*found/i.test(out))
958
958
  die(`codex could not find session ${sessionId} in "${profile}": ${out}`);
@@ -990,7 +990,7 @@ function cmdSessionsRestore(providerId, providerExplicit, handle, flags = {}) {
990
990
  const profile = requireProfile("codex", flags.from, "sessions restore --from");
991
991
  const p = provider("codex");
992
992
  const env = { ...process.env, [p.envVar]: configDir("codex", profile) };
993
- const r = spawnSync(resolveBinary(p.binary), codexSessionCommand("unarchive", handle), { env, encoding: "utf8" });
993
+ const r = spawnSync(resolveBinary(p.binary, readBinaryPath(p.id)), codexSessionCommand("unarchive", handle), { env, encoding: "utf8" });
994
994
  const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
995
995
  if (/no .*session.*found/i.test(out))
996
996
  die(`codex could not find an archived session ${handle} in "${profile}": ${out}`);
@@ -1351,22 +1351,41 @@ function cmdProviders(providerId, providerExplicit, mode, flags = {}) {
1351
1351
  console.log(`${providerId}: cli ${cfg.cli ? "on" : "off"}, ui ${cfg.ui ? "on" : "off"}.`);
1352
1352
  return;
1353
1353
  }
1354
+ // Link the provider's CLI binary when it isn't on PATH — the user points us at
1355
+ // it (from the GUI Providers tab or here). `unlink` reverts to PATH resolution.
1356
+ if (mode === "link") {
1357
+ const binPath = typeof flags.path === "string" ? flags.path : undefined;
1358
+ if (!binPath)
1359
+ die(`usage: agent-switch providers link --provider ${providerId} --path <path-to-binary>`);
1360
+ if (!fs.existsSync(binPath))
1361
+ die(`no file at "${binPath}" — pass the full path to the ${provider(providerId).binary} binary.`);
1362
+ setBinaryPath(providerId, binPath);
1363
+ console.log(`Linked ${providerId} binary → ${binPath}`);
1364
+ return;
1365
+ }
1366
+ if (mode === "unlink") {
1367
+ setBinaryPath(providerId, null);
1368
+ console.log(`Unlinked ${providerId} binary (back to PATH resolution).`);
1369
+ return;
1370
+ }
1354
1371
  if (mode === undefined || mode === "status") {
1355
1372
  const all = readProviders();
1373
+ const links = readBinaryPaths();
1356
1374
  if (flags.json) {
1357
- // The GUI needs every provider's enabled surfaces AND whether its binary
1358
- // is installed (so it can show but not enable a missing provider) at once.
1359
- const enriched = Object.fromEntries(PROVIDER_IDS.map((p) => [p, { ...all[p], installed: isProviderInstalled(p) }]));
1375
+ // The GUI needs every provider's enabled surfaces, whether its binary is
1376
+ // usable (installed on PATH or linked), and any linked path — so it can
1377
+ // show, link, and enable a provider that isn't on PATH — all at once.
1378
+ const enriched = Object.fromEntries(PROVIDER_IDS.map((p) => [p, { ...all[p], installed: isProviderInstalled(p, links[p]), binaryPath: links[p] ?? null }]));
1360
1379
  console.log(JSON.stringify(enriched));
1361
1380
  return;
1362
1381
  }
1363
1382
  for (const p of providerExplicit ? [providerId] : PROVIDER_IDS) {
1364
- const inst = isProviderInstalled(p) ? "installed" : "not installed";
1383
+ const inst = isProviderInstalled(p, links[p]) ? (links[p] ? `linked: ${links[p]}` : "installed") : "not installed";
1365
1384
  console.log(`${p}: cli ${all[p].cli ? "on" : "off"}, ui ${all[p].ui ? "on" : "off"} (${inst}).`);
1366
1385
  }
1367
1386
  return;
1368
1387
  }
1369
- die("usage: agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] [--json]");
1388
+ die("usage: agent-switch providers enable|disable|link|unlink|status [--provider P] [--surface cli|ui] [--path <bin>] [--json]");
1370
1389
  }
1371
1390
  /**
1372
1391
  * Remove agent-switch's own footprint. Deletes every profile (incl. Claude
package/dist/profiles.js CHANGED
@@ -147,27 +147,36 @@ function normalizeProvidersMap(raw) {
147
147
  out[p] = normalizeSurfaces(r[p], p);
148
148
  return out;
149
149
  }
150
+ function normalizeBinaryPaths(raw) {
151
+ const r = (raw ?? {});
152
+ const out = {};
153
+ for (const p of PROVIDER_IDS)
154
+ if (typeof r[p] === "string" && r[p])
155
+ out[p] = r[p];
156
+ return out;
157
+ }
150
158
  export function readState() {
151
159
  try {
152
160
  const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
153
161
  const labels = normalizeLabels(raw?.labels);
154
162
  const autoSwitch = normalizeAutoSwitchMap(raw?.autoSwitch);
155
163
  const providers = normalizeProvidersMap(raw?.providers);
164
+ const binaryPaths = normalizeBinaryPaths(raw?.binaryPaths);
156
165
  const switchStrategy = raw?.switchStrategy === "rotation-first" ? "rotation-first" : DEFAULT_SWITCH_STRATEGY;
157
166
  const osNotifications = raw?.osNotifications === true;
158
167
  // v1: { active: "<name>" } — a single Claude profile.
159
168
  if (typeof raw?.active === "string") {
160
- return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, switchStrategy, osNotifications };
169
+ return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, switchStrategy, osNotifications };
161
170
  }
162
171
  if (raw?.active && typeof raw.active === "object") {
163
- return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, switchStrategy, osNotifications };
172
+ return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, switchStrategy, osNotifications };
164
173
  }
165
- return { active: emptyActive(), labels, autoSwitch, providers, switchStrategy, osNotifications };
174
+ return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, switchStrategy, osNotifications };
166
175
  }
167
176
  catch {
168
177
  /* absent / unparsable → default */
169
178
  }
170
- return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), switchStrategy: DEFAULT_SWITCH_STRATEGY, osNotifications: false };
179
+ return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, switchStrategy: DEFAULT_SWITCH_STRATEGY, osNotifications: false };
171
180
  }
172
181
  export function readSwitchStrategy() {
173
182
  return readState().switchStrategy;
@@ -292,6 +301,24 @@ export function setProviderSurface(providerId, surface, enabled) {
292
301
  writeState(state);
293
302
  return state.providers[providerId];
294
303
  }
304
+ // ---------- linked binary paths (provider CLI not on PATH) ----------
305
+ /** The user-linked absolute path to a provider's CLI binary, or null. */
306
+ export function readBinaryPath(providerId) {
307
+ return readState().binaryPaths[providerId] ?? null;
308
+ }
309
+ /** All linked binary paths (for `providers status` / the GUI tab). */
310
+ export function readBinaryPaths() {
311
+ return readState().binaryPaths;
312
+ }
313
+ /** Link (set) or unlink (null) a provider's CLI binary path. */
314
+ export function setBinaryPath(providerId, binPath) {
315
+ const state = readState();
316
+ if (binPath)
317
+ state.binaryPaths[providerId] = binPath;
318
+ else
319
+ delete state.binaryPaths[providerId];
320
+ writeState(state);
321
+ }
295
322
  // ---------- layout migration: v1 <ROOT>/<name> → <ROOT>/claude/<name> ----------
296
323
  /**
297
324
  * One-time migration of v1 Claude profiles (`<ROOT>/<name>/config`) into the
package/dist/providers.js CHANGED
@@ -143,12 +143,15 @@ function localBinary(binary) {
143
143
  return path.join(os.homedir(), ".local", "bin", binary);
144
144
  }
145
145
  /**
146
- * Resolve a CLI binary to an invocable command: its bare name when it is on
147
- * PATH, otherwise its ~/.local/bin path when the binary lives there (GUI apps
148
- * often run with a minimal PATH that omits it), otherwise the bare name so the
149
- * caller surfaces a clean "not found" error.
146
+ * Resolve a CLI binary to an invocable command: a user-linked path when set and
147
+ * present (the CLI is not on PATH but the user pointed us at it), otherwise its
148
+ * bare name when it is on PATH, otherwise its ~/.local/bin path when the binary
149
+ * lives there (GUI apps often run with a minimal PATH that omits it), otherwise
150
+ * the bare name so the caller surfaces a clean "not found" error.
150
151
  */
151
- export function resolveBinary(binary) {
152
+ export function resolveBinary(binary, linked) {
153
+ if (linked && fs.existsSync(linked))
154
+ return linked;
152
155
  const probe = spawnSync(binary, ["--version"], { stdio: "ignore", timeout: 5000 });
153
156
  if (probe.error?.code !== "ENOENT")
154
157
  return binary;
@@ -156,12 +159,15 @@ export function resolveBinary(binary) {
156
159
  return fs.existsSync(local) ? local : binary;
157
160
  }
158
161
  /**
159
- * Best-effort: is this provider's CLI binary installed? Probes `<binary>
160
- * --version` on PATH and treats only ENOENT as not-installed (a non-zero exit or
161
- * a timeout still means the binary exists), then falls back to the ~/.local/bin
162
- * install location. Used to gate enabling a provider the user hasn't installed.
162
+ * Best-effort: is this provider's CLI binary usable? True when a user-linked
163
+ * path is set and present, else when `<binary> --version` resolves on PATH
164
+ * (only ENOENT counts as not-installed — a non-zero exit or a timeout still
165
+ * means the binary exists), else when it lives in ~/.local/bin. Used to gate
166
+ * enabling a provider the user hasn't installed (or hasn't linked).
163
167
  */
164
- export function isProviderInstalled(id) {
168
+ export function isProviderInstalled(id, linked) {
169
+ if (linked && fs.existsSync(linked))
170
+ return true;
165
171
  const bin = PROVIDERS[id].binary;
166
172
  const probe = spawnSync(bin, ["--version"], { stdio: "ignore", timeout: 5000 });
167
173
  if (probe.error?.code !== "ENOENT")
package/dist/updates.js CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { spawnSync } from "node:child_process";
13
13
  import * as fs from "node:fs";
14
+ import * as os from "node:os";
14
15
  import * as path from "node:path";
15
16
  import { fileURLToPath } from "node:url";
16
17
  /** The published npm package + the repo whose GitHub Releases are the update source. */
@@ -102,9 +103,36 @@ export async function checkForUpdate(repo = UPDATE_REPO) {
102
103
  return { kind: "error", current, message: e instanceof Error ? e.message : String(e) };
103
104
  }
104
105
  }
106
+ /** Build a PATH that can locate `npm` even when this CLI was spawned from the
107
+ * GUI with a minimal environment PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) — the
108
+ * case a macOS/Linux app launched from Finder or the menu bar inherits. `npm`
109
+ * always ships in the same bin directory as the running `node`
110
+ * (`process.execPath`), so that dir goes first; the current PATH and the common
111
+ * global-install locations follow as fallbacks. Mirrors the GUI's Rust PATH
112
+ * recovery so the deepest spawn (`npm`) resolves regardless of launch context.
113
+ * Pure — every source is injectable for tests. */
114
+ export function npmSearchPath(nodeDir = path.dirname(process.execPath), currentPath = process.env.PATH ?? "", home = os.homedir()) {
115
+ return [
116
+ nodeDir,
117
+ currentPath,
118
+ "/opt/homebrew/bin",
119
+ "/usr/local/bin",
120
+ path.join(home, ".npm-global", "bin"),
121
+ path.join(home, ".local", "bin"),
122
+ ]
123
+ .filter(Boolean)
124
+ .join(path.delimiter);
125
+ }
105
126
  /** Self-update: reinstall the npm package at @latest (global). Streams npm's
106
- * own output; returns its exit code (0 = success). */
127
+ * own output; returns its exit code (0 = success). The spawn runs with a
128
+ * PATH that can always find `npm` ({@link npmSearchPath}), so a GUI-triggered
129
+ * update works from Finder/menu-bar launches with a stripped PATH; `shell` is
130
+ * required on Windows, where `npm` is a `.cmd` shim. */
107
131
  export function selfUpdate() {
108
- const r = spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], { stdio: "inherit" });
132
+ const r = spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
133
+ stdio: "inherit",
134
+ shell: process.platform === "win32",
135
+ env: { ...process.env, PATH: npmSearchPath() },
136
+ });
109
137
  return r.status ?? 1;
110
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-switch",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "description": "Switch between multiple Claude Code accounts on macOS, Linux, and Windows via isolated CLAUDE_CONFIG_DIR profiles",
5
5
  "repository": {
6
6
  "type": "git",