claude-multiacc 1.0.20 → 2.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/README.md +34 -402
- package/bin/claude +5 -5
- package/bin/cli.mjs +13 -1
- package/bin/codex +5 -3
- package/bin/multiacc-select +52 -0
- package/docs/ACCOUNT_OPERATIONS.md +396 -0
- package/docs/UNIFIED_SELECTOR.md +42 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/selector_policy.py +174 -0
- package/lib/selector_primitives.py +201 -0
- package/package.json +6 -4
- package/tests/run-tests.sh +40 -2
- package/tests/test_selector.py +209 -0
package/README.md
CHANGED
|
@@ -16,8 +16,31 @@ completely independent — separate manifests, credentials, telemetry, and logs
|
|
|
16
16
|
either provider can be used, re-authenticated, or emptied without touching the other.
|
|
17
17
|
|
|
18
18
|
Tested on: macOS (bash 3.2, zsh, Claude Code 2.1.207+, Codex CLI 0.147) and Ubuntu 24.04
|
|
19
|
-
(bash 5.2).
|
|
20
|
-
quota
|
|
19
|
+
(bash 5.2). The compatibility suite currently covers 644 sandboxed cases with no
|
|
20
|
+
network/quota use; the unified selector has its own adversarial contract suite.
|
|
21
|
+
|
|
22
|
+
## Unified selector for app-robot
|
|
23
|
+
|
|
24
|
+
`DEFAULT_ENGINE=both` uses one pure selector across the Claude and Codex pools. It
|
|
25
|
+
does not change either interactive shim and never launches an agent itself:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
multiacc-select --request-json - --response-json -
|
|
29
|
+
# equivalent npm dispatcher:
|
|
30
|
+
claude-multiacc select --request-json - --response-json -
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The command reads one strict UTF-8 JSON request and emits one JSON response using
|
|
34
|
+
the versioned `claude-multiacc/pool-selection.v2` protocol. It ranks the caller's
|
|
35
|
+
locked candidate snapshot by usable weekly/session headroom and returns one concrete
|
|
36
|
+
provider/account plus canonical snapshot and selection digests. `both` is a policy,
|
|
37
|
+
never an execution engine. The caller remains responsible for durable reservations,
|
|
38
|
+
database locks, retries, and launching the selected CLI.
|
|
39
|
+
|
|
40
|
+
Run `multiacc-select --version` for the selector protocol version. The npm package
|
|
41
|
+
version is independent and remains available through `claude-multiacc --version`.
|
|
42
|
+
See [the complete selector contract](docs/UNIFIED_SELECTOR.md) for the request fields,
|
|
43
|
+
ranking rules, stable errors, and caller boundary.
|
|
21
44
|
|
|
22
45
|
---
|
|
23
46
|
|
|
@@ -122,7 +145,7 @@ and backs off on **every** non-2xx, not just a 429 — honoring `Retry-After`, a
|
|
|
122
145
|
for 6 h on a refusal the server marks `x-should-retry: false`. `limits --force` overrides
|
|
123
146
|
all of it. The cadence is deliberately unhurried: several machines polling the same
|
|
124
147
|
accounts every minute earns a 429 with `Retry-After: 3600`, and telemetry then goes stale
|
|
125
|
-
for an hour at a time — which is exactly when every account starts scoring *
|
|
148
|
+
for an hour at a time — which is exactly when every account starts scoring *unknown* and
|
|
126
149
|
the picker loses its ability to tell them apart.
|
|
127
150
|
|
|
128
151
|
> **The usage endpoint needs an OAuth login, not a setup token.** A portable
|
|
@@ -131,7 +154,7 @@ the picker loses its ability to tell them apart.
|
|
|
131
154
|
> user:profile`: setup tokens are minted without that scope. So an account whose
|
|
132
155
|
> `.credentials.json` grant has lapsed keeps working perfectly while going **permanently
|
|
133
156
|
> dark for telemetry**, and a pool where that happens to every account ranks everything
|
|
134
|
-
>
|
|
157
|
+
> unknown and picks at random. `claude-accounts status` says `RANKING IS BLIND` when the
|
|
135
158
|
> pool is in that state, and the shim prints an hourly warning on a terminal. The fix is
|
|
136
159
|
> a real sign-in on the machine that polls (`claude-accounts login <acct-NN>`); since
|
|
137
160
|
> `limits.json` is one of the things `sync` pushes, only the **source** machine needs it —
|
|
@@ -145,7 +168,7 @@ working accounts, while the cutoff declares one unusable, and an account reading
|
|
|
145
168
|
hour ago may be well past 90% now. When nothing is in-window at all, a stale weekly
|
|
146
169
|
reading is still used **if its bucket has not reset yet** (a weekly bucket only rises
|
|
147
170
|
until then, so the number remains a true lower bound) — logged as `ranking=DEGRADED`.
|
|
148
|
-
Only when even that is unavailable does selection
|
|
171
|
+
Only when even that is unavailable does selection rank the account last, logged as
|
|
149
172
|
`ranking=BLIND`.
|
|
150
173
|
|
|
151
174
|
If an account's OAuth access token has been expired for a while (idle account,
|
|
@@ -232,12 +255,12 @@ above about the claude pool holds for the codex pool with these translations:
|
|
|
232
255
|
| Claude side | Codex side |
|
|
233
256
|
| --- | --- |
|
|
234
257
|
| `bin/claude` shim, `claude-accounts` CLI | `bin/codex` shim, `codex-accounts` CLI |
|
|
235
|
-
| pool `~/.claude-accounts
|
|
258
|
+
| pool `~/.claude-accounts` | pool `~/.codex-accounts` |
|
|
236
259
|
| `CLAUDE_CONFIG_DIR` per-account dirs | `CODEX_HOME` per-account dirs |
|
|
237
260
|
| `.credentials.json` (OAuth, machine-local) | `auth.json` (ChatGPT OAuth, machine-local) |
|
|
238
261
|
| `claude -p` auto-retry | `codex exec` auto-retry |
|
|
239
|
-
| Anthropic OAuth usage
|
|
240
|
-
| `
|
|
262
|
+
| Anthropic OAuth usage and Fable buckets | Codex usage endpoint and per-model buckets |
|
|
263
|
+
| `CLAUDE_*` pool controls | equivalent `CODEX_*` controls |
|
|
241
264
|
|
|
242
265
|
Same selection rule (weekly headroom primary, session tiebreak, ≥90% any-bucket
|
|
243
266
|
exclusion, equal scores rotate), same marker semantics (`.limited` cooldowns, `.expired` parks with
|
|
@@ -348,397 +371,6 @@ What install does (all reversible, nothing else):
|
|
|
348
371
|
cron entries for limits/health (both providers) + the daily auto-update.
|
|
349
372
|
- Both: `~/.claude-accounts/` skeleton + `accounts.json` manifest.
|
|
350
373
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
claude-accounts list # pool at a glance
|
|
355
|
-
claude-accounts status # per-account auth health, per-bucket limits incl. Fable, markers, last pick
|
|
356
|
-
claude-accounts expired # ONLY the accounts that cannot authenticate, and why (exit 1 if any)
|
|
357
|
-
claude-accounts relogin # sign those back in, one at a time (ids or --all to pick your own)
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
Every account in the pool is authenticated — `add` cannot register one otherwise.
|
|
361
|
-
|
|
362
|
-
### When an account stops working
|
|
363
|
-
|
|
364
|
-
Subscription logins expire (the refresh token has a finite life), can be revoked from
|
|
365
|
-
claude.ai, and an organization can disable Claude Code for one of its accounts entirely.
|
|
366
|
-
Such an account is **never selected** — `claude` keeps working on the healthy ones — and
|
|
367
|
-
shows up here:
|
|
368
|
-
|
|
369
|
-
```bash
|
|
370
|
-
claude-accounts expired
|
|
371
|
-
# acct-01 support@example.com EXPIRED refresh token expired 2026-08-05 (2d ago)
|
|
372
|
-
# fix: claude-accounts relogin acct-01
|
|
373
|
-
# acct-08 dev@example.com BLOCKED this account's organization has disabled
|
|
374
|
-
# Claude Code subscription access
|
|
375
|
-
# fix: claude-accounts relogin acct-08
|
|
376
|
-
|
|
377
|
-
claude-accounts relogin # re-authenticate everything a sign-in can fix
|
|
378
|
-
claude-accounts relogin acct-01 # or just one
|
|
379
|
-
claude-accounts relogin acct-01 --token # portable token instead (Mac + server)
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
`relogin` covers `BLOCKED` accounts too — a fresh sign-in re-issues the grant and usually
|
|
383
|
-
clears the block. Use `claude-accounts verify` (a real `-p` call per account) to re-test
|
|
384
|
-
one at any time; a PASS clears the park automatically.
|
|
385
|
-
|
|
386
|
-
`relogin` runs the same verified sign-in as `login` (it refuses to save a credential for
|
|
387
|
-
the wrong email), one account at a time, and syncs to the server once at the end.
|
|
388
|
-
`expired` exits **1** when anything needs a human, so it drops straight into a cron or
|
|
389
|
-
monitoring check. `list` tags such accounts `EXPIRED-LOGIN`, and `status` says
|
|
390
|
-
`selectable: NO` with the reason.
|
|
391
|
-
|
|
392
|
-
### Adopt this machine's existing login (no browser needed)
|
|
393
|
-
|
|
394
|
-
```bash
|
|
395
|
-
claude-accounts import you@example.com --id acct-01 --home mac
|
|
396
|
-
claude-accounts adopt acct-01
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
`adopt` makes the account dir a **symlink to `~/.claude`** — the machine's existing
|
|
400
|
-
login joins the pool with a single credential file (no copy of the OAuth grant exists
|
|
401
|
-
anywhere else, so refreshes can never race). This is how the two current accounts run:
|
|
402
|
-
acct-01 = the Mac's login, acct-02 = the server's login (adopted server-side).
|
|
403
|
-
|
|
404
|
-
### Add a fresh account (~1 min, one browser sign-in)
|
|
405
|
-
|
|
406
|
-
```bash
|
|
407
|
-
claude-accounts add # no email needed — read back from the sign-in
|
|
408
|
-
# or name it explicitly (fast-fails on a duplicate, and pre-fills the login page):
|
|
409
|
-
claude-accounts add new@example.com
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
This runs the **normal full Claude Code login** — the exact same
|
|
413
|
-
`claude auth login` flow the app itself uses (`claude.com/cai/oauth/authorize`, full
|
|
414
|
-
Claude Code scopes). A browser opens (or a sign-in link is printed); sign in to the
|
|
415
|
-
account you're adding, approve, and if a code is shown, paste it back. It draws on the
|
|
416
|
-
subscription — never API keys.
|
|
417
|
-
|
|
418
|
-
Using the full login on purpose: it does **not** hit the *"Sign in again to continue"*
|
|
419
|
-
step-up wall that the narrow long-lived-token grant triggers, and it stores an
|
|
420
|
-
auto-refreshing `.credentials.json`. That credential is **machine-local** (never synced),
|
|
421
|
-
which is what keeps two machines from invalidating each other's refresh token — so an
|
|
422
|
-
account you `add` on the Mac runs on the Mac, and you `add` it on the server (over SSH) if
|
|
423
|
-
you want it there too. The account is registered **only after the signed-in email is read
|
|
424
|
-
back**; an aborted login leaves nothing behind, and duplicate emails are refused.
|
|
425
|
-
|
|
426
|
-
```bash
|
|
427
|
-
claude-accounts login acct-NN # complete/refresh an existing account (full login)
|
|
428
|
-
claude-accounts add new@example.com --token # portable setup-token instead: works on BOTH
|
|
429
|
-
# machines and syncs to the server, but needs a
|
|
430
|
-
# recent sign-in (may show the step-up screen)
|
|
431
|
-
claude-accounts login acct-NN --token # same, for an existing account
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
Use `--token` when you specifically want one account usable on the server via the Mac's
|
|
435
|
-
sync (the ~1-year inference-only token that mirrors over). For everyday adds, the default
|
|
436
|
-
full login is smoother. `status` warns when a token nears end of life; re-run `login`.
|
|
437
|
-
|
|
438
|
-
### A setup token has no identity — name the account yourself
|
|
439
|
-
|
|
440
|
-
A setup token is minted with scope `user:inference` **alone**, so `claude auth status`
|
|
441
|
-
answers `{loggedIn, authMethod, apiProvider}` and no email — only an OAuth login reports
|
|
442
|
-
`email`/`orgId`/`subscriptionType`. Nothing on this side can therefore learn which account
|
|
443
|
-
approved a `setup-token` grant, and no future check will change that.
|
|
444
|
-
|
|
445
|
-
What follows, and what the CLI does about it:
|
|
446
|
-
|
|
447
|
-
- **`add <email> --token` requires the email** — there is nothing to read the account back
|
|
448
|
-
from. It registers under the name you gave and says so; naming no email is refused
|
|
449
|
-
outright rather than registering an anonymous slot.
|
|
450
|
-
- **`mint <acct-NN>` names the account in its prompt** (`approve in a browser signed in as
|
|
451
|
-
…`) and warns afterwards that the identity is unverifiable. Approving in a window signed
|
|
452
|
-
into a *different* account silently binds that account's subscription to the slot —
|
|
453
|
-
every machine then runs it under the wrong name. Sign in to the right account **first**,
|
|
454
|
-
in a fresh private window, then paste the link.
|
|
455
|
-
- `login <acct-NN> --token` compares against the manifest when an identity is readable
|
|
456
|
-
(it is not, today) and otherwise says what it is trusting.
|
|
457
|
-
|
|
458
|
-
Both accounts currently in the pool are live and verified: acct-01 (support@gowalkae.com,
|
|
459
|
-
Mac) and acct-02 (hasan@gowalkqa.com, server) — each adopted from its machine's existing
|
|
460
|
-
login, so each runs on the machine that holds its credential.
|
|
461
|
-
|
|
462
|
-
### Remove
|
|
463
|
-
|
|
464
|
-
```bash
|
|
465
|
-
claude-accounts remove acct-NN # deletes locally, propagates to the server
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
## Sync (multi-machine)
|
|
469
|
-
|
|
470
|
-
One machine is the **source of truth**; every other machine is a **target**. Sync is
|
|
471
|
-
one-way, fired automatically by `add`/`import`/`remove`/`mint` on the source and
|
|
472
|
-
manually via:
|
|
473
|
-
|
|
474
|
-
```bash
|
|
475
|
-
claude-accounts sync # and codex-accounts sync for the codex pool
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
Targets are the resolved sync target — env override, else the manifest's
|
|
479
|
-
`server`/`server_root`/`server_repo` (the Linux box), see *Pointing sync somewhere else*
|
|
480
|
-
below — plus an optional `peers` array for additional machines (e.g. a second Mac):
|
|
481
|
-
|
|
482
|
-
```json
|
|
483
|
-
"peers": [{"target": "gas@gas-mini", "root": "/Users/gas/.claude-accounts",
|
|
484
|
-
"repo": "/Users/gas/claude-multiacc"}]
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
Every target gets the manifest + per-account `server.token` (0600, claude pool) +
|
|
488
|
-
one-time seeds + advisory limit state; **never** `.credentials.json` or codex
|
|
489
|
-
`auth.json` in either direction (their refresh tokens rotate — two machines
|
|
490
|
-
refreshing one grant invalidate each other; codex accounts are signed in per
|
|
491
|
-
machine with the device-code flow instead). Removals propagate to every target; an
|
|
492
|
-
empty/corrupt manifest — or one with an invalid target/peer shape — refuses to sync
|
|
493
|
-
before anything is pushed anywhere. After every push each target re-seeds dirs and
|
|
494
|
-
re-runs its quick verification matrix (`post-sync` hook). Everything logs to
|
|
495
|
-
`sync.log`; failures are loud and non-zero.
|
|
496
|
-
|
|
497
|
-
A target machine's pool carries a `sync-role` file containing `replica`: such a pool
|
|
498
|
-
**never pushes** (manually or via auto-sync) — the source machine owns the account
|
|
499
|
-
set, which is what prevents two machines from overwriting each other's manifests.
|
|
500
|
-
Make account changes on the source; sign-ins (`login`/`relogin`) still happen on
|
|
501
|
-
whichever machine needs the credential.
|
|
502
|
-
|
|
503
|
-
### Pointing sync somewhere else (or nowhere)
|
|
504
|
-
|
|
505
|
-
The target is no longer baked in. In precedence order:
|
|
506
|
-
|
|
507
|
-
1. `CLAUDE_MULTIACC_SYNC_TARGET` / `CODEX_MULTIACC_SYNC_TARGET` (or the provider-neutral
|
|
508
|
-
`MULTIACC_SYNC_TARGET`), plus `…_SYNC_ROOT` and `…_SYNC_REPO` — env, so a daemon can
|
|
509
|
-
point one instance at its own target without rewriting a manifest it does not own;
|
|
510
|
-
2. the manifest's `server` / `server_root` / `server_repo` (what `install.sh --server`
|
|
511
|
-
wrote);
|
|
512
|
-
3. the historical default — unchanged, so existing installs keep pushing where they did.
|
|
513
|
-
|
|
514
|
-
A pool whose `sync-role` file says `replica` still never pushes, in any mode — the
|
|
515
|
-
marker is honored and reported before anything else, and local-only mode below only
|
|
516
|
-
narrows it further (a local pool pushes nothing whether or not it is a replica).
|
|
517
|
-
|
|
518
|
-
The value **`none`** (also `local`, `off`, `disabled`, empty) selects **local-only** mode:
|
|
519
|
-
there is no ssh target at all because something else — the app-robot panel and its runner
|
|
520
|
-
daemon — distributes the pool. `sync` then validates the manifest, re-seeds account dirs,
|
|
521
|
-
fixes permissions and pushes nowhere; mutations stop auto-pushing (and stop warning about
|
|
522
|
-
a server they are not supposed to reach). One run can force it with `sync --no-server`,
|
|
523
|
-
and a whole install can start that way:
|
|
524
|
-
|
|
525
|
-
```bash
|
|
526
|
-
./install.sh --no-server # manifest server = none
|
|
527
|
-
claude-accounts sync # "sync ok (local-only: … nothing pushed)"
|
|
528
|
-
claude-accounts sync --no-server # same, whatever the manifest says
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
## Fleet integration (panels, daemons, multiple instances)
|
|
532
|
-
|
|
533
|
-
Everything below is non-interactive and machine-readable: it is what a supervising panel
|
|
534
|
-
(app-robot) or any other daemon needs to run a pool across a fleet of Macs.
|
|
535
|
-
|
|
536
|
-
### `--json` on `list`, `status` and `limits`
|
|
537
|
-
|
|
538
|
-
```bash
|
|
539
|
-
claude-accounts list --json # pool + accounts, no refresh
|
|
540
|
-
claude-accounts status --json # the same, plus last_picked and credential detail
|
|
541
|
-
claude-accounts limits --json # refresh usage first, then emit the same document
|
|
542
|
-
codex-accounts list --json # identical schema, provider: "codex"
|
|
543
|
-
```
|
|
544
|
-
|
|
545
|
-
Stdout is the JSON document and nothing else. One shape for every verb and both
|
|
546
|
-
providers (`lib/report.py`), so a consumer writes one parser:
|
|
547
|
-
|
|
548
|
-
```jsonc
|
|
549
|
-
{
|
|
550
|
-
"schema": "claude-multiacc/pool.v1", // bumped only on a breaking change
|
|
551
|
-
"provider": "claude", "kind": "list", "generated_at": "…Z",
|
|
552
|
-
"machine": "mac", "host": "my-mini",
|
|
553
|
-
"pool": {
|
|
554
|
-
"root": "/Users/gas/.claude-accounts", "manifest": "…/accounts.json",
|
|
555
|
-
"threshold": 90,
|
|
556
|
-
"sync": {"mode": "server|local", "target": "root@…", "root": "…", "repo": "…",
|
|
557
|
-
"role": "source|replica", "peers": [...]}
|
|
558
|
-
},
|
|
559
|
-
"accounts": [{
|
|
560
|
-
"id": "acct-05", "email": "…", "home": "mac", "added_at": "…Z",
|
|
561
|
-
"home_dir": "/Users/gas/.claude-accounts/acct-05", "adopted": false,
|
|
562
|
-
"status": "active", // active|limited|expired|blocked|missing|remote
|
|
563
|
-
"state": "ok", "label": "OK", "reason": "…", "fix": "…",
|
|
564
|
-
"selectable": true, "needs_login": false,
|
|
565
|
-
"credential_class": "portable", // portable|machine-local|none
|
|
566
|
-
"portable": true,
|
|
567
|
-
"credentials": {"oauth": true, "token": true, "oauth_expires_at": "…Z",
|
|
568
|
-
"oauth_refresh_expires_at": "…Z", "token_minted_at": "…Z",
|
|
569
|
-
"token_age_days": 39},
|
|
570
|
-
"limited": false, "limit_reset_at": null, "limit_detail": null,
|
|
571
|
-
"usage": {"fetched_at": "…Z", "age_seconds": 41, "source": "oauth",
|
|
572
|
-
"max_percent": 62, "weekly_percent": 62, "session_percent": 18,
|
|
573
|
-
"buckets": [{"name": "weekly_scoped:Fable", "group": "weekly",
|
|
574
|
-
"percent": 62, "resets_at": "…Z"}]}
|
|
575
|
-
}],
|
|
576
|
-
"summary": {"total": 3, "active": 2, "limited": 1, "needs_login": 0,
|
|
577
|
-
"portable": 3, "selectable": 2},
|
|
578
|
-
"warnings": []
|
|
579
|
-
}
|
|
580
|
-
```
|
|
581
|
-
|
|
582
|
-
`status` values mean exactly what the shim does: only `active` accounts are selected;
|
|
583
|
-
`limited` authenticates but is parked until `limit_reset_at`; `expired`/`blocked`/
|
|
584
|
-
`missing` need a human (`fix` says what); `remote` is not logged in here on purpose.
|
|
585
|
-
A consumer that only distinguishes *usable / parked / needs-a-human* can read
|
|
586
|
-
`selectable` and `needs_login` and ignore the rest; `state` keeps the raw audit verdict
|
|
587
|
-
without the limit overlay. `limited` follows the shim's marker rule exactly — a marker
|
|
588
|
-
whose reset time has passed does not count, an unreadable one does.
|
|
589
|
-
|
|
590
|
-
### Which accounts are portable
|
|
591
|
-
|
|
592
|
-
| Credential | Where it lives | Class | Can it be copied to another machine? |
|
|
593
|
-
| --- | --- | --- | --- |
|
|
594
|
-
| claude setup-token | `<acct>/server.token` (`sk-ant-oat…`, ~1 year, inference-only) | **portable** | **Yes** — this is the only credential the fleet distributes |
|
|
595
|
-
| claude OAuth login | `<acct>/.credentials.json` | machine-local | **No.** Its refresh token rotates on every refresh; a second machine refreshing the same grant strands the first |
|
|
596
|
-
| codex ChatGPT login | `<acct>/auth.json` | machine-local | **No** — same rotating-refresh-token reason. Codex has **no** portable credential type |
|
|
597
|
-
|
|
598
|
-
Non-portable accounts are not a dead end: they show up as `credential_class:
|
|
599
|
-
"machine-local"`, and the way to run one somewhere else is one interactive sign-in there
|
|
600
|
-
(`claude-accounts login <id>`, `codex-accounts login <id>` — the codex device-code flow
|
|
601
|
-
works over SSH). A claude account can be *converted* to the portable class with one
|
|
602
|
-
`claude-accounts mint <id>` on a machine that is already signed in.
|
|
603
|
-
|
|
604
|
-
### `export-credential` / `import-credential`
|
|
605
|
-
|
|
606
|
-
```bash
|
|
607
|
-
claude-accounts export-credential acct-05 # blob to stdout
|
|
608
|
-
claude-accounts export-credential acct-05 --out cred.json # …or to a 0600 file
|
|
609
|
-
claude-accounts export-credential acct-05 --identity-only # registry entry, no secret
|
|
610
|
-
cat cred.json | claude-accounts import-credential # install it here
|
|
611
|
-
claude-accounts import-credential --in cred.json --home mac --no-sync
|
|
612
|
-
```
|
|
613
|
-
|
|
614
|
-
`export-credential` is **read-only on the source pool** — nothing is refreshed, re-minted
|
|
615
|
-
or marked, so exporting a live account cannot disturb it. It refuses anything that is not
|
|
616
|
-
portable, with the reason and the fix, and its exit code is the contract a daemon branches
|
|
617
|
-
on:
|
|
618
|
-
|
|
619
|
-
| Exit | Meaning |
|
|
620
|
-
| --- | --- |
|
|
621
|
-
| 0 | blob written |
|
|
622
|
-
| 2 | usage error / unknown account / malformed blob |
|
|
623
|
-
| 3 | the credential is machine-local — copying it would break both machines |
|
|
624
|
-
| 4 | the account has no credential material here |
|
|
625
|
-
| 5 | credential present but unusable (corrupt, or an API key rather than a setup-token) |
|
|
626
|
-
|
|
627
|
-
The blob is self-contained JSON (`format: "claude-multiacc/credential"`, version 1):
|
|
628
|
-
identity metadata (`id`, `email`, `home`, `added_at`), where it came from, and — for
|
|
629
|
-
class `portable` — the credential itself. **Treat a `portable` blob exactly like the
|
|
630
|
-
credential it contains.**
|
|
631
|
-
|
|
632
|
-
`import-credential` reads it on stdin (or `--in PATH`), creates the account dir and the
|
|
633
|
-
manifest entry as needed, writes the credential 0600, and clears any dead-auth marker.
|
|
634
|
-
It is idempotent **by email**: re-importing an account that is already in the pool
|
|
635
|
-
refreshes it in place rather than adding a second entry, so a daemon can push the same
|
|
636
|
-
pool to a machine over and over. It refuses a blob from the other provider and refuses an
|
|
637
|
-
API key. It also refuses to land an email in a second slot, or to write into a slot that
|
|
638
|
-
belongs to a different account — `--force` settles exactly those identity conflicts, and
|
|
639
|
-
nothing else: an **adopted** (symlinked) account is refused unconditionally, `--force`
|
|
640
|
-
included, because writing a credential through that link would put it outside the pool.
|
|
641
|
-
An `identity` blob registers the account and says which sign-in it still needs. The
|
|
642
|
-
imported account keeps the `added_at` it had on the source machine, so the same account
|
|
643
|
-
reads identically everywhere.
|
|
644
|
-
|
|
645
|
-
For codex, `export-credential` always refuses with exit 3 (there is nothing portable to
|
|
646
|
-
export) and points at the device-code login; `--identity-only` is how a codex account list
|
|
647
|
-
reaches another machine.
|
|
648
|
-
|
|
649
|
-
### Instance-scoped pools
|
|
650
|
-
|
|
651
|
-
`CLAUDE_ACCOUNTS_ROOT` / `CODEX_ACCOUNTS_ROOT` relocate a pool, so several app-robot
|
|
652
|
-
instances can share one machine and user account without ever seeing each other's
|
|
653
|
-
accounts. Both the CLIs **and the shims** resolve the same root, so a task launched with
|
|
654
|
-
the env set runs under that instance's pool. (The older `…_ACCOUNTS_DIR` spelling still
|
|
655
|
-
works; `…_ROOT` wins when both are set.)
|
|
656
|
-
|
|
657
|
-
```bash
|
|
658
|
-
export CLAUDE_ACCOUNTS_ROOT=/opt/app-robot/teamA/claude-accounts
|
|
659
|
-
export CODEX_ACCOUNTS_ROOT=/opt/app-robot/teamA/codex-accounts
|
|
660
|
-
./install.sh --no-server --instance teamA
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
An install whose roots are not the defaults is an **instance install**: its LaunchAgents
|
|
664
|
-
(and cron lines on Linux) are labelled `com.claude-multiacc.<instance>.*` so instances
|
|
665
|
-
never overwrite each other's agents, and they carry the pool roots in their environment.
|
|
666
|
-
It deliberately does **not** touch the shell rc PATH block, `/etc/profile.d`, or the
|
|
667
|
-
`/usr/local/bin` shims — one interactive PATH cannot serve two pools, and those belong to
|
|
668
|
-
the default install. `--instance NAME` labels it explicitly; otherwise the label is
|
|
669
|
-
derived from the pool root. Uninstalling an instance removes only that instance's agents.
|
|
670
|
-
|
|
671
|
-
## Pinning & env switches
|
|
672
|
-
|
|
673
|
-
| Env | Effect |
|
|
674
|
-
| --- | --- |
|
|
675
|
-
| `CLAUDE_ACCOUNT=acct-03` | pin this invocation to one account (wins over markers) |
|
|
676
|
-
| `CLAUDE_CONFIG_DIR=...` | shim passes straight through (scripts can pin the old way) |
|
|
677
|
-
| `CLAUDE_MULTIACC_DISABLE=1` | bypass selection entirely |
|
|
678
|
-
| `CLAUDE_MULTIACC_CLIENT_LIMITS=0` | do not read the client's own rate-limit records (same for `CODEX_MULTIACC_CLIENT_LIMITS`) |
|
|
679
|
-
| `CLAUDE_MULTIACC_CLIENT_SCAN_TTL=<s>` | how long a clean client-limit scan is memoized (default 20s; same for `CODEX_MULTIACC_CLIENT_SCAN_TTL`) |
|
|
680
|
-
| `CLAUDE_SHIM_RETRY=0` | disable the `-p` auto-retry |
|
|
681
|
-
| `CLAUDE_ACCOUNTS_ROOT=...` | relocate the pool — one isolated pool per app-robot instance (legacy spelling: `CLAUDE_ACCOUNTS_DIR`) |
|
|
682
|
-
| `CLAUDE_MULTIACC_SYNC_TARGET=...` | sync target, overriding the manifest; `none` = local-only |
|
|
683
|
-
| `CLAUDE_MULTIACC_SYNC_ROOT=... / _SYNC_REPO=...` | remote pool root / addon repo that goes with it |
|
|
684
|
-
|
|
685
|
-
The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
|
|
686
|
-
`CODEX_HOME` (passthrough), `CODEX_MULTIACC_DISABLE`, `CODEX_SHIM_RETRY`,
|
|
687
|
-
`CODEX_SHIM_SELECT`, `CODEX_ACCOUNTS_ROOT` (legacy `CODEX_ACCOUNTS_DIR`),
|
|
688
|
-
`CODEX_MULTIACC_SYNC_TARGET`, `CODEX_MULTIACC_THRESHOLD`.
|
|
689
|
-
|
|
690
|
-
## Verification
|
|
691
|
-
|
|
692
|
-
```bash
|
|
693
|
-
tests/run-tests.sh # 525 sandboxed tests, no quota
|
|
694
|
-
claude-accounts verify # real matrix: `claude -p "reply OK"` per authed account
|
|
695
|
-
claude-accounts verify --quick# auth presence/expiry only, no inference
|
|
696
|
-
claude-accounts limits # live per-bucket usage incl. the Fable bucket
|
|
697
|
-
claude-accounts health # limits + full verify, logs to health.log, notifies on failure (Mac)
|
|
698
|
-
```
|
|
699
|
-
|
|
700
|
-
Verified end-to-end on both machines (2026-07-13): full matrix PASS, 20-invocation shim
|
|
701
|
-
spread clean, real project `-p` through the shim OK on Mac (`~/pipeline`) and server
|
|
702
|
-
(`/opt/inapp-onboarding`), uninstall→stock→reinstall roundtrip byte-identical, three
|
|
703
|
-
server systemd services (`vpn-server-management`, `inapp-onboarding`, `vpn-seo-panel`)
|
|
704
|
-
repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
|
|
705
|
-
|
|
706
|
-
## Troubleshooting
|
|
707
|
-
|
|
708
|
-
- **`claude` resolves to the real binary, not the shim** — open a new shell, or check
|
|
709
|
-
that the marked block is the LAST PATH manipulation in your rc file
|
|
710
|
-
(`grep -A2 'claude-multiacc >>>' ~/.zshrc`).
|
|
711
|
-
- **An account never gets picked** — `claude-accounts status`: no auth on this machine,
|
|
712
|
-
a dead login (`selectable: NO`, see `claude-accounts expired`), or an active `.limited`
|
|
713
|
-
marker (shows bucket + minutes to reset).
|
|
714
|
-
- **"Failed to authenticate: OAuth session expired and could not be refreshed"** — the
|
|
715
|
-
login behind that run is dead. Run `claude-accounts expired` to see which, then
|
|
716
|
-
`claude-accounts relogin`. Accounts in that state are excluded from selection
|
|
717
|
-
automatically, so this only surfaces when *every* account needs a re-login (the shim
|
|
718
|
-
then falls back to the machine's own `~/.claude` login) or when it was pinned with
|
|
719
|
-
`CLAUDE_ACCOUNT`.
|
|
720
|
-
- **Everything marked limited** — the shim still runs (least-utilized fallback);
|
|
721
|
-
check `selection.log` for `all-limited` lines.
|
|
722
|
-
- **Sync fails** — `tail ~/.claude-accounts/sync.log`; it's ssh/rsync to the manifest's
|
|
723
|
-
`server` (BatchMode — needs key auth).
|
|
724
|
-
- **A service bypasses the shim** — it spawns an absolute path. Point its env
|
|
725
|
-
(`CLAUDE_BIN` etc.) at `/usr/local/bin/claude`. Docker-internal `claude` installs
|
|
726
|
-
(e.g. openclaw live-test containers) are out of scope for a host shim.
|
|
727
|
-
- **`add` sign-in says "Sign in again to continue"** — that's Claude's step-up
|
|
728
|
-
authentication, not a tool error: granting a long-lived token needs a *recent* sign-in.
|
|
729
|
-
Open a fresh incognito window, sign in to the target account at claude.ai **first**,
|
|
730
|
-
then paste the link and approve. The approval scope is `user:inference`
|
|
731
|
-
(*"Contribute to your Claude subscription usage"*) — that's correct and minimal.
|
|
732
|
-
- **Limits look stale** — launchd: `launchctl list | grep claude-multiacc`;
|
|
733
|
-
cron: `crontab -l | grep claude-multiacc`. The shim also kicks a refresh when data
|
|
734
|
-
is >3 min old.
|
|
735
|
-
|
|
736
|
-
## Security notes & known risks
|
|
737
|
-
|
|
738
|
-
- Tokens and credentials live as 0600 files under `~/.claude-accounts` (and
|
|
739
|
-
`/root/.claude-accounts` on the server). Server compromise = account access.
|
|
740
|
-
- `selection.log` records timestamps/account/cwd only — never prompt text.
|
|
741
|
-
- Rotating multiple subscriptions to spread usage may be flagged by anti-abuse systems;
|
|
742
|
-
accounts can be banned for limit circumvention. Known and accepted by the operator.
|
|
743
|
-
- The usage endpoint is the internal one `/usage` consumes; if it changes shape, limit
|
|
744
|
-
tracking fails open (accounts stay available, error-retry is the backstop).
|
|
374
|
+
Account lifecycle, synchronization, fleet integration, environment switches,
|
|
375
|
+
verification, troubleshooting, and security guidance continue in
|
|
376
|
+
[Account and fleet operations](docs/ACCOUNT_OPERATIONS.md).
|
package/bin/claude
CHANGED
|
@@ -214,17 +214,17 @@ age_human() { # $1 = seconds
|
|
|
214
214
|
# confirm this reset asymmetry — an account whose only near-full bucket is the cheap
|
|
215
215
|
# session one must NOT rank behind one burning durable weekly headroom.)
|
|
216
216
|
# score = weekly%*1000 + session% weekly,session in [0,100]
|
|
217
|
-
# Stale/unreadable telemetry ranks
|
|
217
|
+
# Stale/unreadable telemetry ranks LAST (weekly 100, session 100), never "free" —
|
|
218
218
|
# EXCEPT in a blind pool (SEL_DEGRADED=1), where a still-valid stale weekly reading is
|
|
219
|
-
# used instead.
|
|
220
|
-
#
|
|
219
|
+
# used instead. Unknown data must never beat a truthful usage reading; when every
|
|
220
|
+
# candidate is unknown, the equal worst-case scores still preserve fail-open selection.
|
|
221
221
|
SEL_DEGRADED=0
|
|
222
222
|
sel_score_of() { # $1 = acct dir
|
|
223
223
|
local w s
|
|
224
224
|
if ! w="$(fresh_field "$1" weekly_percent)" && ! w="$(fresh_field "$1" max_percent)"; then
|
|
225
|
-
if [ "$SEL_DEGRADED" = 1 ]; then w="$(stale_weekly "$1")" || w=
|
|
225
|
+
if [ "$SEL_DEGRADED" = 1 ]; then w="$(stale_weekly "$1")" || w=100; else w=100; fi
|
|
226
226
|
fi
|
|
227
|
-
s="$(fresh_field "$1" session_percent)" || s=
|
|
227
|
+
s="$(fresh_field "$1" session_percent)" || s=100
|
|
228
228
|
printf '%s\n' $((w * 1000 + s))
|
|
229
229
|
}
|
|
230
230
|
|
package/bin/cli.mjs
CHANGED
|
@@ -46,6 +46,10 @@ function runCodexAccounts(args) {
|
|
|
46
46
|
return sh('bash', [join(ROOT, 'bin', 'codex-accounts'), ...args]);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function runSelector(args) {
|
|
50
|
+
return sh(join(ROOT, 'bin', 'multiacc-select'), args);
|
|
51
|
+
}
|
|
52
|
+
|
|
49
53
|
const HELP = `claude-multiacc ${pkg.version}
|
|
50
54
|
|
|
51
55
|
Every 'claude' / 'claude -p' AND every 'codex' / 'codex exec' runs under a
|
|
@@ -62,6 +66,8 @@ USAGE
|
|
|
62
66
|
remove <acct-NN> | sync | verify | limits | health
|
|
63
67
|
claude-multiacc codex <cmd> [args...] same commands for the Codex pool
|
|
64
68
|
(runs codex-accounts <cmd>)
|
|
69
|
+
claude-multiacc select <args...> run the unified pool-selection.v2 selector
|
|
70
|
+
(JSON request/response over stdin/stdout)
|
|
65
71
|
claude-multiacc -v | --version
|
|
66
72
|
claude-multiacc -h | --help
|
|
67
73
|
|
|
@@ -89,7 +95,10 @@ switch (cmd) {
|
|
|
89
95
|
console.log('Updating claude-multiacc from npm…');
|
|
90
96
|
const up = sh('npm', ['install', '-g', `${pkg.name}@latest`]);
|
|
91
97
|
if (up !== 0) {
|
|
92
|
-
console.error(
|
|
98
|
+
console.error(
|
|
99
|
+
'npm update failed — is this a global npm install? '
|
|
100
|
+
+ '(git checkouts update with: git pull && ./install.sh)',
|
|
101
|
+
);
|
|
93
102
|
status = up;
|
|
94
103
|
break;
|
|
95
104
|
}
|
|
@@ -110,6 +119,9 @@ switch (cmd) {
|
|
|
110
119
|
// Codex-pool passthrough: claude-multiacc codex list | status | add | ...
|
|
111
120
|
status = argv.length > 1 ? runCodexAccounts(argv.slice(1)) : runCodexAccounts(['help']);
|
|
112
121
|
break;
|
|
122
|
+
case 'select':
|
|
123
|
+
status = runSelector(argv.slice(1));
|
|
124
|
+
break;
|
|
113
125
|
case '-v':
|
|
114
126
|
case '--version':
|
|
115
127
|
console.log(pkg.version);
|
package/bin/codex
CHANGED
|
@@ -144,11 +144,13 @@ fresh_field() { # fresh_field <acct dir> <json key> -> integer if telemetry fres
|
|
|
144
144
|
# window only refills on its multi-day reset, while the ~5h window self-heals, so
|
|
145
145
|
# session is a mild tiebreaker only (same reset asymmetry as the claude pool).
|
|
146
146
|
# score = weekly%*1000 + session% weekly,session in [0,100]
|
|
147
|
-
# Stale/unreadable telemetry ranks
|
|
147
|
+
# Stale/unreadable telemetry ranks LAST (weekly 100, session 100), never "free".
|
|
148
|
+
# Equal worst-case scores keep an entirely unknown pool selectable, but an unknown
|
|
149
|
+
# account can never beat a candidate with truthful usage telemetry.
|
|
148
150
|
sel_score_of() { # $1 = acct dir
|
|
149
151
|
local w s
|
|
150
|
-
w="$(fresh_field "$1" weekly_percent)" || w="$(fresh_field "$1" max_percent)" || w=
|
|
151
|
-
s="$(fresh_field "$1" session_percent)" || s=
|
|
152
|
+
w="$(fresh_field "$1" weekly_percent)" || w="$(fresh_field "$1" max_percent)" || w=100
|
|
153
|
+
s="$(fresh_field "$1" session_percent)" || s=100
|
|
152
154
|
printf '%s\n' $((w * 1000 + s))
|
|
153
155
|
}
|
|
154
156
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Strict JSON transport for the unified Claude/Codex pool selector."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from decimal import Decimal, DecimalException
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
sys.path.insert(0, str(ROOT / "lib"))
|
|
13
|
+
|
|
14
|
+
from selector_policy import select # noqa: E402
|
|
15
|
+
from selector_primitives import SCHEMA, SELECTOR_VERSION # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _invalid_json() -> dict:
|
|
19
|
+
return {"schema": SCHEMA, "selector_version": SELECTOR_VERSION, "ok": False,
|
|
20
|
+
"error_code": "invalid_request", "error_detail": {"field": "$"}}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _strict_object(pairs: list[tuple[str, object]]) -> dict:
|
|
24
|
+
result: dict = {}
|
|
25
|
+
for key, value in pairs:
|
|
26
|
+
if key in result:
|
|
27
|
+
raise ValueError("duplicate JSON object key")
|
|
28
|
+
result[key] = value
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> int:
|
|
33
|
+
if sys.argv[1:] == ["--version"]:
|
|
34
|
+
print(SELECTOR_VERSION)
|
|
35
|
+
return 0
|
|
36
|
+
if sys.argv[1:] != ["--request-json", "-", "--response-json", "-"]:
|
|
37
|
+
print("usage: multiacc-select --request-json - --response-json -", file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
try:
|
|
40
|
+
raw = sys.stdin.buffer.read().decode("utf-8")
|
|
41
|
+
request = json.loads(
|
|
42
|
+
raw, object_pairs_hook=_strict_object, parse_float=Decimal,
|
|
43
|
+
parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value)))
|
|
44
|
+
response = select(request)
|
|
45
|
+
except (UnicodeDecodeError, ValueError, json.JSONDecodeError, DecimalException):
|
|
46
|
+
response = _invalid_json()
|
|
47
|
+
print(json.dumps(response, ensure_ascii=False, allow_nan=False, separators=(",", ":")))
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
raise SystemExit(main())
|