@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/dist/apps.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * GUI/desktop app launch layer (foundation).
3
+ *
4
+ * Extends agent-switch's per-profile isolation beyond the CLIs to GUI clients
5
+ * (Claude Desktop, Codex UI, …). Two isolation strategies:
6
+ *
7
+ * - "env" — export the provider's config-dir env var (the same
8
+ * mechanism the CLIs use) when launching the GUI, so a
9
+ * surface that reads e.g. CODEX_HOME isolates the same
10
+ * way. Reuses the existing profile config dir.
11
+ * - "user-data-dir" — pass Chromium's `--user-data-dir` to an Electron app,
12
+ * giving each profile its own fully isolated data dir
13
+ * (cookies/session). Uses a dedicated per-profile dir.
14
+ *
15
+ * `buildLaunch` is pure (path + argv construction only) so both strategies are
16
+ * unit-testable. The registry starts EMPTY on purpose: concrete apps are
17
+ * registered by the per-client roadmaps (claude-desktop, codex-ui), not here.
18
+ */
19
+ import * as path from "node:path";
20
+ import { spawnSync } from "node:child_process";
21
+ import { configDir, profileDir } from "./profiles.js";
22
+ /**
23
+ * Registered launchable GUI apps. Per-client roadmaps add entries (each owns its
24
+ * verification + caveats); the foundation provides the mechanism.
25
+ *
26
+ * claude-desktop: the account is a Chromium web session, isolated per profile
27
+ * by a distinct `--user-data-dir` (distinct single-instance lock ⇒ parallel
28
+ * accounts). The profile's own data dir is used — the default
29
+ * `~/Library/Application Support/Claude` install is never touched.
30
+ */
31
+ export const APPS = [
32
+ {
33
+ id: "claude-desktop",
34
+ displayName: "Claude Desktop",
35
+ bundleId: "com.anthropic.claudefordesktop",
36
+ provider: "claude",
37
+ strategy: "user-data-dir",
38
+ },
39
+ {
40
+ // Codex IDE extension: the editor reads CODEX_HOME from its process env, so
41
+ // isolation is the env strategy (reuses the codex profile's config dir).
42
+ // Targets VS Code; other editors are a future addition.
43
+ id: "codex-ide",
44
+ displayName: "VS Code",
45
+ bundleId: "com.microsoft.VSCode",
46
+ provider: "codex",
47
+ strategy: "env",
48
+ envVar: "CODEX_HOME",
49
+ },
50
+ {
51
+ // Codex desktop app is two-layer (both verified first-hand): CODEX_HOME
52
+ // isolates the codex agent auth/config (reuses the codex profile dir), and
53
+ // --user-data-dir isolates the Electron/ChatGPT web session (per-profile
54
+ // gui dir). Both are set in one launch.
55
+ id: "codex-desktop",
56
+ displayName: "Codex",
57
+ bundleId: "com.openai.codex",
58
+ provider: "codex",
59
+ strategy: "env+user-data-dir",
60
+ envVar: "CODEX_HOME",
61
+ },
62
+ {
63
+ // Antigravity is an Electron/VS Code-fork IDE whose login is a web session in
64
+ // an Electron user-data dir — isolate per profile via --user-data-dir (same
65
+ // strategy as claude-desktop). The agy CLI's HOME lever is separate.
66
+ id: "antigravity",
67
+ displayName: "Antigravity",
68
+ bundleId: "com.google.antigravity",
69
+ provider: "antigravity",
70
+ strategy: "user-data-dir",
71
+ },
72
+ ];
73
+ export function findApp(id, registry = APPS) {
74
+ return registry.find((a) => a.id === id) ?? null;
75
+ }
76
+ /** The per-profile Electron user-data dir for a "user-data-dir" app.
77
+ * `path.join` keeps separators OS-correct (the string-concat version broke the
78
+ * Windows CI: `\`-joined profileDir + `/`-appended tail = mixed separators). */
79
+ export function guiDataDir(app, name) {
80
+ return path.join(profileDir(app.provider, name), "gui", app.id);
81
+ }
82
+ /**
83
+ * Build the launch command for an app + profile (pure). macOS `open`:
84
+ * - env: open -n --env <VAR>=<profileConfigDir> -b <bundleId>
85
+ * - user-data-dir: open -n -b <bundleId> --args --user-data-dir=<guiDataDir>
86
+ * `-n` forces a new instance; distinct dir ⇒ distinct single-instance lock ⇒
87
+ * profiles run in parallel. Throws on a misconfigured descriptor.
88
+ */
89
+ export function buildLaunch(app, name) {
90
+ const wantsEnv = app.strategy === "env" || app.strategy === "env+user-data-dir";
91
+ const wantsUdd = app.strategy === "user-data-dir" || app.strategy === "env+user-data-dir";
92
+ if (wantsEnv && !app.envVar) {
93
+ throw new Error(`app "${app.id}" uses an env strategy but declares no envVar`);
94
+ }
95
+ // `open -n [--env VAR=<configDir>] -b <bundle> [--args --user-data-dir=<guiDir>]`.
96
+ // Some apps isolate two independent layers at once (e.g. Codex desktop: its
97
+ // agent config via CODEX_HOME + its web session via --user-data-dir).
98
+ const args = ["-n"];
99
+ if (wantsEnv)
100
+ args.push("--env", `${app.envVar}=${configDir(app.provider, name)}`);
101
+ args.push("-b", app.bundleId);
102
+ if (wantsUdd)
103
+ args.push("--args", `--user-data-dir=${guiDataDir(app, name)}`);
104
+ return { program: "open", args };
105
+ }
106
+ /** Best-effort: is the app installed? (macOS bundle-id lookup; false elsewhere.) */
107
+ export function isInstalled(app) {
108
+ if (process.platform !== "darwin")
109
+ return false;
110
+ const r = spawnSync("mdfind", [`kMDItemCFBundleIdentifier == '${app.bundleId}'`], { encoding: "utf8" });
111
+ return r.status === 0 && r.stdout.trim().length > 0;
112
+ }
package/dist/args.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * CLI argument parsing — extracted so it is unit-testable without running the
3
+ * `index.ts` entry point.
4
+ *
5
+ * The bug this fixes (round-2 review F1): the old
6
+ * `positional = rest.filter(a => !a.startsWith("--"))` stripped a flag but NOT
7
+ * its value, so `--provider codex` left `"codex"` in the positionals — and
8
+ * `remove --provider codex opfer` then targeted `codex` instead of `opfer`
9
+ * (destructive, silent). Value-flags consume their value here.
10
+ */
11
+ import { PROVIDER_IDS, isProviderId } from "./providers.js";
12
+ /** Flags that take a value (the next token), as opposed to boolean switches.
13
+ * Every flag read as a string in index.ts MUST be listed here, or its value
14
+ * leaks into the positionals and the flag reads back as boolean `true`
15
+ * (the bug behind `notify`/`providers --surface` silently losing their args). */
16
+ const VALUE_FLAGS = new Set([
17
+ "provider",
18
+ "shell",
19
+ "source",
20
+ "threshold",
21
+ "to",
22
+ "from",
23
+ "recent",
24
+ "kind",
25
+ "title",
26
+ "message",
27
+ "surface",
28
+ "tag",
29
+ "ack",
30
+ "brief",
31
+ ]);
32
+ /** Resolve a `--provider` value to a ProviderId; throws (not exits) on invalid
33
+ * so callers can test it. Undefined → claude. */
34
+ export function resolveProviderValue(v) {
35
+ if (v === undefined)
36
+ return "claude";
37
+ if (typeof v !== "string" || !isProviderId(v)) {
38
+ throw new Error(`unknown provider "${String(v)}" (choose: ${PROVIDER_IDS.join(", ")})`);
39
+ }
40
+ return v;
41
+ }
42
+ export function parseArgs(argv) {
43
+ const [cmd, ...rest] = argv;
44
+ const positional = [];
45
+ const flags = {};
46
+ for (let i = 0; i < rest.length; i++) {
47
+ const a = rest[i];
48
+ if (a.startsWith("--")) {
49
+ const key = a.slice(2);
50
+ if (VALUE_FLAGS.has(key)) {
51
+ flags[key] = rest[i + 1]; // consume the value...
52
+ i++; // ...so it never leaks into positionals
53
+ }
54
+ else {
55
+ flags[key] = true;
56
+ }
57
+ }
58
+ else {
59
+ positional.push(a);
60
+ }
61
+ }
62
+ return {
63
+ cmd,
64
+ providerId: resolveProviderValue(flags.provider),
65
+ providerExplicit: "provider" in flags,
66
+ positional,
67
+ flags,
68
+ };
69
+ }
70
+ /**
71
+ * `run` is special: everything after the profile name is passed THROUGH to the
72
+ * provider binary (including its own `--flags`), so it can't go through the
73
+ * general parser. Strip only `--provider <val>`, take the name, pass the rest.
74
+ */
75
+ export function parseRun(rest) {
76
+ const args = [...rest];
77
+ let providerId = "claude";
78
+ const pi = args.indexOf("--provider");
79
+ if (pi >= 0) {
80
+ providerId = resolveProviderValue(args[pi + 1]);
81
+ args.splice(pi, 2);
82
+ }
83
+ const name = args.shift();
84
+ return { providerId, name, args };
85
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Codex banked rate-limit reset credits: list + redeem.
3
+ *
4
+ * Endpoints (accept the stored OAuth bearer; extracted from the openai.chatgpt
5
+ * VS Code extension and confirmed by the community `codex-reset` tool — Codex
6
+ * itself only exposes `/reset` from v0.135):
7
+ *
8
+ * GET /backend-api/wham/rate-limit-reset-credits
9
+ * → { credits: [{ id, status: "available"|…, … }], available_count, … }
10
+ * POST /backend-api/wham/rate-limit-reset-credits/consume
11
+ * body { credit_id, redeem_request_id } // redeem_request_id = idempotency UUID
12
+ * → { code: "reset", windows_reset, redeemed_at, … }
13
+ *
14
+ * SAFETY: a reset credit is scarce and non-refundable, and redeeming is known to
15
+ * be occasionally buggy upstream. This module only redeems when the caller asks;
16
+ * the daemon's reset-first path guards it to at most once per reset cycle so a
17
+ * loop can never burn a user's whole balance. The redeem call itself cannot be
18
+ * exercised in CI (it would consume a real credit) — the request construction is
19
+ * unit-tested and it is validated live the first time a limit is hit.
20
+ */
21
+ import { randomUUID } from "node:crypto";
22
+ import { codexTokenOf } from "./codex-usage.js";
23
+ const BASE = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
24
+ function headers(accessToken, accountId) {
25
+ return {
26
+ Authorization: `Bearer ${accessToken}`,
27
+ ...(accountId ? { "chatgpt-account-id": accountId } : {}),
28
+ originator: "codex_cli_rs",
29
+ "User-Agent": "codex_cli_rs/0.134.0",
30
+ Accept: "application/json",
31
+ "Content-Type": "application/json",
32
+ };
33
+ }
34
+ /** Number of currently-available reset credits for a codex home, or null. */
35
+ export async function availableResetCredits(configDir) {
36
+ const tok = codexTokenOf(configDir);
37
+ if (!tok)
38
+ return null;
39
+ try {
40
+ const res = await fetch(BASE, { headers: headers(tok.accessToken, tok.accountId), signal: AbortSignal.timeout(10_000) });
41
+ if (!res.ok)
42
+ return null;
43
+ const raw = await res.json();
44
+ if (typeof raw?.available_count === "number")
45
+ return raw.available_count;
46
+ if (Array.isArray(raw?.credits))
47
+ return raw.credits.filter((c) => c?.status === "available").length;
48
+ return null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /**
55
+ * Redeem ONE available reset credit for a codex home. Returns {ok:false} when
56
+ * there is nothing to redeem or the call fails — never throws. The
57
+ * `redeem_request_id` is a fresh UUID (the documented idempotency key).
58
+ */
59
+ export async function redeemResetCredit(configDir) {
60
+ const tok = codexTokenOf(configDir);
61
+ if (!tok)
62
+ return { ok: false, reason: "no token" };
63
+ const h = headers(tok.accessToken, tok.accountId);
64
+ let creditId;
65
+ try {
66
+ const res = await fetch(BASE, { headers: h, signal: AbortSignal.timeout(10_000) });
67
+ if (!res.ok)
68
+ return { ok: false, reason: `list ${res.status}` };
69
+ const raw = await res.json();
70
+ const credits = Array.isArray(raw?.credits) ? raw.credits : [];
71
+ creditId = credits.find((c) => c?.status === "available")?.id ?? null;
72
+ if (!creditId)
73
+ return { ok: false, reason: "no available credit" };
74
+ }
75
+ catch (e) {
76
+ return { ok: false, reason: String(e?.message ?? e) };
77
+ }
78
+ try {
79
+ const res = await fetch(`${BASE}/consume`, {
80
+ method: "POST",
81
+ headers: h,
82
+ body: JSON.stringify({ credit_id: creditId, redeem_request_id: randomUUID() }),
83
+ signal: AbortSignal.timeout(10_000),
84
+ });
85
+ if (!res.ok)
86
+ return { ok: false, reason: `consume ${res.status}` };
87
+ const raw = await res.json();
88
+ return { ok: raw?.code === "reset", windowsReset: typeof raw?.windows_reset === "number" ? raw.windows_reset : undefined };
89
+ }
90
+ catch (e) {
91
+ return { ok: false, reason: String(e?.message ?? e) };
92
+ }
93
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Codex usage readout — live, from the ChatGPT backend.
3
+ *
4
+ * Codex has no `usage` CLI command, and `/backend-api/codex/*` is Cloudflare-
5
+ * gated (a bearer token gets a 403). But the `wham/*` endpoints DO accept the
6
+ * stored OAuth bearer, and `GET /backend-api/wham/usage` returns the live rate
7
+ * limits + banked reset credits (verified). Shape:
8
+ *
9
+ * { plan_type, rate_limit: { allowed, limit_reached,
10
+ * primary_window: { used_percent, limit_window_seconds, reset_at },
11
+ * secondary_window: null | {…} },
12
+ * rate_limit_reset_credits: { available_count } }
13
+ *
14
+ * We map the windows to the same UsageSnapshot the Claude readout produces, and
15
+ * carry the reset-credit count so the GUI can show "N resets available".
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ const WHAM_USAGE = "https://chatgpt.com/backend-api/wham/usage";
20
+ const CODEX_VERSION = "0.134.0"; // only used for the User-Agent string
21
+ /** Read a codex profile's OAuth access token + account id from its auth.json. */
22
+ export function codexTokenOf(configDir) {
23
+ try {
24
+ const raw = fs.readFileSync(path.join(configDir, "auth.json"), "utf8");
25
+ const parsed = JSON.parse(raw);
26
+ const accessToken = parsed?.tokens?.access_token;
27
+ if (typeof accessToken !== "string" || !accessToken)
28
+ return null;
29
+ const accountId = typeof parsed?.tokens?.account_id === "string" ? parsed.tokens.account_id : null;
30
+ return { accessToken, accountId };
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /** limit_window_seconds → a short window key + label (18000 = 5h, 604800 = 7d,
37
+ * 2592000 = 30d; anything else falls back to hours/days). */
38
+ function windowMeta(seconds) {
39
+ if (seconds === 18000)
40
+ return { key: "five_hour", label: "5h" };
41
+ if (seconds === 604800)
42
+ return { key: "seven_day", label: "7d" };
43
+ if (seconds === 2592000)
44
+ return { key: "monthly", label: "30d" };
45
+ if (typeof seconds === "number" && seconds > 0) {
46
+ return seconds >= 86400
47
+ ? { key: `w_${seconds}`, label: `${Math.round(seconds / 86400)}d` }
48
+ : { key: `w_${seconds}`, label: `${Math.round(seconds / 3600)}h` };
49
+ }
50
+ return { key: "window", label: "—" };
51
+ }
52
+ function toWindow(w) {
53
+ if (!w || typeof w !== "object")
54
+ return null;
55
+ const utilization = typeof w.used_percent === "number" ? Math.round(w.used_percent) : null;
56
+ const resetsAt = typeof w.reset_at === "number" ? new Date(w.reset_at * 1000).toISOString() : null;
57
+ if (utilization === null && resetsAt === null)
58
+ return null;
59
+ const { key, label } = windowMeta(w.limit_window_seconds);
60
+ return { key, label, utilization, resetsAt };
61
+ }
62
+ /** Pure parse of a `wham/usage` response into a UsageSnapshot. Exported for
63
+ * tests; degrades to null when no window carries data. */
64
+ export function parseCodexUsage(raw, capturedAt = new Date().toISOString()) {
65
+ const rl = raw?.rate_limit;
66
+ if (!rl || typeof rl !== "object")
67
+ return null;
68
+ const windows = [];
69
+ for (const w of [rl.primary_window, rl.secondary_window]) {
70
+ const win = toWindow(w);
71
+ if (win)
72
+ windows.push(win);
73
+ }
74
+ const available = raw?.rate_limit_reset_credits?.available_count;
75
+ const resetCredits = typeof available === "number" ? available : null;
76
+ if (windows.length === 0 && resetCredits === null)
77
+ return null;
78
+ return { windows, routines: null, capturedAt, resetCredits };
79
+ }
80
+ /** Live Codex usage for one profile home, or null (no token / network / 4xx). */
81
+ export async function readCodexUsage(configDir, capturedAt = new Date().toISOString()) {
82
+ const tok = codexTokenOf(configDir);
83
+ if (!tok)
84
+ return null;
85
+ let raw;
86
+ try {
87
+ const res = await fetch(WHAM_USAGE, {
88
+ headers: {
89
+ Authorization: `Bearer ${tok.accessToken}`,
90
+ ...(tok.accountId ? { "chatgpt-account-id": tok.accountId } : {}),
91
+ originator: "codex_cli_rs",
92
+ "User-Agent": `codex_cli_rs/${CODEX_VERSION}`,
93
+ Accept: "application/json",
94
+ },
95
+ signal: AbortSignal.timeout(10_000),
96
+ });
97
+ if (!res.ok)
98
+ return null;
99
+ raw = await res.json();
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ return parseCodexUsage(raw, capturedAt);
105
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Per-OS credential access — a read-only abstraction over where Claude Code
3
+ * keeps a profile's OAuth credential.
4
+ *
5
+ * By design there is NO `write()`: agent-switch never writes Claude Code's
6
+ * credential storage. The only supported way to place a credential is seeding
7
+ * a plaintext `.credentials.json` file inside the config dir (see `import`),
8
+ * which Claude Code then migrates into its own store on first use.
9
+ *
10
+ * Backends:
11
+ * - darwin → Keychain (hashed service per config dir), then the plaintext
12
+ * file as a fallback. Claude Code reads in this same order, so a
13
+ * stale hashed entry can shadow a file seed — hence `clearStale`.
14
+ * - linux → plaintext `.credentials.json` under CLAUDE_CONFIG_DIR only.
15
+ * - win32 → plaintext `.credentials.json` under CLAUDE_CONFIG_DIR only.
16
+ *
17
+ * Everything degrades gracefully: an unreadable credential returns null, and
18
+ * removal/clear are best-effort.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import * as keychain from "./keychain.js";
23
+ /** The plaintext credential file every OS uses as the seed / fallback. */
24
+ function readCredentialFile(configDir) {
25
+ try {
26
+ return fs.readFileSync(path.join(configDir, ".credentials.json"), "utf8");
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** linux/win32: credentials live only as a plaintext file under CLAUDE_CONFIG_DIR. */
33
+ class FileCredentialStore {
34
+ read(configDir) {
35
+ return readCredentialFile(configDir);
36
+ }
37
+ readDefault(defaultConfigDir) {
38
+ return readCredentialFile(defaultConfigDir);
39
+ }
40
+ removeEntry() {
41
+ return false; // no OS store; the file is removed with the profile dir
42
+ }
43
+ clearStale() {
44
+ /* no OS store to shadow the seed */
45
+ }
46
+ }
47
+ /** darwin: Keychain first (hashed service), then the plaintext file fallback. */
48
+ class DarwinCredentialStore {
49
+ read(configDir) {
50
+ return keychain.getPassword(keychain.serviceNameFor(configDir)) ?? readCredentialFile(configDir);
51
+ }
52
+ readDefault(defaultConfigDir) {
53
+ // The default install uses the un-suffixed service; file as parity fallback.
54
+ return keychain.readDefaultCredential() ?? readCredentialFile(defaultConfigDir);
55
+ }
56
+ removeEntry(configDir) {
57
+ return keychain.deletePassword(keychain.serviceNameFor(configDir));
58
+ }
59
+ clearStale(configDir) {
60
+ keychain.deletePassword(keychain.serviceNameFor(configDir));
61
+ }
62
+ }
63
+ /** The credential store for a platform (defaults to the host platform). */
64
+ export function credentialStore(platform = process.platform) {
65
+ return platform === "darwin" ? new DarwinCredentialStore() : new FileCredentialStore();
66
+ }