@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 event4u.app
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,383 @@
1
+ # agent-switch
2
+
3
+ Switch between multiple Claude Code accounts on macOS, Linux, and Windows with a single shell command — no repeated login/logout, no browser round-trips after the initial setup.
4
+
5
+ ```
6
+ $ asw work # switch active account
7
+ $ claude # runs on the "work" account
8
+ $ asw # list all profiles, active one marked *
9
+ * work matze@company.example
10
+ privat matze@personal.example
11
+ event4u dev@event4u.example
12
+ ```
13
+
14
+ ## Why not keychain snapshot swapping?
15
+
16
+ Most naive switchers snapshot the macOS Keychain entry (`Claude Code-credentials`) per account and restore it on switch. This breaks: Claude Code's OAuth **refresh tokens rotate on every refresh**, so a stored snapshot goes stale within hours and you're back to browser logins.
17
+
18
+ `agent-switch` uses **`CLAUDE_CONFIG_DIR` isolation** instead. Each profile gets its own config directory under `~/.agent-switch/<name>/config`. Claude Code derives a *separate* keychain entry per config dir, so every account stays logged in **live and independently**. Switching only changes which directory new `claude` invocations point at — nothing is snapshotted, nothing goes stale, and each account logs in via browser exactly once.
19
+
20
+ ## Install
21
+
22
+ **npm / npx** (any OS — needs Node ≥ 18). The package is published as
23
+ `@event4u/agent-switch` (the bare name `agent-switch` on npm is an unrelated
24
+ project):
25
+
26
+ ```bash
27
+ npm install -g @event4u/agent-switch # puts `agent-switch` on your PATH
28
+ # or run without installing:
29
+ npx @event4u/agent-switch --help
30
+ npx @event4u/agent-switch setup
31
+ ```
32
+
33
+ `npx` is handy for a one-off or first run; for daily use install globally so the
34
+ `agent-switch` / `asw` commands and the shell wrapper are always on PATH.
35
+
36
+ From source (for development):
37
+
38
+ ```bash
39
+ npm install # or: npm ci
40
+ npm run build
41
+ npm link # puts `agent-switch` on your PATH (npm creates a .cmd shim on Windows)
42
+ ```
43
+
44
+ Native package managers (Homebrew / Scoop / winget) are planned; `npm i -g`
45
+ above is the supported install path on every OS in the meantime.
46
+
47
+ Then add the shell integration — `agent-switch shellenv` auto-detects your shell,
48
+ or pass `--shell`:
49
+
50
+ | Shell | One-time setup |
51
+ |---|---|
52
+ | **zsh** (macOS default) | add `eval "$(agent-switch shellenv)"` to `~/.zshrc` |
53
+ | **bash** | add `eval "$(agent-switch shellenv)"` to `~/.bashrc` |
54
+ | **fish** | add `agent-switch shellenv --shell fish \| source` to `~/.config/fish/config.fish` |
55
+ | **PowerShell** (Windows) | add `agent-switch shellenv --shell powershell \| Out-String \| Invoke-Expression` to `$PROFILE` |
56
+ | **cmd.exe** | no wrapper — use `agent-switch run <name>` directly (see below) |
57
+
58
+ This defines:
59
+ - a `claude` wrapper that injects the active profile's `CLAUDE_CONFIG_DIR`
60
+ - `asw <name>` as shorthand for `agent-switch use <name>`, and bare `asw` for `agent-switch list`
61
+
62
+ Open a new terminal afterwards, then run `agent-switch doctor` to self-check the setup.
63
+
64
+ **cmd.exe has no function-wrapper story**, so there is no `claude` shadow there:
65
+ run a profile explicitly with `agent-switch run <name>` (which sets
66
+ `CLAUDE_CONFIG_DIR` for that one invocation). PowerShell is the recommended
67
+ Windows shell.
68
+
69
+ ## Setup your accounts
70
+
71
+ ```bash
72
+ # Adopt your existing default install as the first profile (keeps history/settings):
73
+ agent-switch import privat
74
+ agent-switch run privat # then /login once so this profile owns its own credential
75
+
76
+ # Add the other accounts — each opens Claude Code for a one-time login:
77
+ agent-switch add work
78
+ agent-switch add event4u
79
+ ```
80
+
81
+ ## Daily use
82
+
83
+ | Command | Effect |
84
+ |---|---|
85
+ | `asw work` | switch active account (new sessions) |
86
+ | `asw` | list profiles (live sessions shown) |
87
+ | `agent-switch run event4u` | one-shot session on another account **without** switching — all accounts can run in parallel |
88
+ | `agent-switch run work -- --resume` | pass any flags through to `claude` |
89
+ | `agent-switch status` | identity + 5h/7d usage for every profile |
90
+ | `agent-switch map work ~/projects/kunde-a` | `claude` in that repo always uses this account — no switching |
91
+ | `agent-switch share on` | one settings/skills/commands/agents tree for all profiles |
92
+ | `agent-switch sessions` | recent + live Claude sessions per profile |
93
+ | `agent-switch takeover <id> --to work` | move a session to another account and resume it there (see below) |
94
+ | `agent-switch web work` | claude.ai in a persistent per-profile browser (see below) |
95
+ | `agent-switch remove old --force` | delete a profile incl. its keychain entry |
96
+ | `agent-switch label work Work` | tag a profile (`Work` / `Personal` / `Other`, or `none` to clear) |
97
+ | `agent-switch providers status` | which providers are enabled (default: Claude + Codex; others off) |
98
+ | `agent-switch providers enable --provider gemini` | enable a provider (or `disable`; `--surface cli\|ui` for one surface) |
99
+ | `agent-switch autoswitch on --threshold 90` | opt-in auto-switch on limit (Claude-only; globally off by default; see below) |
100
+ | `agent-switch uninstall --force` | remove all agent-switch data, keychain entries, and the daemon |
101
+ | `agent-switch doctor` | per-OS self-check (claude on PATH, config, creds, share links) |
102
+
103
+ ## Per-repo accounts (directory mappings)
104
+
105
+ ```bash
106
+ agent-switch map work ~/projects/firma
107
+ agent-switch map privat ~/projects/hobby
108
+ ```
109
+
110
+ The `claude` shell wrapper resolves the current directory to the nearest mapped
111
+ ancestor. Precedence: **mapping > active profile > default**. `agent-switch mappings`
112
+ lists, `agent-switch unmap` removes.
113
+
114
+ ## Shared settings across accounts
115
+
116
+ ```bash
117
+ agent-switch share on # settings.json, CLAUDE.md, skills/, commands/, agents/
118
+ agent-switch share on --history # additionally share conversation history + plans/ (--resume, POSIX only)
119
+ agent-switch share sync # re-link any file a /config edit forked (see below)
120
+ agent-switch share off # removes only agent-switch-created links
121
+ ```
122
+
123
+ Sharing links from `~/.claude` (or `--source <profile>`) into each profile.
124
+ Two behaviors, because Claude Code's settings writer writes atomically:
125
+
126
+ - **Directories** (`skills/`, `commands/`, `agents/`) write **through** the link
127
+ — an edit in any profile lands in the shared source. This is the payoff.
128
+ Linked as a symlink on macOS/Linux, a junction on Windows (no admin needed).
129
+ - **Files** (`settings.json`, `keybindings.json`, `CLAUDE.md`) are linked too,
130
+ but an in-profile `/config` edit **forks** the file (the atomic rename
131
+ replaces the link). Run `agent-switch share sync` to push a fork back into the
132
+ shared source and re-link it (last sync wins across profiles). On Windows,
133
+ file symlinks need Developer Mode or admin; without it, only the directories
134
+ are shared and files are skipped with a message.
135
+
136
+ Account-scoped state (credentials, `.claude.json`, plugins) always stays per
137
+ profile.
138
+
139
+ ## Session handoff between accounts (takeover)
140
+
141
+ Switching the account should not cost the conversation. A Claude Code session is
142
+ a transcript file scoped to its project directory — and an *account* handoff
143
+ keeps that directory constant, so the session can move between profiles:
144
+
145
+ ```bash
146
+ agent-switch sessions # recent + live sessions, per profile (* = live)
147
+ agent-switch takeover <session-id> --to work # move it, then resume on "work"
148
+ agent-switch takeover <session-id> --to work --keep-source # copy + fork instead (source keeps its own)
149
+ ```
150
+
151
+ - **Move by default.** The transcript is transferred copy→verify→delete; the
152
+ original account no longer owns the session afterwards (no same-id divergence).
153
+ - **`--keep-source` forks.** The target resumes with `--fork-session` (fresh
154
+ session id there); the source's transcript stays untouched. Session-scoped
155
+ permission approvals do not carry into a fork — Claude asks once more.
156
+ - **Guard rails.** Takeover refuses when the source profile has live sessions
157
+ (close them first, or `--force`), never overwrites an existing transcript on
158
+ the target, and detects `share on --history` (nothing to move — profiles
159
+ already see one history tree).
160
+ - In an interactive terminal the takeover resumes the session directly; use
161
+ `--print-only` to just print the resume command. Transcripts are treated as
162
+ opaque blobs — moved whole, never parsed or rewritten.
163
+
164
+ ## Context monitoring
165
+
166
+ See how full each live session's context window is *before* the "97% of context
167
+ used" wall, get warned at a threshold, and act — all from own-session data read
168
+ locally (no API calls, own-profile only, never a cross-account comparison).
169
+
170
+ ```bash
171
+ agent-switch sessions # live sessions carry a context column: 67% · 134k/1000k
172
+ agent-switch status # active profile's worst live session's context, one line
173
+ agent-switch alerts on --threshold 80,95 # daemon records ONE coalesced notification per cycle on a crossing
174
+ agent-switch compact work # type /compact into work's agent-switch-managed tmux pane (idle-guarded)
175
+ ```
176
+
177
+ - **Context %** is read from the session's own transcript (the last finalized
178
+ main-chain turn's input side, matching Claude Code's own `/context`); the
179
+ window (200k / 1M) comes from a small per-model table. Unknown model → raw
180
+ tokens, no guessed percentage. Codex context comes in-band from its rollout.
181
+ - **Alerts** are **off by default** and, when on, name only the project +
182
+ percentage + a suggested `/compact` — never another profile. Enable per-session
183
+ liveness/compaction signals with `agent-switch hooks install` (adds additive,
184
+ reversible, share-aware hooks to the profile's `settings.json`).
185
+ - **Compaction is never automatic.** `agent-switch compact <profile>` only types
186
+ into an agent-switch-*managed* tmux pane (`run --tmux`), refuses while a turn
187
+ is in flight (`--force` to override), and prints the manual command anywhere
188
+ else. `/clear` is gated behind `--force` (it discards the conversation).
189
+
190
+ ## Browser sessions (claude.ai) without re-login
191
+
192
+ `agent-switch web <name>` launches a Playwright Chromium instance with a **persistent user-data-dir per profile** (`~/.agent-switch/<name>/browser`). You log in once per account; cookies and session live in that directory and are reused on every subsequent `agent-switch web <name>`. No cookie copying, no fragile session extraction — just isolated browser profiles.
193
+
194
+ Requires the optional dependency:
195
+
196
+ ```bash
197
+ npm install playwright && npx playwright install chromium
198
+ ```
199
+
200
+ (Alternative without any tooling: separate Chrome profiles or Firefox containers achieve the same thing.)
201
+
202
+ ## GUI apps (experimental, macOS)
203
+
204
+ Beyond the CLIs, agent-switch can launch **desktop/GUI clients** on an isolated
205
+ profile. Two strategies, picked per app:
206
+
207
+ - **env** — export the provider's config-dir env var (the same mechanism the
208
+ CLIs use, e.g. `CODEX_HOME`) when launching the app. Reuses the profile's
209
+ config dir.
210
+ - **user-data-dir** — pass Chromium's `--user-data-dir` to an Electron app, so
211
+ each profile gets its own isolated data dir and profiles run **in parallel**.
212
+
213
+ ```bash
214
+ agent-switch apps # list launchable GUI apps + installed state
215
+ agent-switch open <app> [profile] # launch an app on a profile (active if omitted)
216
+ ```
217
+
218
+ **Supported now:**
219
+
220
+ - **Claude Desktop** (`agent-switch open claude-desktop <profile>`) —
221
+ `user-data-dir` strategy, parallel accounts.
222
+ - **Codex desktop** (`agent-switch open codex-desktop <profile>`) — **two
223
+ layers** in one launch: `CODEX_HOME` (agent auth, reuses the codex profile) +
224
+ `--user-data-dir` (the ChatGPT web session).
225
+ - **Codex in VS Code** (`agent-switch open codex-ide <profile>`) — `CODEX_HOME`
226
+ only (the extension reads it from the editor's env).
227
+
228
+ More clients land via their roadmaps in `agents/roadmaps/`.
229
+
230
+ Caveats (why this is experimental):
231
+ - **macOS-only for now**; launched via the `open` command, not a Finder/Dock
232
+ double-click (a plain launch doesn't carry the flag/env → no isolation).
233
+ - **Unofficial / version-fragile** — no vendor ships a built-in account
234
+ switcher; an app update can change launcher behaviour. Re-verify per app.
235
+ - The profile's data dir is never swapped on a live app (that risks
236
+ SingletonLock/DB corruption); isolation is by launch flag/env only. Claude
237
+ Desktop's own default install (`~/Library/Application Support/Claude`) is
238
+ never touched — each profile gets its own dir.
239
+ - **Claude Desktop login is a web session** (not a refreshable token): each
240
+ profile signs in once and expires on its own schedule. Because an OAuth
241
+ callback can reach the wrong live window, **quit other Claude windows before
242
+ signing into a new account.**
243
+ - **Codex** — the desktop app lists only the *latest* session when a custom
244
+ `CODEX_HOME` is set (upstream openai/codex#14389; cosmetic, not an isolation
245
+ failure). For `codex-ide`, `CODEX_HOME` reaches a *newly launched* editor
246
+ process — if your editor is already running, quit it first so the new account
247
+ takes effect (also re-check openai/codex#7971 on your extension version).
248
+
249
+ Verify a candidate Electron app honours `--user-data-dir` on your machine:
250
+
251
+ ```bash
252
+ open -n -a "<App>" --args --user-data-dir="$HOME/Library/Application Support/<App>-test"
253
+ pgrep -lf "<App>.app/Contents/MacOS" # ≥2 processes ⇒ parallel instances
254
+ ls "$HOME/Library/Application Support/<App>-test" # populated ⇒ flag honoured
255
+ ```
256
+
257
+ ## Platform support
258
+
259
+ Every command works on macOS, Linux, and Windows, or degrades with an explicit
260
+ message. `agent-switch doctor` reports the live status for your machine.
261
+
262
+ | Capability | macOS | Linux | Windows |
263
+ |---|---|---|---|
264
+ | Profiles, switch, run, status, mappings | ✅ | ✅ | ✅ |
265
+ | Credential store | Keychain (per-dir), file fallback | plaintext file per profile | plaintext file per profile |
266
+ | `import` (login-free) | ✅ | ✅ | ✅ |
267
+ | Share **directories** (skills/commands/agents) | ✅ symlink | ✅ symlink | ✅ junction (no admin) |
268
+ | Share **files** (settings.json, …) | ✅ (forks on edit → `share sync`) | ✅ (same) | ⚠️ needs Developer Mode/admin; else skipped |
269
+ | `share --history` | ✅ | ✅ | ❌ POSIX-only |
270
+ | Shell wrapper | zsh/bash | zsh/bash/fish | PowerShell (cmd.exe → `run`) |
271
+ | `web` (claude.ai browser) | ✅ | ✅ desktop session | ✅ |
272
+
273
+ Full per-mechanism contract (verified/degraded/broken with sources) lives in
274
+ [`ADOPTED.md`](ADOPTED.md#per-os-contract-matrix).
275
+
276
+ ## Notes & gotchas
277
+
278
+ - **Never use `claude auth logout` to switch** — it revokes the token server-side. Switching via `agent-switch` never touches the other accounts' credentials.
279
+ - **Running sessions are unaffected by a switch.** Claude Code reads its config at startup; only *new* sessions pick up the new profile. Restart a session if you want it on the other account.
280
+ - `agent-switch remove` deletes the config dir **and** the profile's hashed keychain entry, and prunes its directory mappings.
281
+ - After `agent-switch import`, the imported profile and the old default login share one OAuth lineage — the first side to refresh wins. Stop using the bare default login afterwards.
282
+ - Profiles are fully isolated by default; use `agent-switch share on` for a managed shared-settings setup.
283
+ - Override the profile root with `AGENT_SWITCH_HOME` if you don't want `~/.agent-switch`.
284
+ - **`CLAUDE_CONFIG_DIR` relocates only the config home, not the OS state dir.** On Linux, Claude Code may also write to `~/.local/state/claude/` (XDG state), which is *not* per-profile — so any state kept there (not credentials, which live in each profile's config dir) is effectively shared across all profiles. Impact is limited and the exact contents vary by version; flagged here so a surprising cross-profile artifact there is not mistaken for a leak of account data. Credentials, `.claude.json`, and history stay per profile.
285
+ - **VS Code extension ignores `CLAUDE_CONFIG_DIR`** (upstream #30538) — the extension always uses the default `~/.claude`. Use the CLI (`agent-switch run` / the `claude` wrapper) for per-profile sessions; the extension is out of scope.
286
+
287
+ ## Background service & tray GUI (optional)
288
+
289
+ The CLI is fully usable on its own. Two optional layers add convenience:
290
+
291
+ - **Usage daemon** — a background service polls each profile's **own** usage
292
+ (only profiles with a live session + the active one), caches it, and notifies
293
+ on the active profile's threshold crossings (75% / 90% by default):
294
+
295
+ ```bash
296
+ agent-switch service install # launchd (macOS) / systemd --user (Linux) / Task Scheduler (Windows)
297
+ agent-switch service status # health, last poll, cached profiles
298
+ agent-switch service start|stop # manual control (no install)
299
+ ```
300
+
301
+ - **Tray/menubar GUI** (`gui/`, Tauri) — a small panel to see per-profile usage
302
+ and switch/open sessions. It is a **client of the CLI** (`agent-switch <cmd>
303
+ --json`) and never re-implements profile logic; the CLI core stays
304
+ dependency-free. See [`gui/README.md`](gui/README.md).
305
+
306
+ Usage readout is per-provider: Claude exposes an OAuth `/usage` endpoint;
307
+ **Codex and Gemini have no usage readout**, so they show identity only (never a
308
+ fabricated number).
309
+
310
+ ### Installing the desktop app (unsigned builds)
311
+
312
+ The tray GUI ships as native installers on the project's GitHub Releases —
313
+ `.dmg`/`.app` (macOS, universal), `.deb`/`.rpm`/`.AppImage` (Linux), and
314
+ `.msi`/`.exe` (Windows). They are **not code-signed or notarized yet**, so the
315
+ OS blocks the first launch. This is expected; here is how to open them.
316
+
317
+ **macOS** — Gatekeeper shows *"'agent-switch.app' can't be opened because Apple
318
+ cannot check it for malicious software."* Pick one:
319
+
320
+ - **Recommended:** open **System Settings → Privacy & Security**, scroll to the
321
+ message about `agent-switch`, and click **Open Anyway**, then confirm. On
322
+ macOS 15 (Sequoia) and later this is the only reliable route.
323
+ - **macOS 14 and earlier:** right-click (Control-click) the app in Finder →
324
+ **Open** → **Open** in the dialog. (Double-clicking only offers "Move to
325
+ Trash" — you must use the right-click menu to get an "Open" button.)
326
+ - **Terminal:** strip the quarantine flag directly —
327
+ `xattr -dr com.apple.quarantine /Applications/agent-switch.app`.
328
+
329
+ You only do this once per install; subsequent launches open normally.
330
+
331
+ **Windows** — SmartScreen shows *"Windows protected your PC."* Click **More
332
+ info → Run anyway**.
333
+
334
+ **Linux** — no Gatekeeper equivalent; for the AppImage, mark it executable
335
+ (`chmod +x agent-switch_*.AppImage`) before running.
336
+
337
+ To remove these prompts entirely, the builds need Apple Developer-ID signing +
338
+ notarization (macOS) and an Authenticode certificate (Windows) — wired via
339
+ release secrets when certificates are available.
340
+
341
+ ### Automatic account rotation (opt-in, off by default)
342
+
343
+ By default `agent-switch` only switches when **you** ask, and shows **your own**
344
+ usage per profile (the same information the vendors' native `/usage` surfaces
345
+ show). It never nags or ranks unless you turn rotation on.
346
+
347
+ > **Usage-policy warning.** Quota-triggered rotation across accounts can violate
348
+ > the providers' usage policies: it pools separate subscriptions to route around
349
+ > per-account rate limits. Enabling it is your decision — but the risk is real,
350
+ > and this notice states it plainly. Plain per-context profile switching
351
+ > (private / work / client) and viewing your own usage are **not** affected;
352
+ > this warning is specific to automated quota-driven rotation.
353
+
354
+ Auto-switch is **globally off by default** and must be explicitly enabled. It is
355
+ available **only for Claude** — the one provider with a usage readout to trigger
356
+ on; Codex and Gemini have none, so they are profile-switch only and never show an
357
+ auto-switch control. When enabled (`agent-switch autoswitch on [--threshold N]`),
358
+ the background daemon moves the active Claude profile to the account with the most
359
+ headroom once the active one hits the threshold. Switching only affects **new**
360
+ sessions; running ones keep their environment.
361
+
362
+ **On the record:** this capability was reviewed and **rejected** — first in the
363
+ v1 adoption analysis, then in a two-round AI-council review that was **unanimous**
364
+ that quota-triggered cross-account rotation crosses the providers' usage policies
365
+ and recommended removal. It ships here anyway, off by default, as a deliberate
366
+ decision that **overrode** that recommendation — not a relitigation that resolved
367
+ it. The full rejection rationale is preserved verbatim in
368
+ [`agents/roadmaps/skipped/road-to-agent-switch-autoswitch-rejected.md`](agents/roadmaps/skipped/road-to-agent-switch-autoswitch-rejected.md);
369
+ read it before enabling.
370
+
371
+ ## Layout
372
+
373
+ ```
374
+ ~/.agent-switch/
375
+ state.json # { "active": "work" }
376
+ work/
377
+ config/ # CLAUDE_CONFIG_DIR for this account (.claude.json, settings, history)
378
+ browser/ # persistent Playwright profile for claude.ai
379
+ privat/
380
+ config/
381
+ event4u/
382
+ config/
383
+ ```
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Per-profile macOS keychain isolation for the Antigravity CLI (`agy`).
3
+ *
4
+ * `agy` stores its OAuth login token in the macOS keychain via the Go library
5
+ * zalando/go-keyring, under a FIXED key (service "gemini" / account
6
+ * "antigravity") on the session's DEFAULT keychain. go-keyring shells out to
7
+ * `/usr/bin/security` by absolute path with no keychain argument, so there is no
8
+ * shim and no per-call override — the only lever is which keychain is "default".
9
+ *
10
+ * Verified behaviour (the load-bearing fact this module relies on): the keychain
11
+ * default + search list that `/usr/bin/security` reads are resolved from
12
+ * `$HOME/Library/Preferences/com.apple.security.plist` — they are HOME-SCOPED.
13
+ * So by pointing HOME at the profile's config dir (which agent-switch already
14
+ * does for antigravity) AND seeding a keychain + default/search-list under that
15
+ * HOME's `Library/`, each profile gets its OWN keychain with agy's fixed-key
16
+ * entry — fully isolated, concurrent across profiles, and WITHOUT touching the
17
+ * user's real login keychain or global session state.
18
+ *
19
+ * This is why redirecting HOME alone produced macOS's "no keychain found to
20
+ * secure 'antigravity'" dialog: the profile HOME had no keychain yet. Seeding
21
+ * one here is the fix.
22
+ */
23
+ import { spawnSync } from "node:child_process";
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ /** The per-profile keychain file, under the profile HOME's `Library/Keychains`.
27
+ * Named `login.keychain-db` so the isolated HOME looks like a normal macOS home
28
+ * and `security` resolves it without complaint. */
29
+ export function agyKeychainPath(home) {
30
+ return path.join(home, "Library", "Keychains", "login.keychain-db");
31
+ }
32
+ /**
33
+ * Ensure the profile HOME has an unlocked keychain set as its (HOME-scoped)
34
+ * default + sole search-list entry, so a subsequent `agy` launched with this
35
+ * HOME reads/writes its go-keyring token there. Idempotent and best-effort:
36
+ * macOS-only, and never throws — if setup fails, agy simply falls back to its
37
+ * own prompt rather than crashing the launch.
38
+ */
39
+ export function ensureAgyKeychain(home) {
40
+ if (process.platform !== "darwin")
41
+ return;
42
+ try {
43
+ const kc = agyKeychainPath(home);
44
+ fs.mkdirSync(path.dirname(kc), { recursive: true, mode: 0o700 });
45
+ // com.apple.security.plist (the HOME-scoped default/search-list store) lives here.
46
+ fs.mkdirSync(path.join(home, "Library", "Preferences"), { recursive: true, mode: 0o700 });
47
+ // CFFIXED_USER_HOME is set alongside HOME: macOS CoreFoundation preference
48
+ // resolution (what `security` uses for the HOME-scoped default/search list)
49
+ // prefers CFFIXED_USER_HOME over HOME, so an ambient value would silently
50
+ // redirect the keychain out of the profile and break isolation. Pin it too.
51
+ const env = { ...process.env, HOME: home, CFFIXED_USER_HOME: home };
52
+ const sec = (args) => spawnSync("security", args, { env, stdio: "ignore", timeout: 10000 });
53
+ // Empty password: within agent-switch's threat model (it already stores
54
+ // plaintext credentials per profile) and the keychain is inside the
55
+ // per-profile dir. create is skipped once the file exists.
56
+ if (!fs.existsSync(kc))
57
+ sec(["create-keychain", "-p", "", kc]);
58
+ // Point this HOME's default + search list at the profile keychain (HOME-scoped
59
+ // writes — the real session is untouched).
60
+ sec(["default-keychain", "-d", "user", "-s", kc]);
61
+ sec(["list-keychains", "-d", "user", "-s", kc]);
62
+ sec(["unlock-keychain", "-p", "", kc]);
63
+ // No `-t`/`-l`: disable the auto-lock timeout and lock-on-sleep so agy's next
64
+ // read within a session never triggers a re-unlock prompt.
65
+ sec(["set-keychain-settings", kc]);
66
+ }
67
+ catch {
68
+ /* best-effort: never block the launch on keychain setup */
69
+ }
70
+ }
package/dist/api.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Read-only OAuth endpoints for identity verification and usage display.
3
+ *
4
+ * Endpoints and header adopted from claude-swap (oauth.py):
5
+ * GET https://api.anthropic.com/api/oauth/profile — account identity
6
+ * GET https://api.anthropic.com/api/oauth/usage — 5h/7d windows
7
+ * both with `Authorization: Bearer <accessToken>` and the
8
+ * `anthropic-beta: oauth-2025-04-20` header.
9
+ *
10
+ * Deliberately NOT adopted from claude-swap: the token-refresh grant. Our
11
+ * profiles are live logins — Claude Code refreshes its own tokens per config
12
+ * dir. Refreshing from outside would rotate the refresh token underneath a
13
+ * running session for no benefit.
14
+ */
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { credentialStore } from "./credentials.js";
18
+ const BETA_HEADER = "oauth-2025-04-20";
19
+ /** A Claude profile's live credential via the per-OS store (keychain-then-file
20
+ * on darwin, plaintext file on linux/win32), given its config dir. Claude-only:
21
+ * the OAuth identity/usage endpoints below are Anthropic's. null if unreadable. */
22
+ export function readProfileCredential(configDir) {
23
+ return credentialStore().read(configDir);
24
+ }
25
+ export function accessTokenOf(credentials) {
26
+ try {
27
+ return JSON.parse(credentials)?.claudeAiOauth?.accessToken ?? null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ async function oauthGet(pathname, token) {
34
+ // The usage/profile endpoints rate-limit (429) aggressively — a few near-
35
+ // simultaneous reads (e.g. the GUI polling every profile) trip it, which
36
+ // surfaced as a profile's usage intermittently coming back empty. Retry a 429
37
+ // with a short backoff (honouring Retry-After), capped, so one throttled read
38
+ // recovers instead of silently degrading to "no data".
39
+ for (let attempt = 0;; attempt++) {
40
+ let res;
41
+ try {
42
+ res = await fetch(`https://api.anthropic.com${pathname}`, {
43
+ headers: {
44
+ Authorization: `Bearer ${token}`,
45
+ "anthropic-beta": BETA_HEADER,
46
+ // Send an honest tool User-Agent. Some clients report the endpoint
47
+ // rate-limits (429) without one; a real UA is good practice regardless.
48
+ "User-Agent": "agent-switch/1.0 (+https://github.com/event4u-app/agent-switch)",
49
+ },
50
+ signal: AbortSignal.timeout(10_000),
51
+ });
52
+ }
53
+ catch {
54
+ return null; // network / timeout — stay snappy, don't retry
55
+ }
56
+ if (res.status === 429 && attempt < 3) {
57
+ const retryAfter = Number(res.headers.get("retry-after"));
58
+ const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1000, 5_000) : 800 * (attempt + 1);
59
+ await new Promise((r) => setTimeout(r, waitMs));
60
+ continue;
61
+ }
62
+ if (!res.ok)
63
+ return null;
64
+ try {
65
+ return await res.json();
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ }
72
+ export function fetchProfile(token) {
73
+ return oauthGet("/api/oauth/profile", token);
74
+ }
75
+ export function fetchUsage(token) {
76
+ return oauthGet("/api/oauth/usage", token);
77
+ }
78
+ /** Classify an HTTP status (null = network/timeout failure) into an auth state.
79
+ * Pure — the testable core of `checkAuth`. 401/403 = credential present but
80
+ * rejected (login expired); 2xx = valid; anything else / no response = unknown
81
+ * (offline, transient, or an API change) — NEVER reported as "expired". */
82
+ export function classifyAuthStatus(status) {
83
+ if (status === null)
84
+ return "unknown";
85
+ if (status === 401 || status === 403)
86
+ return "expired";
87
+ if (status >= 200 && status < 300)
88
+ return "ok";
89
+ return "unknown";
90
+ }
91
+ /** Read-only login check for a Claude profile: is the stored credential still
92
+ * accepted by Anthropic? Never writes or refreshes a token. "no-credential"
93
+ * when nothing is stored; otherwise the classified profile-endpoint probe. */
94
+ export async function checkAuth(configDir) {
95
+ const creds = readProfileCredential(configDir);
96
+ const token = creds ? accessTokenOf(creds) : null;
97
+ if (!token)
98
+ return "no-credential";
99
+ let status = null;
100
+ try {
101
+ const res = await fetch("https://api.anthropic.com/api/oauth/profile", {
102
+ headers: {
103
+ Authorization: `Bearer ${token}`,
104
+ "anthropic-beta": BETA_HEADER,
105
+ "User-Agent": "agent-switch/1.0 (+https://github.com/event4u-app/agent-switch)",
106
+ },
107
+ signal: AbortSignal.timeout(10_000),
108
+ });
109
+ status = res.status;
110
+ }
111
+ catch {
112
+ status = null;
113
+ }
114
+ return classifyAuthStatus(status);
115
+ }
116
+ // Usage parsing/formatting moved to usage.ts (richer per-model + routines +
117
+ // thresholds). api.ts stays the low-level fetch layer.
118
+ // ---------------------------------------------------------------------------
119
+ // Process detection — adopted from claude-swap (process_detection.py): Claude
120
+ // Code writes session PID files to <config>/sessions/{pid}.json and IDE
121
+ // lockfiles to <config>/ide/{port}.lock. Reading them is the same mechanism
122
+ // Claude Code uses internally. Best-effort: absence of files ≠ proof of no
123
+ // session on older versions.
124
+ // ---------------------------------------------------------------------------
125
+ function pidAlive(pid) {
126
+ try {
127
+ process.kill(pid, 0);
128
+ return true;
129
+ }
130
+ catch (err) {
131
+ return err?.code === "EPERM"; // exists, not ours
132
+ }
133
+ }
134
+ /** PIDs of live Claude Code sessions running on a profile's config dir. */
135
+ export function liveSessionPids(configDir) {
136
+ const sessionsDir = path.join(configDir, "sessions");
137
+ let entries;
138
+ try {
139
+ entries = fs.readdirSync(sessionsDir);
140
+ }
141
+ catch {
142
+ return [];
143
+ }
144
+ const pids = [];
145
+ for (const entry of entries) {
146
+ const m = /^(\d+)\.json$/.exec(entry);
147
+ if (!m)
148
+ continue;
149
+ const pid = Number(m[1]);
150
+ if (pidAlive(pid))
151
+ pids.push(pid);
152
+ }
153
+ return pids;
154
+ }