multi-claude-cli 0.9.0__tar.gz → 0.9.1__tar.gz
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.
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/PKG-INFO +12 -10
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/README.md +11 -9
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/__init__.py +1 -1
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/cli.py +23 -9
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/identity.py +105 -9
- multi_claude_cli-0.9.1/src/multi_claude/plan.py +540 -0
- multi_claude_cli-0.9.1/src/multi_claude/refresh.py +326 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude_cli.egg-info/PKG-INFO +12 -10
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude_cli.egg-info/SOURCES.txt +2 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_plan.py +11 -7
- multi_claude_cli-0.9.1/tests/test_refresh.py +574 -0
- multi_claude_cli-0.9.0/src/multi_claude/plan.py +0 -317
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/LICENSE +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/pyproject.toml +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/setup.cfg +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/__main__.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/accounts.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/actions.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/bundle.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/completion.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/config.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/doctor.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/env.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/fsutil.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/history.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/hook.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/launcher.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/lock.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/migrate.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/platform.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/restore.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/routes.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/sessions.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/shared.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/shellpath.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/statusline.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude/usage.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude_cli.egg-info/dependency_links.txt +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude_cli.egg-info/entry_points.txt +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/src/multi_claude_cli.egg-info/top_level.txt +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_accounts.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_bundle.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_completion.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_config.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_doctor.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_everyday.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_fsutil.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_handoff.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_help.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_history.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_identity.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_install.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_mcp.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_migrate.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_onboarding.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_packaging.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_platform.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_rename.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_routes.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_set.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_shared_exclude.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_statusline.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_usability.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_usage.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.9.1}/tests/test_vscode.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multi-claude-cli
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Run several Claude Code accounts side by side: separate CLAUDE_CONFIG_DIR directories, launchers, proxies, environment variables and arguments.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/jakoes-wu/multi-claude
|
|
@@ -37,7 +37,7 @@ Dynamic: license-file
|
|
|
37
37
|
|
|
38
38
|
Use several [Claude Code](https://code.claude.com/docs) accounts on one machine, at the same time. Each account keeps its own login, settings and history and, if you like, its own proxy. No more logging out and in again.
|
|
39
39
|
|
|
40
|
-
It never
|
|
40
|
+
It never copies or exports your login and never edits your shell startup files. Only `list` reads an account's access token and goes online, to ask Anthropic for the current plan (renewing the token first if it has expired); `list --offline` skips all of that.
|
|
41
41
|
|
|
42
42
|
```sh
|
|
43
43
|
claude-work # Claude Code with your work account
|
|
@@ -64,8 +64,8 @@ The launchers are plain shell scripts. They keep working even if you uninstall m
|
|
|
64
64
|
|
|
65
65
|
## Features
|
|
66
66
|
|
|
67
|
-
- **Your login stays yours** — logins are never
|
|
68
|
-
- **
|
|
67
|
+
- **Your login stays yours** — logins are never copied or exported. One command touches them: `list` takes each account's access token (from the keychain, or `.credentials.json` on Linux) to ask Anthropic which plan the account is on, and when that token has expired it renews it the way Claude Code does and saves the new one in the same place. Tokens stay in memory and go only to Anthropic (`api.anthropic.com`, and `platform.claude.com` for a renewal), through that account's proxy. `list --no-refresh` never writes, `list --offline` reads nothing. Every other command only checks whether a login exists. `migrate-default` and `restore` move whole account directories, and on Linux the credentials file moves with its directory without being opened.
|
|
68
|
+
- **Only `list` goes online** — its plan lookup (one request per account, plus one to renew an expired token) is the only connection multi-claude makes: no telemetry, no usage reporting, no update checks. `list --offline` makes none. Only the installer, pipx or Homebrew download the package.
|
|
69
69
|
- **Leaves your shell alone** — when `PATH` needs a change, it prints the line to add instead of editing `~/.zshrc` or `~/.bashrc`.
|
|
70
70
|
- **Changes only what you ask** — it writes its own configuration and state, the launchers, account directories (including the VS Code data that `code` keeps in `<root>/.apps`) and shared links. A Claude Code settings file is changed only by `statusline install` (the file you name, with a backup) and `add --config-from` (the new account's `settings.json`).
|
|
71
71
|
- **Migrate the default account** — move your existing `~/.claude` into the account root without logging out. `claude` keeps working exactly as before.
|
|
@@ -78,7 +78,7 @@ The launchers are plain shell scripts. They keep working even if you uninstall m
|
|
|
78
78
|
|
|
79
79
|
## Prerequisites
|
|
80
80
|
|
|
81
|
-
**Servers.** None. multi-claude runs only on your own computer; its only network requests are the plan lookups of `list`, sent to Anthropic.
|
|
81
|
+
**Servers.** None. multi-claude runs only on your own computer; its only network requests are the plan lookups and token renewals of `list`, sent to Anthropic.
|
|
82
82
|
|
|
83
83
|
**Your computer.**
|
|
84
84
|
|
|
@@ -319,15 +319,17 @@ work:
|
|
|
319
319
|
NAME LOGIN PLAN BILLING PROXY SHARED 5H 7D LAST USED STATUS
|
|
320
320
|
work keychain max (5x) stripe http://127.0.0.1:7890 no 23% 14:00 41% Fri 5m ago ok
|
|
321
321
|
home keychain pro apple inherit no - - 2d ago ok
|
|
322
|
-
old keychain expired
|
|
322
|
+
old keychain token expired - inherit no - - 9d ago ok
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
`PLAN` and `BILLING` are not cached values: every `list` asks Anthropic (`GET https://api.anthropic.com/api/oauth/profile`, the request Claude Code itself makes) with the account's own access token. `PLAN` is the subscription — `pro`, `max (5x)`, `max (20x)`, `team`, `enterprise`. `BILLING` is where the subscription is paid: `stripe` (claude.ai), `apple` (App Store), `google-play`, `aws-marketplace` and so on.
|
|
326
326
|
|
|
327
327
|
- The request uses the account's proxy setting, exactly like its launcher: the configured proxy, the proxy variables of your shell for `inherit`, none for `off`. If a proxy is configured and fails, the lookup fails; it never falls back to a direct connection. Only `http://` proxies work for the lookup.
|
|
328
|
-
-
|
|
328
|
+
- Access tokens last a few hours. When one has expired, `list` renews it first: it takes the same lock as Claude Code (`.oauth_refresh.lock` in the account directory), posts the refresh token to `https://platform.claude.com/v1/oauth/token`, and saves the new tokens where the old ones were, keeping everything else in the login untouched. A running Claude session picks the new token up by itself. If another process has just renewed the token, `list` uses that result and writes nothing; if another process holds the lock, `list` does not wait and shows `token expired` this time. This follows what Claude Code 2.1.295 does; it is not a documented interface.
|
|
329
|
+
- `token expired` means the access token has run out and could not be renewed; the subscription itself is not affected. The reason is on stderr (`token refresh failed for work: lock_busy`). Start `claude-NAME` once and run `list` again; when the reason is `refresh_rejected` or `no_refresh_token`, log in again with `multi-claude login NAME`. `list --no-refresh` never renews and never writes to a login.
|
|
330
|
+
- `-` means the account has no OAuth login, and `rejected` that the server refused the token.
|
|
329
331
|
- `error` comes with a reason on stderr, for example `plan lookup failed for work: timeout (via 127.0.0.1:7890, 10.01s)`. `tls_failed` usually means the Python that runs multi-claude has no root certificates (python.org installers ship an `Install Certificates.command`).
|
|
330
|
-
- `list --offline` skips the lookup — no token is read, nothing is sent — and prints the table without the two columns. `list --json` carries the same data in each account's `plan` object; scripts should use it instead of parsing the table. The lookup adds about a second, at most 15.
|
|
332
|
+
- `list --offline` skips the lookup — no token is read, nothing is sent — and prints the table without the two columns. `list --json` carries the same data in each account's `plan` object (`refresh` tells whether a token was renewed); scripts should use it instead of parsing the table. The lookup adds about a second, at most 15; a renewal that is under way is allowed to finish first (the first Ctrl-C only prints a notice), so that the new login is not lost.
|
|
331
333
|
|
|
332
334
|
### Handing a session over to another account
|
|
333
335
|
|
|
@@ -463,7 +465,7 @@ By default the numbers come from the cache that Claude Code writes in `.claude.j
|
|
|
463
465
|
|
|
464
466
|
### Does multi-claude read or copy my login?
|
|
465
467
|
|
|
466
|
-
It never
|
|
468
|
+
It never copies, exports, uploads or backs up a login. One command touches one: `list` takes each account's access token to ask Anthropic for the account's plan, and renews the token when it has expired, saving the new one where the old one was (see [Plan and billing](#plan-and-billing)). Tokens stay in memory and are sent only to Anthropic. `list --no-refresh` never writes and `list --offline` does not read. All other commands only check whether a login exists. `migrate-default` and `restore` move a whole account directory; on Linux the credentials file is inside it and moves with it (in copy mode it is copied as part of the directory, verified, and the original directory is then removed unless you pass `--keep-backup`), still without being opened. On macOS a login is tied to the account directory's path, which is why `rename` keeps the directory.
|
|
467
469
|
|
|
468
470
|
## Reference
|
|
469
471
|
|
|
@@ -483,7 +485,7 @@ It never writes, copies, exports, uploads, refreshes or backs up a login. One co
|
|
|
483
485
|
| `multi-claude args (NAME \| --defaults) -- [ARG ...]` | Replace the fixed arguments; `--` with nothing after it clears them. |
|
|
484
486
|
| `multi-claude remove NAME` | Unregister an account and delete its launcher. **The directory and the login are kept.** |
|
|
485
487
|
| `multi-claude apply [-f FILE]` | Converge everything to the configuration (or to `FILE`). |
|
|
486
|
-
| `multi-claude list [--verbose \| --json \| --names] [--offline]` | Show accounts, launchers, login state and the plan and billing channel looked up from Anthropic. `--json` adds usage and is meant for scripts; `--names` prints only the names; `--offline` skips the plan lookup (no token read, no network). |
|
|
488
|
+
| `multi-claude list [--verbose \| --json \| --names] [--no-refresh] [--offline]` | Show accounts, launchers, login state and the plan and billing channel looked up from Anthropic. `--json` adds usage and is meant for scripts; `--names` prints only the names; `--no-refresh` does not renew expired access tokens (never writes to a login); `--offline` skips the plan lookup (no token read, no network). |
|
|
487
489
|
| `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
|
|
488
490
|
| `multi-claude usage --history [NAME] [--days N] [--by day\|model] [--json]` | Token usage per day or model, counted from the session files on this machine (default: 7 days). |
|
|
489
491
|
| `multi-claude doctor [--json] [--verbose]` | Check the configuration, launchers, account directories, shared links, logins and environment. |
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
Use several [Claude Code](https://code.claude.com/docs) accounts on one machine, at the same time. Each account keeps its own login, settings and history and, if you like, its own proxy. No more logging out and in again.
|
|
12
12
|
|
|
13
|
-
It never
|
|
13
|
+
It never copies or exports your login and never edits your shell startup files. Only `list` reads an account's access token and goes online, to ask Anthropic for the current plan (renewing the token first if it has expired); `list --offline` skips all of that.
|
|
14
14
|
|
|
15
15
|
```sh
|
|
16
16
|
claude-work # Claude Code with your work account
|
|
@@ -37,8 +37,8 @@ The launchers are plain shell scripts. They keep working even if you uninstall m
|
|
|
37
37
|
|
|
38
38
|
## Features
|
|
39
39
|
|
|
40
|
-
- **Your login stays yours** — logins are never
|
|
41
|
-
- **
|
|
40
|
+
- **Your login stays yours** — logins are never copied or exported. One command touches them: `list` takes each account's access token (from the keychain, or `.credentials.json` on Linux) to ask Anthropic which plan the account is on, and when that token has expired it renews it the way Claude Code does and saves the new one in the same place. Tokens stay in memory and go only to Anthropic (`api.anthropic.com`, and `platform.claude.com` for a renewal), through that account's proxy. `list --no-refresh` never writes, `list --offline` reads nothing. Every other command only checks whether a login exists. `migrate-default` and `restore` move whole account directories, and on Linux the credentials file moves with its directory without being opened.
|
|
41
|
+
- **Only `list` goes online** — its plan lookup (one request per account, plus one to renew an expired token) is the only connection multi-claude makes: no telemetry, no usage reporting, no update checks. `list --offline` makes none. Only the installer, pipx or Homebrew download the package.
|
|
42
42
|
- **Leaves your shell alone** — when `PATH` needs a change, it prints the line to add instead of editing `~/.zshrc` or `~/.bashrc`.
|
|
43
43
|
- **Changes only what you ask** — it writes its own configuration and state, the launchers, account directories (including the VS Code data that `code` keeps in `<root>/.apps`) and shared links. A Claude Code settings file is changed only by `statusline install` (the file you name, with a backup) and `add --config-from` (the new account's `settings.json`).
|
|
44
44
|
- **Migrate the default account** — move your existing `~/.claude` into the account root without logging out. `claude` keeps working exactly as before.
|
|
@@ -51,7 +51,7 @@ The launchers are plain shell scripts. They keep working even if you uninstall m
|
|
|
51
51
|
|
|
52
52
|
## Prerequisites
|
|
53
53
|
|
|
54
|
-
**Servers.** None. multi-claude runs only on your own computer; its only network requests are the plan lookups of `list`, sent to Anthropic.
|
|
54
|
+
**Servers.** None. multi-claude runs only on your own computer; its only network requests are the plan lookups and token renewals of `list`, sent to Anthropic.
|
|
55
55
|
|
|
56
56
|
**Your computer.**
|
|
57
57
|
|
|
@@ -292,15 +292,17 @@ work:
|
|
|
292
292
|
NAME LOGIN PLAN BILLING PROXY SHARED 5H 7D LAST USED STATUS
|
|
293
293
|
work keychain max (5x) stripe http://127.0.0.1:7890 no 23% 14:00 41% Fri 5m ago ok
|
|
294
294
|
home keychain pro apple inherit no - - 2d ago ok
|
|
295
|
-
old keychain expired
|
|
295
|
+
old keychain token expired - inherit no - - 9d ago ok
|
|
296
296
|
```
|
|
297
297
|
|
|
298
298
|
`PLAN` and `BILLING` are not cached values: every `list` asks Anthropic (`GET https://api.anthropic.com/api/oauth/profile`, the request Claude Code itself makes) with the account's own access token. `PLAN` is the subscription — `pro`, `max (5x)`, `max (20x)`, `team`, `enterprise`. `BILLING` is where the subscription is paid: `stripe` (claude.ai), `apple` (App Store), `google-play`, `aws-marketplace` and so on.
|
|
299
299
|
|
|
300
300
|
- The request uses the account's proxy setting, exactly like its launcher: the configured proxy, the proxy variables of your shell for `inherit`, none for `off`. If a proxy is configured and fails, the lookup fails; it never falls back to a direct connection. Only `http://` proxies work for the lookup.
|
|
301
|
-
-
|
|
301
|
+
- Access tokens last a few hours. When one has expired, `list` renews it first: it takes the same lock as Claude Code (`.oauth_refresh.lock` in the account directory), posts the refresh token to `https://platform.claude.com/v1/oauth/token`, and saves the new tokens where the old ones were, keeping everything else in the login untouched. A running Claude session picks the new token up by itself. If another process has just renewed the token, `list` uses that result and writes nothing; if another process holds the lock, `list` does not wait and shows `token expired` this time. This follows what Claude Code 2.1.295 does; it is not a documented interface.
|
|
302
|
+
- `token expired` means the access token has run out and could not be renewed; the subscription itself is not affected. The reason is on stderr (`token refresh failed for work: lock_busy`). Start `claude-NAME` once and run `list` again; when the reason is `refresh_rejected` or `no_refresh_token`, log in again with `multi-claude login NAME`. `list --no-refresh` never renews and never writes to a login.
|
|
303
|
+
- `-` means the account has no OAuth login, and `rejected` that the server refused the token.
|
|
302
304
|
- `error` comes with a reason on stderr, for example `plan lookup failed for work: timeout (via 127.0.0.1:7890, 10.01s)`. `tls_failed` usually means the Python that runs multi-claude has no root certificates (python.org installers ship an `Install Certificates.command`).
|
|
303
|
-
- `list --offline` skips the lookup — no token is read, nothing is sent — and prints the table without the two columns. `list --json` carries the same data in each account's `plan` object; scripts should use it instead of parsing the table. The lookup adds about a second, at most 15.
|
|
305
|
+
- `list --offline` skips the lookup — no token is read, nothing is sent — and prints the table without the two columns. `list --json` carries the same data in each account's `plan` object (`refresh` tells whether a token was renewed); scripts should use it instead of parsing the table. The lookup adds about a second, at most 15; a renewal that is under way is allowed to finish first (the first Ctrl-C only prints a notice), so that the new login is not lost.
|
|
304
306
|
|
|
305
307
|
### Handing a session over to another account
|
|
306
308
|
|
|
@@ -436,7 +438,7 @@ By default the numbers come from the cache that Claude Code writes in `.claude.j
|
|
|
436
438
|
|
|
437
439
|
### Does multi-claude read or copy my login?
|
|
438
440
|
|
|
439
|
-
It never
|
|
441
|
+
It never copies, exports, uploads or backs up a login. One command touches one: `list` takes each account's access token to ask Anthropic for the account's plan, and renews the token when it has expired, saving the new one where the old one was (see [Plan and billing](#plan-and-billing)). Tokens stay in memory and are sent only to Anthropic. `list --no-refresh` never writes and `list --offline` does not read. All other commands only check whether a login exists. `migrate-default` and `restore` move a whole account directory; on Linux the credentials file is inside it and moves with it (in copy mode it is copied as part of the directory, verified, and the original directory is then removed unless you pass `--keep-backup`), still without being opened. On macOS a login is tied to the account directory's path, which is why `rename` keeps the directory.
|
|
440
442
|
|
|
441
443
|
## Reference
|
|
442
444
|
|
|
@@ -456,7 +458,7 @@ It never writes, copies, exports, uploads, refreshes or backs up a login. One co
|
|
|
456
458
|
| `multi-claude args (NAME \| --defaults) -- [ARG ...]` | Replace the fixed arguments; `--` with nothing after it clears them. |
|
|
457
459
|
| `multi-claude remove NAME` | Unregister an account and delete its launcher. **The directory and the login are kept.** |
|
|
458
460
|
| `multi-claude apply [-f FILE]` | Converge everything to the configuration (or to `FILE`). |
|
|
459
|
-
| `multi-claude list [--verbose \| --json \| --names] [--offline]` | Show accounts, launchers, login state and the plan and billing channel looked up from Anthropic. `--json` adds usage and is meant for scripts; `--names` prints only the names; `--offline` skips the plan lookup (no token read, no network). |
|
|
461
|
+
| `multi-claude list [--verbose \| --json \| --names] [--no-refresh] [--offline]` | Show accounts, launchers, login state and the plan and billing channel looked up from Anthropic. `--json` adds usage and is meant for scripts; `--names` prints only the names; `--no-refresh` does not renew expired access tokens (never writes to a login); `--offline` skips the plan lookup (no token read, no network). |
|
|
460
462
|
| `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
|
|
461
463
|
| `multi-claude usage --history [NAME] [--days N] [--by day\|model] [--json]` | Token usage per day or model, counted from the session files on this machine (default: 7 days). |
|
|
462
464
|
| `multi-claude doctor [--json] [--verbose]` | Check the configuration, launchers, account directories, shared links, logins and environment. |
|
|
@@ -19,7 +19,7 @@ from datetime import datetime, timedelta, timezone
|
|
|
19
19
|
from typing import List, Optional, Tuple
|
|
20
20
|
|
|
21
21
|
from . import (__version__, accounts, bundle, completion, doctor, history, hook, identity, launcher, migrate,
|
|
22
|
-
plan, platform, restore, routes, sessions, shellpath, statusline, usage)
|
|
22
|
+
plan, platform, refresh, restore, routes, sessions, shellpath, statusline, usage)
|
|
23
23
|
from .actions import LOG_PREFIX, error, info, warn
|
|
24
24
|
from .config import (DEFAULT_SHARED_DIR, DEFAULT_SHARED_ITEMS, IDENTITY_DEFAULT, IDENTITY_DIR, Account, Config, ConfigError, RouteRule,
|
|
25
25
|
is_unshareable, load_config, normalize_proxy, parse_config, validate_env_key, validate_name,
|
|
@@ -173,6 +173,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
173
173
|
help="show every field, the global settings and the keychain details")
|
|
174
174
|
list_mode.add_argument("--json", action="store_true", help="print machine-readable JSON (includes usage)")
|
|
175
175
|
list_mode.add_argument("--names", action="store_true", help="print only the account names, one per line")
|
|
176
|
+
p_list.add_argument("--no-refresh", action="store_true",
|
|
177
|
+
help="do not renew an expired access token (never write to the login); "
|
|
178
|
+
"such accounts show 'token expired'")
|
|
176
179
|
p_list.add_argument("--offline", action="store_true",
|
|
177
180
|
help="skip the plan lookup: do not read access tokens and do not connect to the network")
|
|
178
181
|
|
|
@@ -400,7 +403,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
400
403
|
warn(notice)
|
|
401
404
|
# list 与 usage 只读:不加写锁,迁移未完成时也放行。
|
|
402
405
|
if args.command == "list":
|
|
403
|
-
return cmd_list(verbose=args.verbose, as_json=args.json, names_only=args.names, offline=args.offline
|
|
406
|
+
return cmd_list(verbose=args.verbose, as_json=args.json, names_only=args.names, offline=args.offline,
|
|
407
|
+
refresh_tokens=not args.no_refresh)
|
|
404
408
|
if args.command == "usage":
|
|
405
409
|
if args.history:
|
|
406
410
|
return cmd_usage_history(args.name, args.days, args.by or "day", args.json)
|
|
@@ -1263,8 +1267,10 @@ def _checked_proxy(value: str) -> str:
|
|
|
1263
1267
|
raise UsageError(str(exc))
|
|
1264
1268
|
|
|
1265
1269
|
|
|
1266
|
-
def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = False, offline: bool = False
|
|
1267
|
-
|
|
1270
|
+
def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = False, offline: bool = False,
|
|
1271
|
+
refresh_tokens: bool = True) -> int:
|
|
1272
|
+
"""列出账号。除 --names 与 --offline 外,会读各账号的访问令牌并向服务端查询套餐(方案 feature-live-plan);
|
|
1273
|
+
访问令牌过期时先刷新并写回凭据,refresh_tokens 为假时不刷新(方案 feature-token-refresh)。"""
|
|
1268
1274
|
config, exists = load_config()
|
|
1269
1275
|
if names_only:
|
|
1270
1276
|
# 给补全脚本用:只输出名称,不输出任何告警。
|
|
@@ -1276,7 +1282,8 @@ def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = Fa
|
|
|
1276
1282
|
if variable in os.environ:
|
|
1277
1283
|
warn("{} is set; launchers do not change it, even with proxy off".format(variable))
|
|
1278
1284
|
# 套餐只查一次,表格与 JSON 共用;None 表示本次不查(--offline 或还没有配置)。
|
|
1279
|
-
plans = None if offline or not exists else plan.query_all(config, os.environ, datetime.now(timezone.utc)
|
|
1285
|
+
plans = None if offline or not exists else plan.query_all(config, os.environ, datetime.now(timezone.utc),
|
|
1286
|
+
allow_refresh=refresh_tokens)
|
|
1280
1287
|
if as_json:
|
|
1281
1288
|
_print_json(_list_data(config, exists, plans))
|
|
1282
1289
|
if exists:
|
|
@@ -1336,20 +1343,27 @@ def _plan_cells(plans: Optional[dict], name: str) -> tuple:
|
|
|
1336
1343
|
|
|
1337
1344
|
|
|
1338
1345
|
def _report_plan_problems(plans: Optional[dict]) -> None:
|
|
1339
|
-
"""
|
|
1346
|
+
"""套餐查询与令牌刷新的失败提示,打到 stderr;不影响表格、STATUS 列与退出码。"""
|
|
1340
1347
|
if plans is None:
|
|
1341
1348
|
return
|
|
1342
1349
|
expired = []
|
|
1343
1350
|
for name, result in plans.items():
|
|
1351
|
+
if result.refresh == refresh.SAVE_FAILED:
|
|
1352
|
+
# 新令牌只用了这一次、没存下来:旧的刷新令牌多半已作废,下次启动可能要重新登录。
|
|
1353
|
+
error("the renewed login of {0} could not be saved; if claude-{0} asks you to log in, "
|
|
1354
|
+
"run multi-claude login {0}".format(name))
|
|
1344
1355
|
if result.status == plan.STATUS_ERROR:
|
|
1345
1356
|
warn(plan.failure_text(name, result))
|
|
1346
1357
|
elif result.status == plan.STATUS_EXPIRED:
|
|
1347
1358
|
expired.append(name)
|
|
1359
|
+
if result.refresh is not None:
|
|
1360
|
+
hint = "; run multi-claude login {}".format(name) if result.refresh in refresh.NEEDS_LOGIN else ""
|
|
1361
|
+
warn("token refresh failed for {}: {}{}".format(name, result.refresh, hint))
|
|
1348
1362
|
if expired:
|
|
1349
|
-
# multi-claude 不刷新令牌:让 Claude Code 自己续期,再查一次即可。
|
|
1350
1363
|
# 走 stderr 而不是 info():info() 写 stdout,会混进 --json 的输出。
|
|
1351
|
-
print("{} expired:
|
|
1352
|
-
|
|
1364
|
+
print("{} token expired: the access token ran out, the subscription is not affected; start {} once, "
|
|
1365
|
+
"then run list again".format(LOG_PREFIX, ", ".join("claude-" + name for name in expired)),
|
|
1366
|
+
file=sys.stderr)
|
|
1353
1367
|
|
|
1354
1368
|
|
|
1355
1369
|
def _print_brief_list(config: Config, plans: Optional[dict] = None) -> int:
|
|
@@ -6,10 +6,13 @@ Claude Code 在 macOS 上把登录凭据存进钥匙串,服务名由配置目
|
|
|
6
6
|
|
|
7
7
|
- `probe()`:只查询凭据是否存在。钥匙串调用 `security find-generic-password -a <账户> -s <服务名>`,
|
|
8
8
|
不带 -w / -g,只看退出码。
|
|
9
|
-
- `read_oauth_token()
|
|
10
|
-
|
|
9
|
+
- `read_oauth_token()` / `read_credentials()`:读出访问令牌或整份凭据,供 `list` 向服务端查询套餐
|
|
10
|
+
(调用方只有 plan.py 与 refresh.py)。钥匙串调用带 -w 的同一条命令。内容只留在内存里,
|
|
11
|
+
不写入日志或任何输出。
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
唯一的写入是 `write_credentials()`:`list` 发现访问令牌过期、用刷新令牌换到新令牌后,把新令牌写回
|
|
14
|
+
原来的位置(方案 feature-token-refresh;refresh.py 是唯一调用方)。除此之外不写入、不复制、不删除凭据。
|
|
15
|
+
调用 `security` 的地方只有本模块,测试会断言它收到的参数。
|
|
13
16
|
"""
|
|
14
17
|
|
|
15
18
|
import hashlib
|
|
@@ -24,6 +27,7 @@ import unicodedata
|
|
|
24
27
|
from typing import NamedTuple, Optional, Tuple
|
|
25
28
|
|
|
26
29
|
from .config import IDENTITY_DEFAULT
|
|
30
|
+
from .fsutil import atomic_write
|
|
27
31
|
|
|
28
32
|
SERVICE_BASE = "Claude Code-credentials"
|
|
29
33
|
CREDENTIALS_FILE = ".credentials.json"
|
|
@@ -62,7 +66,8 @@ def keychain_account() -> str:
|
|
|
62
66
|
user = pwd.getpwuid(os.getuid()).pw_name
|
|
63
67
|
except KeyError:
|
|
64
68
|
user = ""
|
|
65
|
-
|
|
69
|
+
# fullmatch:`$` 会放过尾部换行,而账户名现在会拼进 `security -i` 的命令行
|
|
70
|
+
if not _KEYCHAIN_ACCOUNT_PATTERN.fullmatch(user):
|
|
66
71
|
return "claude-code-user"
|
|
67
72
|
return user
|
|
68
73
|
|
|
@@ -174,11 +179,22 @@ def _parse_credentials(state: str, text: Optional[str]) -> Tuple[str, Optional[O
|
|
|
174
179
|
return TOKEN_UNREADABLE, None
|
|
175
180
|
if not isinstance(data, dict):
|
|
176
181
|
return TOKEN_UNREADABLE, None
|
|
177
|
-
|
|
178
|
-
|
|
182
|
+
token = token_of(data)
|
|
183
|
+
return (TOKEN_OK, token) if token is not None else (TOKEN_NONE, None)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def is_token(value: object) -> bool:
|
|
187
|
+
"""value 是否是能放进 HTTP 请求头的令牌串。"""
|
|
179
188
|
# fullmatch 而不是 match:`$` 会放过尾部换行
|
|
180
|
-
|
|
181
|
-
|
|
189
|
+
return isinstance(value, str) and _TOKEN_PATTERN.fullmatch(value) is not None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def token_of(document: dict) -> Optional[OAuthToken]:
|
|
193
|
+
"""从凭据 JSON 对象里取访问令牌;没有可用的访问令牌时返回 None。"""
|
|
194
|
+
oauth = document.get("claudeAiOauth")
|
|
195
|
+
access_token = oauth.get("accessToken") if isinstance(oauth, dict) else None
|
|
196
|
+
if not is_token(access_token):
|
|
197
|
+
return None
|
|
182
198
|
expires_at = oauth.get("expiresAt")
|
|
183
199
|
# bool 是 int 的子类,要先排除
|
|
184
200
|
# JSON 里的 1e999 会解析成 inf,取整会抛异常:非有限值同样当作“没有过期时刻”
|
|
@@ -186,4 +202,84 @@ def _parse_credentials(state: str, text: Optional[str]) -> Tuple[str, Optional[O
|
|
|
186
202
|
if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)) \
|
|
187
203
|
or (isinstance(expires_at, float) and not math.isfinite(expires_at)):
|
|
188
204
|
expires_at = None
|
|
189
|
-
return
|
|
205
|
+
return OAuthToken(TOKEN_OK, access_token, None if expires_at is None else int(expires_at))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Credentials(NamedTuple):
|
|
209
|
+
state: str
|
|
210
|
+
# claudeAiOauth 是从哪里读到的:LOGIN_KEYCHAIN 或 LOGIN_FILE;state 不是 TOKEN_OK 时为空串
|
|
211
|
+
source: str
|
|
212
|
+
# 该来源的完整 JSON 对象(含 claudeAiOauth 以外的键,如 mcpOAuth);写回时必须整份带上
|
|
213
|
+
document: Optional[dict]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def read_credentials(identity: str, account_dir: str) -> Credentials:
|
|
217
|
+
"""读出含 OAuth 登录的那份凭据,以及它的来源。来源顺序与 read_oauth_token() 相同。
|
|
218
|
+
|
|
219
|
+
与 read_oauth_token() 的区别:只要求有 claudeAiOauth 对象,不要求访问令牌可用,
|
|
220
|
+
因为刷新要处理的正是访问令牌已经失效的凭据。
|
|
221
|
+
"""
|
|
222
|
+
sources = []
|
|
223
|
+
if sys.platform == "darwin":
|
|
224
|
+
sources.append((LOGIN_KEYCHAIN, _read_keychain_secret(service_name(identity, account_dir))))
|
|
225
|
+
sources.append((LOGIN_FILE, _read_credentials_file(credentials_file(account_dir))))
|
|
226
|
+
unreadable = False
|
|
227
|
+
for source, (state, text) in sources:
|
|
228
|
+
if text is None:
|
|
229
|
+
unreadable = unreadable or state == TOKEN_UNREADABLE
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
data = json.loads(text)
|
|
233
|
+
except ValueError:
|
|
234
|
+
data = None
|
|
235
|
+
if not isinstance(data, dict):
|
|
236
|
+
unreadable = True
|
|
237
|
+
elif isinstance(data.get("claudeAiOauth"), dict):
|
|
238
|
+
return Credentials(TOKEN_OK, source, data)
|
|
239
|
+
return Credentials(TOKEN_UNREADABLE if unreadable else TOKEN_NONE, "", None)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# `security -i` 从 stdin 读一行命令的长度上限,取自 Claude Code;超过时它改用命令行参数,这里照做。
|
|
243
|
+
_SECURITY_STDIN_LIMIT = 4032
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def write_credentials(identity: str, account_dir: str, source: str, document: dict) -> bool:
|
|
247
|
+
"""把整份凭据写回 source 指定的位置,返回是否成功。全工具唯一写凭据的入口。
|
|
248
|
+
|
|
249
|
+
两种写法都是整体替换:钥匙串条目由 security 原子更新,文件用临时文件加 rename,
|
|
250
|
+
任何时刻被打断,凭据要么是旧的、要么是完整的新的。
|
|
251
|
+
调用方必须先确认 document 来自同一来源的最新读取结果,只改了令牌相关字段。
|
|
252
|
+
"""
|
|
253
|
+
text = json.dumps(document, separators=(",", ":"))
|
|
254
|
+
if source == LOGIN_KEYCHAIN:
|
|
255
|
+
return _write_keychain_secret(service_name(identity, account_dir), text)
|
|
256
|
+
if source == LOGIN_FILE:
|
|
257
|
+
try:
|
|
258
|
+
# 凭据文件是软链时写到它指向的文件:直接替换会把软链换成普通文件,原文件反而没更新
|
|
259
|
+
atomic_write(os.path.realpath(credentials_file(account_dir)), text, 0o600)
|
|
260
|
+
except OSError:
|
|
261
|
+
return False
|
|
262
|
+
return True
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _write_keychain_secret(service: str, text: str) -> bool:
|
|
267
|
+
"""与 Claude Code 写钥匙串的方式相同:内容转成十六进制,经 stdin 交给 `security -i`。
|
|
268
|
+
|
|
269
|
+
走 stdin 是为了不让内容出现在进程列表里。命令行超过上限时 security 读不完整,
|
|
270
|
+
只能改用参数(Claude Code 也是这样做的),这时十六进制内容在本机进程列表里短暂可见。
|
|
271
|
+
"""
|
|
272
|
+
payload = text.encode("utf-8").hex()
|
|
273
|
+
account = keychain_account()
|
|
274
|
+
line = 'add-generic-password -U -a "{}" -s "{}" -X "{}"\n'.format(account, service, payload)
|
|
275
|
+
try:
|
|
276
|
+
if len(line) <= _SECURITY_STDIN_LIMIT:
|
|
277
|
+
proc = subprocess.run(["security", "-i"], input=line.encode("utf-8"),
|
|
278
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
|
279
|
+
else:
|
|
280
|
+
proc = subprocess.run(["security", "add-generic-password", "-U", "-a", account, "-s", service,
|
|
281
|
+
"-X", payload], stdin=subprocess.DEVNULL,
|
|
282
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
|
283
|
+
except (OSError, subprocess.SubprocessError):
|
|
284
|
+
return False
|
|
285
|
+
return proc.returncode == 0
|