@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.
- package/LICENSE +21 -0
- package/README.md +383 -0
- package/dist/agy-keychain.js +70 -0
- package/dist/api.js +154 -0
- package/dist/apps.js +112 -0
- package/dist/args.js +85 -0
- package/dist/codex-reset.js +93 -0
- package/dist/codex-usage.js +105 -0
- package/dist/credentials.js +66 -0
- package/dist/daemon.js +393 -0
- package/dist/doctor.js +119 -0
- package/dist/gui-launch.js +91 -0
- package/dist/handoff.js +167 -0
- package/dist/history.js +35 -0
- package/dist/hooks.js +142 -0
- package/dist/index.js +1693 -0
- package/dist/keychain.js +54 -0
- package/dist/locks.js +89 -0
- package/dist/mappings.js +132 -0
- package/dist/notifications.js +70 -0
- package/dist/notify.js +84 -0
- package/dist/os-notify.js +66 -0
- package/dist/profiles.js +376 -0
- package/dist/providers.js +170 -0
- package/dist/service.js +223 -0
- package/dist/session-preview.js +163 -0
- package/dist/sessions.js +543 -0
- package/dist/settings-inherit.js +81 -0
- package/dist/share.js +257 -0
- package/dist/shellenv.js +116 -0
- package/dist/telemetry.js +269 -0
- package/dist/tmux.js +98 -0
- package/dist/tokens.js +88 -0
- package/dist/usage.js +208 -0
- package/package.json +39 -0
package/dist/profiles.js
ADDED
|
@@ -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
|
+
}
|