claude-multiacc 1.0.21 → 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 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). Test suite: `tests/run-tests.sh` (525 sandboxed tests, no network / no
20
- quota passes on both platforms).
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
 
@@ -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`, server `/root/.claude-accounts` | pool `~/.codex-accounts`, server `/root/.codex-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 endpoint, per-model Fable bucket | `chatgpt.com/backend-api/codex/usage`, per-model buckets (e.g. `GPT-5.3-Codex-Spark:5h/7d`) |
240
- | `CLAUDE_ACCOUNT` / `CLAUDE_SHIM_RETRY` / `CLAUDE_MULTIACC_DISABLE` / … | `CODEX_ACCOUNT` / `CODEX_SHIM_RETRY` / `CODEX_MULTIACC_DISABLE` / … |
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
- ## Accounts
352
-
353
- ```bash
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/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('npm update failed — is this a global npm install? (git checkouts update with: git pull && ./install.sh)');
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);
@@ -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())