@event4u/agent-switch 1.0.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.
@@ -0,0 +1,376 @@
1
+ /** Profile roots, per-provider state, layout migration, and shared helpers. */
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { PROVIDER_IDS, provider, isProviderId } from "./providers.js";
6
+ import { credentialStore } from "./credentials.js";
7
+ export const HOME = os.homedir();
8
+ export const ROOT = process.env.AGENT_SWITCH_HOME ?? path.join(HOME, ".agent-switch");
9
+ export const STATE_FILE = path.join(ROOT, "state.json");
10
+ /** Optional profile type/tag, to tell work vs personal accounts apart. */
11
+ export const PROFILE_LABELS = ["Work", "Personal", "Other"];
12
+ export function isProfileLabel(v) {
13
+ return typeof v === "string" && PROFILE_LABELS.includes(v);
14
+ }
15
+ export const DEFAULT_AUTOSWITCH = { enabled: false, threshold: 95, tag: "all" };
16
+ export const DEFAULT_SWITCH_STRATEGY = "reset-first";
17
+ /**
18
+ * Providers enabled out of the box. Everything else is available but off by
19
+ * default — the user opts it in from the Providers settings tab. A provider that
20
+ * already has profiles is also treated as enabled on first migration, so
21
+ * upgrading never hides an account the user already set up.
22
+ */
23
+ export const PROVIDERS_ON_BY_DEFAULT = ["claude", "codex"];
24
+ function emptyActive() {
25
+ return { claude: null, codex: null, antigravity: null };
26
+ }
27
+ function labelKey(providerId, name) {
28
+ return `${providerId}/${name}`;
29
+ }
30
+ export function die(msg) {
31
+ console.error(`error: ${msg}`);
32
+ process.exit(1);
33
+ }
34
+ export function ensureRoot() {
35
+ fs.mkdirSync(ROOT, { recursive: true, mode: 0o700 });
36
+ }
37
+ function validateName(name) {
38
+ if (!/^[A-Za-z0-9_-]+$/.test(name))
39
+ die(`invalid profile name "${name}" (use a-z, 0-9, -, _)`);
40
+ }
41
+ export function profileDir(providerId, name) {
42
+ validateName(name);
43
+ return path.join(ROOT, providerId, name);
44
+ }
45
+ /**
46
+ * The value exported as the provider's isolation env var
47
+ * (`<root>/<provider>/<name>/config`).
48
+ *
49
+ * IMPORTANT (macOS/claude): Claude Code derives its Keychain service name by
50
+ * hashing the *exact, unresolved* CLAUDE_CONFIG_DIR string. This function is
51
+ * the single source of that string — never pass a resolved/realpath variant.
52
+ * Changing this path for an existing profile changes the hash (see
53
+ * `migrateLegacyLayout`, which re-seeds the credential across the move).
54
+ */
55
+ export function configDir(providerId, name) {
56
+ return path.join(profileDir(providerId, name), "config");
57
+ }
58
+ export function browserDir(providerId, name) {
59
+ return path.join(profileDir(providerId, name), "browser");
60
+ }
61
+ export function profileExists(providerId, name) {
62
+ return fs.existsSync(configDir(providerId, name));
63
+ }
64
+ /** Profile names for one provider (dirs with a `config/` subdir), sorted. */
65
+ export function listProfiles(providerId) {
66
+ const dir = path.join(ROOT, providerId);
67
+ if (!fs.existsSync(dir))
68
+ return [];
69
+ return fs
70
+ .readdirSync(dir, { withFileTypes: true })
71
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "config")))
72
+ .map((e) => e.name)
73
+ .sort();
74
+ }
75
+ /** Every profile across all providers. */
76
+ export function listAllProfiles() {
77
+ return PROVIDER_IDS.flatMap((p) => listProfiles(p).map((name) => ({ provider: p, name })));
78
+ }
79
+ export function requireProfile(providerId, name, cmd) {
80
+ if (!name)
81
+ die(`usage: agent-switch ${cmd} <profile>`);
82
+ if (!profileExists(providerId, name)) {
83
+ const existing = listProfiles(providerId).join(", ") || "(none)";
84
+ die(`profile "${name}" not found for ${providerId}. Existing: ${existing}`);
85
+ }
86
+ return name;
87
+ }
88
+ /** Best-effort account identity for a profile, via its provider. */
89
+ export function identity(providerId, name) {
90
+ const p = provider(providerId);
91
+ return p.readIdentity(p.configDirFor(configDir(providerId, name)));
92
+ }
93
+ // ---------- state (per-provider active), with v1 → v2 migration ----------
94
+ function normalizeLabels(raw) {
95
+ const out = {};
96
+ if (raw && typeof raw === "object") {
97
+ for (const [k, v] of Object.entries(raw)) {
98
+ if (isProfileLabel(v))
99
+ out[k] = v;
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+ function normalizeAutoSwitch(raw) {
105
+ const r = (raw ?? {});
106
+ const threshold = typeof r.threshold === "number" && r.threshold >= 1 && r.threshold <= 100 ? Math.round(r.threshold) : DEFAULT_AUTOSWITCH.threshold;
107
+ const tag = r.tag === "all" || isProfileLabel(r.tag) ? r.tag : DEFAULT_AUTOSWITCH.tag;
108
+ return { enabled: r.enabled === true, threshold, tag };
109
+ }
110
+ function emptyAutoSwitch() {
111
+ return { claude: { ...DEFAULT_AUTOSWITCH }, codex: { ...DEFAULT_AUTOSWITCH }, antigravity: { ...DEFAULT_AUTOSWITCH } };
112
+ }
113
+ function normalizeAutoSwitchMap(raw) {
114
+ const r = (raw ?? {});
115
+ // Migration: a single global `{ enabled, threshold }` (the old shape) applies
116
+ // to every provider, so turning per-provider on doesn't silently disable it.
117
+ if ("enabled" in r || "threshold" in r) {
118
+ const one = normalizeAutoSwitch(r);
119
+ return { claude: { ...one }, codex: { ...one }, antigravity: { ...one } };
120
+ }
121
+ const out = emptyAutoSwitch();
122
+ for (const p of PROVIDER_IDS)
123
+ out[p] = normalizeAutoSwitch(r[p]);
124
+ return out;
125
+ }
126
+ /** Default enabled-state for a provider when its config key is absent: on when
127
+ * in the default set OR when it already has profiles (never hide existing). */
128
+ function defaultSurfaces(id) {
129
+ const on = PROVIDERS_ON_BY_DEFAULT.includes(id) || listProfiles(id).length > 0;
130
+ return { cli: on, ui: on };
131
+ }
132
+ function normalizeSurfaces(raw, id) {
133
+ const r = (raw ?? {});
134
+ const d = defaultSurfaces(id);
135
+ return {
136
+ cli: typeof r.cli === "boolean" ? r.cli : d.cli,
137
+ ui: typeof r.ui === "boolean" ? r.ui : d.ui,
138
+ };
139
+ }
140
+ function emptyProviders() {
141
+ return Object.fromEntries(PROVIDER_IDS.map((p) => [p, defaultSurfaces(p)]));
142
+ }
143
+ function normalizeProvidersMap(raw) {
144
+ const r = (raw ?? {});
145
+ const out = emptyProviders();
146
+ for (const p of PROVIDER_IDS)
147
+ out[p] = normalizeSurfaces(r[p], p);
148
+ return out;
149
+ }
150
+ export function readState() {
151
+ try {
152
+ const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
153
+ const labels = normalizeLabels(raw?.labels);
154
+ const autoSwitch = normalizeAutoSwitchMap(raw?.autoSwitch);
155
+ const providers = normalizeProvidersMap(raw?.providers);
156
+ const switchStrategy = raw?.switchStrategy === "rotation-first" ? "rotation-first" : DEFAULT_SWITCH_STRATEGY;
157
+ const osNotifications = raw?.osNotifications === true;
158
+ // v1: { active: "<name>" } — a single Claude profile.
159
+ if (typeof raw?.active === "string") {
160
+ return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, switchStrategy, osNotifications };
161
+ }
162
+ if (raw?.active && typeof raw.active === "object") {
163
+ return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, switchStrategy, osNotifications };
164
+ }
165
+ return { active: emptyActive(), labels, autoSwitch, providers, switchStrategy, osNotifications };
166
+ }
167
+ catch {
168
+ /* absent / unparsable → default */
169
+ }
170
+ return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), switchStrategy: DEFAULT_SWITCH_STRATEGY, osNotifications: false };
171
+ }
172
+ export function readSwitchStrategy() {
173
+ return readState().switchStrategy;
174
+ }
175
+ export function setSwitchStrategy(strategy) {
176
+ const state = readState();
177
+ state.switchStrategy = strategy;
178
+ writeState(state);
179
+ }
180
+ export function readOsNotifications() {
181
+ return readState().osNotifications;
182
+ }
183
+ export function setOsNotifications(on) {
184
+ const state = readState();
185
+ state.osNotifications = on;
186
+ writeState(state);
187
+ }
188
+ export function writeState(state) {
189
+ ensureRoot();
190
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
191
+ }
192
+ export function activeFor(providerId) {
193
+ return readState().active[providerId];
194
+ }
195
+ export function setActive(providerId, name) {
196
+ const state = readState();
197
+ state.active[providerId] = name;
198
+ writeState(state);
199
+ }
200
+ export function labelFor(providerId, name) {
201
+ return readState().labels[labelKey(providerId, name)] ?? null;
202
+ }
203
+ /** Set (or clear, with null) a profile's label. */
204
+ export function setLabel(providerId, name, label) {
205
+ const state = readState();
206
+ const key = labelKey(providerId, name);
207
+ if (label)
208
+ state.labels[key] = label;
209
+ else
210
+ delete state.labels[key];
211
+ writeState(state);
212
+ }
213
+ /** Drop a removed profile's label so it can't linger as an orphan. */
214
+ export function clearLabel(providerId, name) {
215
+ setLabel(providerId, name, null);
216
+ }
217
+ /**
218
+ * Rename a profile: move its config dir, carry its Claude credential across the
219
+ * path change, and carry its state (active pointer + label) to the new name.
220
+ * Throws if the source is missing or the target already exists — the caller
221
+ * validates the new name first.
222
+ *
223
+ * Credential carry-over (mirrors `migrateLegacyLayout`): Claude on darwin keys
224
+ * its Keychain credential by a hash of the *exact* config-dir string, so the
225
+ * rename would otherwise orphan it (keychain miss at the new path → no token →
226
+ * usage/limits stop updating). Read it from the old path BEFORE the move, then
227
+ * re-seed a plaintext `.credentials.json` at the new path (Claude Code migrates
228
+ * it back into a hashed entry on first use) and drop the stale old-path entry.
229
+ * On linux/win the credential is a file that moves with the dir and the store's
230
+ * keychain ops are no-ops, so the re-seed is a harmless same-content rewrite.
231
+ */
232
+ export function renameProfile(providerId, from, to, store = credentialStore()) {
233
+ if (!profileExists(providerId, from))
234
+ throw new Error(`profile "${from}" does not exist`);
235
+ if (profileExists(providerId, to))
236
+ throw new Error(`profile "${to}" already exists`);
237
+ const fromConfig = configDir(providerId, from);
238
+ const toConfig = configDir(providerId, to);
239
+ // Capture the credential while the old path (and thus the darwin keychain hash)
240
+ // still resolves. Only Claude uses this store; codex/antigravity keep auth inside
241
+ // the config dir, which simply moves with the rename.
242
+ const cred = providerId === "claude" ? store.read(fromConfig) : null;
243
+ fs.renameSync(profileDir(providerId, from), profileDir(providerId, to));
244
+ if (cred) {
245
+ store.clearStale(toConfig); // a stale hashed entry at the new path would shadow the seed (darwin)
246
+ fs.writeFileSync(path.join(toConfig, ".credentials.json"), cred, { mode: 0o600 });
247
+ store.removeEntry(fromConfig); // drop the orphaned old-path keychain entry (darwin)
248
+ }
249
+ const state = readState();
250
+ if (state.active[providerId] === from)
251
+ state.active[providerId] = to;
252
+ const fromKey = labelKey(providerId, from);
253
+ const toKey = labelKey(providerId, to);
254
+ if (state.labels[fromKey] !== undefined) {
255
+ state.labels[toKey] = state.labels[fromKey];
256
+ delete state.labels[fromKey];
257
+ }
258
+ writeState(state);
259
+ }
260
+ export function readAutoSwitch(providerId) {
261
+ return readState().autoSwitch[providerId];
262
+ }
263
+ /** All providers' auto-switch configs (for `status` / the GUI tab dots). */
264
+ export function readAutoSwitchAll() {
265
+ return readState().autoSwitch;
266
+ }
267
+ export function setAutoSwitch(providerId, cfg) {
268
+ const state = readState();
269
+ state.autoSwitch[providerId] = normalizeAutoSwitch({ ...state.autoSwitch[providerId], ...cfg });
270
+ writeState(state);
271
+ return state.autoSwitch[providerId];
272
+ }
273
+ // ---------- provider enable/disable (per surface) ----------
274
+ /** Every provider's enabled surfaces (for `providers status` / the GUI tab). */
275
+ export function readProviders() {
276
+ return readState().providers;
277
+ }
278
+ /** Is a provider enabled? With a surface, that specific surface; without, either. */
279
+ export function providerEnabled(providerId, surface) {
280
+ const s = readState().providers[providerId];
281
+ return surface ? s[surface] : s.cli || s.ui;
282
+ }
283
+ /** Provider ids whose given surface is enabled (default `cli`), in canonical order. */
284
+ export function enabledProviders(surface = "cli") {
285
+ const cfg = readState().providers;
286
+ return PROVIDER_IDS.filter((p) => cfg[p][surface]);
287
+ }
288
+ /** Enable/disable one surface of a provider; returns its new surface state. */
289
+ export function setProviderSurface(providerId, surface, enabled) {
290
+ const state = readState();
291
+ state.providers[providerId] = { ...state.providers[providerId], [surface]: enabled };
292
+ writeState(state);
293
+ return state.providers[providerId];
294
+ }
295
+ // ---------- layout migration: v1 <ROOT>/<name> → <ROOT>/claude/<name> ----------
296
+ /**
297
+ * One-time migration of v1 Claude profiles (`<ROOT>/<name>/config`) into the
298
+ * provider-scoped layout (`<ROOT>/claude/<name>/config`). Copy-then-verify;
299
+ * aborts a single profile on verify failure and keeps the original.
300
+ *
301
+ * The config-dir path change alters the macOS Keychain service hash, so the
302
+ * credential is read from the old location and re-seeded as a plaintext
303
+ * `.credentials.json` (the supported path — Claude Code re-migrates it into the
304
+ * new hashed entry on first use). On linux/win the credential file simply moves
305
+ * with the dir. Idempotent: once every legacy dir is under `<provider>/`, a
306
+ * re-run does nothing. Returns the names migrated.
307
+ */
308
+ /**
309
+ * One-time marker so migration is not re-scanned on every command launch.
310
+ * Limitation (accepted, low-risk): a v1-layout profile that appears AFTER the
311
+ * marker is written (e.g. a backup restored into ROOT) is not auto-migrated —
312
+ * re-scanning on every launch to catch that is exactly the startup tax the
313
+ * marker exists to avoid. Delete `.layout-v2` to force a re-scan.
314
+ */
315
+ const LAYOUT_MARKER = path.join(ROOT, ".layout-v2");
316
+ export function migrateLegacyLayout(store = credentialStore()) {
317
+ if (!fs.existsSync(ROOT))
318
+ return [];
319
+ if (fs.existsSync(LAYOUT_MARKER))
320
+ return []; // already migrated — cheap early-out
321
+ const moved = [];
322
+ for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
323
+ if (!entry.isDirectory() || isProviderId(entry.name))
324
+ continue; // already provider-scoped
325
+ const oldProfile = path.join(ROOT, entry.name);
326
+ const oldConfig = path.join(oldProfile, "config");
327
+ if (!fs.existsSync(oldConfig))
328
+ continue; // not a v1 profile dir
329
+ const newProfile = path.join(ROOT, "claude", entry.name);
330
+ if (fs.existsSync(newProfile))
331
+ continue; // name clash → leave for the user to resolve
332
+ // Capture the credential BEFORE the path (and thus the hash) changes.
333
+ const cred = store.read(oldConfig);
334
+ fs.mkdirSync(path.join(ROOT, "claude"), { recursive: true, mode: 0o700 });
335
+ fs.cpSync(oldProfile, newProfile, { recursive: true });
336
+ const newConfig = path.join(newProfile, "config");
337
+ if (!fs.existsSync(newConfig)) {
338
+ fs.rmSync(newProfile, { recursive: true, force: true }); // verify failed → abort this one
339
+ continue;
340
+ }
341
+ // Re-seed the credential so the new-path hash resolves it (macOS); on
342
+ // linux/win the file was copied already. Always OVERWRITE: `cred` was read
343
+ // keychain-first, so it is the freshest known credential — a stale
344
+ // `.credentials.json` relic that `cpSync` copied must not shadow it, or the
345
+ // profile would migrate onto a dead token (round-2 review F2).
346
+ if (cred) {
347
+ store.clearStale(newConfig);
348
+ fs.writeFileSync(path.join(newConfig, ".credentials.json"), cred, { mode: 0o600 });
349
+ }
350
+ store.removeEntry(oldConfig); // drop the stale old-path keychain entry (darwin)
351
+ fs.rmSync(oldProfile, { recursive: true, force: true });
352
+ moved.push(entry.name);
353
+ }
354
+ // Drop the marker only when no legacy profile dir remains (a name clash may
355
+ // have left one for the user to resolve — then re-scan next time).
356
+ const stillLegacy = fs
357
+ .readdirSync(ROOT, { withFileTypes: true })
358
+ .some((e) => e.isDirectory() && !isProviderId(e.name) && fs.existsSync(path.join(ROOT, e.name, "config")));
359
+ if (!stillLegacy) {
360
+ try {
361
+ fs.writeFileSync(LAYOUT_MARKER, "2\n", { mode: 0o600 });
362
+ }
363
+ catch {
364
+ /* best-effort — a missing marker only costs a re-scan */
365
+ }
366
+ }
367
+ return moved;
368
+ }
369
+ export function readJson(p) {
370
+ try {
371
+ return JSON.parse(fs.readFileSync(p, "utf8"));
372
+ }
373
+ catch {
374
+ return null;
375
+ }
376
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Provider abstraction — the per-CLI-assistant differences behind one interface.
3
+ *
4
+ * All three tools use the same isolation architecture (one config dir per
5
+ * account via an env var), but differ in:
6
+ * - the env var name and what it points at (claude/codex: the dir itself;
7
+ * antigravity: HOME, under which the CLI nests `.gemini/antigravity-cli`);
8
+ * - where the credential/identity lives inside the config dir;
9
+ * - the one-shot invocation.
10
+ *
11
+ * Contracts verified live against real installs:
12
+ * claude CLAUDE_CONFIG_DIR = dir · Keychain (macOS)/.credentials.json · `claude -p`
13
+ * codex CODEX_HOME = dir · $dir/auth.json (0600) · `codex exec`
14
+ * antigravity HOME = home · $home/.gemini/{google_accounts,oauth_creds}.json · `agy --print`
15
+ *
16
+ * Everything is read-only + best-effort: unreadable identity returns null.
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import * as fs from "node:fs";
20
+ import * as os from "node:os";
21
+ import * as path from "node:path";
22
+ export const PROVIDER_IDS = ["claude", "codex", "antigravity"];
23
+ export function isProviderId(s) {
24
+ return PROVIDER_IDS.includes(s);
25
+ }
26
+ function readJson(p) {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(p, "utf8"));
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /** Decode the `email` claim from a JWT id_token, best-effort. */
35
+ function emailFromJwt(idToken) {
36
+ if (!idToken)
37
+ return null;
38
+ try {
39
+ const payload = idToken.split(".")[1];
40
+ if (!payload)
41
+ return null;
42
+ const json = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
43
+ return typeof json?.email === "string" ? json.email : null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /**
50
+ * Extract the signed-in email from an `agy` go-keyring keychain value. go-keyring
51
+ * stores the token blob as `go-keyring-base64:<base64(JSON)>`; the JSON carries an
52
+ * OAuth `id_token` (JWT) whose `email` claim is the account. Pure + best-effort,
53
+ * so it is unit-testable and returns null on any shape it does not recognise.
54
+ */
55
+ export function decodeGoKeyringEmail(raw) {
56
+ if (!raw)
57
+ return null;
58
+ try {
59
+ const prefix = "go-keyring-base64:";
60
+ const b64 = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
61
+ const obj = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
62
+ const idToken = obj?.id_token ?? obj?.token?.id_token ?? obj?.tokens?.id_token;
63
+ return emailFromJwt(typeof idToken === "string" ? idToken : undefined);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ const claude = {
70
+ id: "claude",
71
+ binary: "claude",
72
+ envVar: "CLAUDE_CONFIG_DIR",
73
+ configDirFor: (dir) => dir,
74
+ defaultConfigDir: () => path.join(os.homedir(), ".claude"),
75
+ credentialPath: (dir) => path.join(dir, ".credentials.json"),
76
+ readIdentity: (dir) => readJson(path.join(dir, ".claude.json"))?.oauthAccount?.emailAddress ?? null,
77
+ oneShotArgs: (prompt) => ["-p", prompt],
78
+ importFiles: [],
79
+ hasUsageReadout: true,
80
+ };
81
+ const codex = {
82
+ id: "codex",
83
+ binary: "codex",
84
+ envVar: "CODEX_HOME",
85
+ configDirFor: (dir) => dir,
86
+ defaultConfigDir: () => path.join(os.homedir(), ".codex"),
87
+ credentialPath: (dir) => path.join(dir, "auth.json"),
88
+ readIdentity: (dir) => {
89
+ const auth = readJson(path.join(dir, "auth.json"));
90
+ if (!auth)
91
+ return null;
92
+ // Prefer the id_token's email claim; fall back to the opaque account id.
93
+ return emailFromJwt(auth?.tokens?.id_token) ?? auth?.tokens?.account_id ?? null;
94
+ },
95
+ oneShotArgs: (prompt) => ["exec", prompt],
96
+ importFiles: ["auth.json"],
97
+ hasUsageReadout: true, // live via wham/usage (ChatGPT backend)
98
+ };
99
+ const antigravity = {
100
+ id: "antigravity",
101
+ binary: "agy",
102
+ // HOME is the isolation lever: agy keeps its config under $HOME/.gemini/ AND
103
+ // stores its OAuth token in the macOS keychain via go-keyring (fixed key
104
+ // service "gemini"/account "antigravity"). Because the keychain default +
105
+ // search list are HOME-scoped, pointing HOME at a per-profile dir — plus
106
+ // seeding a keychain there (see agy-keychain.ts) — isolates the token per
107
+ // account with no global keychain mutation.
108
+ envVar: "HOME",
109
+ // The exported dir IS the HOME; identity/keychain ops run `security` under it.
110
+ configDirFor: (dir) => dir,
111
+ defaultConfigDir: () => os.homedir(),
112
+ // The per-profile keychain file that holds agy's token (under the profile HOME).
113
+ credentialPath: (dir) => path.join(dir, "Library", "Keychains", "login.keychain-db"),
114
+ // Account identity: agy's token lives in the profile keychain; read it via
115
+ // `security` under this HOME and decode the email from its id_token.
116
+ readIdentity: (home) => {
117
+ if (process.platform !== "darwin")
118
+ return null;
119
+ const r = spawnSync("security", ["find-generic-password", "-s", "gemini", "-wa", "antigravity"], {
120
+ // Pin CFFIXED_USER_HOME with HOME so CoreFoundation reads the profile's
121
+ // keychain, not an ambient home (see agy-keychain.ts for the rationale).
122
+ env: { ...process.env, HOME: home, CFFIXED_USER_HOME: home },
123
+ encoding: "utf8",
124
+ timeout: 10000,
125
+ });
126
+ return decodeGoKeyringEmail(r.stdout?.trim() || null);
127
+ },
128
+ oneShotArgs: (prompt) => ["--print", prompt],
129
+ importFiles: [],
130
+ hasUsageReadout: false,
131
+ };
132
+ const PROVIDERS = { claude, codex, antigravity };
133
+ export function provider(id) {
134
+ return PROVIDERS[id];
135
+ }
136
+ export function allProviders() {
137
+ return PROVIDER_IDS.map((id) => PROVIDERS[id]);
138
+ }
139
+ /** Where an installer may drop a binary off the default PATH. Some CLIs (e.g.
140
+ * agy, installed via `curl … | bash`) land in ~/.local/bin, which a GUI app's
141
+ * minimal PATH frequently omits. */
142
+ function localBinary(binary) {
143
+ return path.join(os.homedir(), ".local", "bin", binary);
144
+ }
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.
150
+ */
151
+ export function resolveBinary(binary) {
152
+ const probe = spawnSync(binary, ["--version"], { stdio: "ignore", timeout: 5000 });
153
+ if (probe.error?.code !== "ENOENT")
154
+ return binary;
155
+ const local = localBinary(binary);
156
+ return fs.existsSync(local) ? local : binary;
157
+ }
158
+ /**
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.
163
+ */
164
+ export function isProviderInstalled(id) {
165
+ const bin = PROVIDERS[id].binary;
166
+ const probe = spawnSync(bin, ["--version"], { stdio: "ignore", timeout: 5000 });
167
+ if (probe.error?.code !== "ENOENT")
168
+ return true;
169
+ return fs.existsSync(localBinary(bin));
170
+ }