@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/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
|
+
}
|