multi-claude-cli 0.9.0__tar.gz → 0.10.0__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.10.0}/PKG-INFO +37 -10
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/README.md +36 -9
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/__init__.py +1 -1
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/cli.py +181 -10
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/completion.py +1 -1
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/identity.py +105 -9
- multi_claude_cli-0.10.0/src/multi_claude/plan.py +540 -0
- multi_claude_cli-0.10.0/src/multi_claude/refresh.py +326 -0
- multi_claude_cli-0.10.0/src/multi_claude/sessions.py +436 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude_cli.egg-info/PKG-INFO +37 -10
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude_cli.egg-info/SOURCES.txt +3 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_completion.py +3 -1
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_plan.py +11 -7
- multi_claude_cli-0.10.0/tests/test_refresh.py +574 -0
- multi_claude_cli-0.10.0/tests/test_sessions.py +410 -0
- multi_claude_cli-0.9.0/src/multi_claude/plan.py +0 -317
- multi_claude_cli-0.9.0/src/multi_claude/sessions.py +0 -195
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/LICENSE +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/pyproject.toml +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/setup.cfg +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/__main__.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/accounts.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/actions.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/bundle.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/config.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/doctor.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/env.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/fsutil.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/history.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/hook.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/launcher.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/lock.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/migrate.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/platform.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/restore.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/routes.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/shared.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/shellpath.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/statusline.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude/usage.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude_cli.egg-info/dependency_links.txt +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude_cli.egg-info/entry_points.txt +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/src/multi_claude_cli.egg-info/top_level.txt +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_accounts.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_bundle.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_config.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_doctor.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_everyday.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_fsutil.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_handoff.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_help.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_history.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_identity.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_install.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_mcp.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_migrate.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_onboarding.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_packaging.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_platform.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_rename.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_routes.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_set.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_shared_exclude.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_statusline.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_usability.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/tests/test_usage.py +0 -0
- {multi_claude_cli-0.9.0 → multi_claude_cli-0.10.0}/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.
|
|
3
|
+
Version: 0.10.0
|
|
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,37 @@ 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.
|
|
333
|
+
|
|
334
|
+
### Finding a session
|
|
335
|
+
|
|
336
|
+
Forgot which account or which directory a session was started in? `sessions` lists the sessions of every account and every project directory, newest first, each followed by the command that resumes it:
|
|
337
|
+
|
|
338
|
+
```sh
|
|
339
|
+
multi-claude sessions
|
|
340
|
+
# ACCOUNT UPDATED CREATED SESSION ID TITLE
|
|
341
|
+
# work 2026-10-11 17:42 2026-10-11 16:05 3c588340-1219-48df-a231-509866789487 Fix the importer
|
|
342
|
+
# cd /path/to/project && claude-work --resume 3c588340-1219-48df-a231-509866789487
|
|
343
|
+
multi-claude sessions work # one account
|
|
344
|
+
multi-claude sessions --grep importer # title, directory or session id contains the text
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- It shows the 30 most recent sessions; `--limit N` changes that and `--limit 0` shows all. `--json` prints every match with the full fields (`directory`, `created`, `updated`, `title_source`, `file`, `resume`). `conversations` is another name for the same command.
|
|
348
|
+
- The title is the session's name in Claude Code; a session without one shows the beginning of its first prompt, and `-` if it has no prompt at all. Times are local. `UPDATED` is the time of the last message, or the file's modification time when the session has no timestamps.
|
|
349
|
+
- A `~/.claude` that has not been turned into an account is listed as `default`.
|
|
350
|
+
- A session copied with `handoff` appears once per account that has a copy.
|
|
351
|
+
- The command is read-only and stays on this machine: it reads the start and the end of each session file, prints the title (a piece of your conversation) to the terminal, and saves or sends nothing. It does not list subagent transcripts.
|
|
352
|
+
- If the directory a session was started in no longer exists, the `cd` in the resume command fails; `claude --resume` only finds a session from the directory it was started in.
|
|
331
353
|
|
|
332
354
|
### Handing a session over to another account
|
|
333
355
|
|
|
@@ -449,6 +471,10 @@ The one in `~/.claude`, as before: multi-claude does not change it. After [migra
|
|
|
449
471
|
|
|
450
472
|
There is no command that switches which account plain `claude` uses. Besides `~/.claude`, plain `claude` reads the login in the keychain (macOS) and `~/.claude.json`, and neither would follow such a switch, so two accounts would get mixed up. Use the launchers (`claude-work`), or `multi-claude route --default work` and start `claude-auto`.
|
|
451
473
|
|
|
474
|
+
### Which account or directory was that session in?
|
|
475
|
+
|
|
476
|
+
`multi-claude sessions --grep TEXT` searches the titles, directories and ids of the sessions of all accounts and prints the command that resumes each match. See [Finding a session](#finding-a-session).
|
|
477
|
+
|
|
452
478
|
### How do I continue a conversation with another account?
|
|
453
479
|
|
|
454
480
|
Inside the Claude session, run `! multi-claude handoff OTHER`, then run the command it prints in a new terminal. See [Handing a session over to another account](#handing-a-session-over-to-another-account).
|
|
@@ -463,7 +489,7 @@ By default the numbers come from the cache that Claude Code writes in `.claude.j
|
|
|
463
489
|
|
|
464
490
|
### Does multi-claude read or copy my login?
|
|
465
491
|
|
|
466
|
-
It never
|
|
492
|
+
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
493
|
|
|
468
494
|
## Reference
|
|
469
495
|
|
|
@@ -483,7 +509,7 @@ It never writes, copies, exports, uploads, refreshes or backs up a login. One co
|
|
|
483
509
|
| `multi-claude args (NAME \| --defaults) -- [ARG ...]` | Replace the fixed arguments; `--` with nothing after it clears them. |
|
|
484
510
|
| `multi-claude remove NAME` | Unregister an account and delete its launcher. **The directory and the login are kept.** |
|
|
485
511
|
| `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). |
|
|
512
|
+
| `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
513
|
| `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
|
|
488
514
|
| `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
515
|
| `multi-claude doctor [--json] [--verbose]` | Check the configuration, launchers, account directories, shared links, logins and environment. |
|
|
@@ -496,6 +522,7 @@ It never writes, copies, exports, uploads, refreshes or backs up a login. One co
|
|
|
496
522
|
| `multi-claude path NAME` | Print the account's directory. |
|
|
497
523
|
| `multi-claude code NAME [PATH] [-- ARGS ...]` | Open a separate VS Code instance with the account's environment (experimental). |
|
|
498
524
|
| `multi-claude handoff TARGET [--from NAME] [--session ID] [--force]` | Copy a session to another account and print the command that resumes it there. |
|
|
525
|
+
| `multi-claude sessions [NAME] [--grep TEXT] [--limit N] [--json]` | List the sessions of every account and directory, newest first, with the command that resumes each. Also `conversations`. |
|
|
499
526
|
| `multi-claude statusline install\|uninstall FILE` | Wrap the statusLine command in a settings file so that `usage` gets fresh numbers, or restore it. |
|
|
500
527
|
| `multi-claude completion bash\|zsh\|fish` | Print a shell completion script. |
|
|
501
528
|
|
|
@@ -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,37 @@ 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.
|
|
306
|
+
|
|
307
|
+
### Finding a session
|
|
308
|
+
|
|
309
|
+
Forgot which account or which directory a session was started in? `sessions` lists the sessions of every account and every project directory, newest first, each followed by the command that resumes it:
|
|
310
|
+
|
|
311
|
+
```sh
|
|
312
|
+
multi-claude sessions
|
|
313
|
+
# ACCOUNT UPDATED CREATED SESSION ID TITLE
|
|
314
|
+
# work 2026-10-11 17:42 2026-10-11 16:05 3c588340-1219-48df-a231-509866789487 Fix the importer
|
|
315
|
+
# cd /path/to/project && claude-work --resume 3c588340-1219-48df-a231-509866789487
|
|
316
|
+
multi-claude sessions work # one account
|
|
317
|
+
multi-claude sessions --grep importer # title, directory or session id contains the text
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
- It shows the 30 most recent sessions; `--limit N` changes that and `--limit 0` shows all. `--json` prints every match with the full fields (`directory`, `created`, `updated`, `title_source`, `file`, `resume`). `conversations` is another name for the same command.
|
|
321
|
+
- The title is the session's name in Claude Code; a session without one shows the beginning of its first prompt, and `-` if it has no prompt at all. Times are local. `UPDATED` is the time of the last message, or the file's modification time when the session has no timestamps.
|
|
322
|
+
- A `~/.claude` that has not been turned into an account is listed as `default`.
|
|
323
|
+
- A session copied with `handoff` appears once per account that has a copy.
|
|
324
|
+
- The command is read-only and stays on this machine: it reads the start and the end of each session file, prints the title (a piece of your conversation) to the terminal, and saves or sends nothing. It does not list subagent transcripts.
|
|
325
|
+
- If the directory a session was started in no longer exists, the `cd` in the resume command fails; `claude --resume` only finds a session from the directory it was started in.
|
|
304
326
|
|
|
305
327
|
### Handing a session over to another account
|
|
306
328
|
|
|
@@ -422,6 +444,10 @@ The one in `~/.claude`, as before: multi-claude does not change it. After [migra
|
|
|
422
444
|
|
|
423
445
|
There is no command that switches which account plain `claude` uses. Besides `~/.claude`, plain `claude` reads the login in the keychain (macOS) and `~/.claude.json`, and neither would follow such a switch, so two accounts would get mixed up. Use the launchers (`claude-work`), or `multi-claude route --default work` and start `claude-auto`.
|
|
424
446
|
|
|
447
|
+
### Which account or directory was that session in?
|
|
448
|
+
|
|
449
|
+
`multi-claude sessions --grep TEXT` searches the titles, directories and ids of the sessions of all accounts and prints the command that resumes each match. See [Finding a session](#finding-a-session).
|
|
450
|
+
|
|
425
451
|
### How do I continue a conversation with another account?
|
|
426
452
|
|
|
427
453
|
Inside the Claude session, run `! multi-claude handoff OTHER`, then run the command it prints in a new terminal. See [Handing a session over to another account](#handing-a-session-over-to-another-account).
|
|
@@ -436,7 +462,7 @@ By default the numbers come from the cache that Claude Code writes in `.claude.j
|
|
|
436
462
|
|
|
437
463
|
### Does multi-claude read or copy my login?
|
|
438
464
|
|
|
439
|
-
It never
|
|
465
|
+
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
466
|
|
|
441
467
|
## Reference
|
|
442
468
|
|
|
@@ -456,7 +482,7 @@ It never writes, copies, exports, uploads, refreshes or backs up a login. One co
|
|
|
456
482
|
| `multi-claude args (NAME \| --defaults) -- [ARG ...]` | Replace the fixed arguments; `--` with nothing after it clears them. |
|
|
457
483
|
| `multi-claude remove NAME` | Unregister an account and delete its launcher. **The directory and the login are kept.** |
|
|
458
484
|
| `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). |
|
|
485
|
+
| `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
486
|
| `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
|
|
461
487
|
| `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
488
|
| `multi-claude doctor [--json] [--verbose]` | Check the configuration, launchers, account directories, shared links, logins and environment. |
|
|
@@ -469,6 +495,7 @@ It never writes, copies, exports, uploads, refreshes or backs up a login. One co
|
|
|
469
495
|
| `multi-claude path NAME` | Print the account's directory. |
|
|
470
496
|
| `multi-claude code NAME [PATH] [-- ARGS ...]` | Open a separate VS Code instance with the account's environment (experimental). |
|
|
471
497
|
| `multi-claude handoff TARGET [--from NAME] [--session ID] [--force]` | Copy a session to another account and print the command that resumes it there. |
|
|
498
|
+
| `multi-claude sessions [NAME] [--grep TEXT] [--limit N] [--json]` | List the sessions of every account and directory, newest first, with the command that resumes each. Also `conversations`. |
|
|
472
499
|
| `multi-claude statusline install\|uninstall FILE` | Wrap the statusLine command in a settings file so that `usage` gets fresh numbers, or restore it. |
|
|
473
500
|
| `multi-claude completion bash\|zsh\|fish` | Print a shell completion script. |
|
|
474
501
|
|
|
@@ -16,10 +16,10 @@ import stat
|
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
18
|
from datetime import datetime, timedelta, timezone
|
|
19
|
-
from typing import List, Optional, Tuple
|
|
19
|
+
from typing import List, NamedTuple, 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,
|
|
@@ -55,6 +55,7 @@ COMMAND_GROUPS = [
|
|
|
55
55
|
("usage", "5-hour and 7-day usage of each account"),
|
|
56
56
|
("route, which", "choose an account by directory (launcher claude-auto)"),
|
|
57
57
|
("handoff", "copy a session to another account"),
|
|
58
|
+
("sessions", "find a session across accounts and directories"),
|
|
58
59
|
("mcp", "run `claude mcp` as an account"),
|
|
59
60
|
("run, path", "run any command as an account; print its directory"),
|
|
60
61
|
("code", "open VS Code for an account (experimental)"),
|
|
@@ -173,6 +174,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
173
174
|
help="show every field, the global settings and the keychain details")
|
|
174
175
|
list_mode.add_argument("--json", action="store_true", help="print machine-readable JSON (includes usage)")
|
|
175
176
|
list_mode.add_argument("--names", action="store_true", help="print only the account names, one per line")
|
|
177
|
+
p_list.add_argument("--no-refresh", action="store_true",
|
|
178
|
+
help="do not renew an expired access token (never write to the login); "
|
|
179
|
+
"such accounts show 'token expired'")
|
|
176
180
|
p_list.add_argument("--offline", action="store_true",
|
|
177
181
|
help="skip the plan lookup: do not read access tokens and do not connect to the network")
|
|
178
182
|
|
|
@@ -273,6 +277,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
273
277
|
p_handoff.add_argument("--force", action="store_true",
|
|
274
278
|
help="back up and replace a different copy of the session in TARGET")
|
|
275
279
|
_add_dry_run(p_handoff)
|
|
280
|
+
|
|
281
|
+
p_sessions = sub.add_parser("sessions", description="list the Claude sessions of every account and every directory, "
|
|
282
|
+
"newest first, each with the command that resumes it "
|
|
283
|
+
"(read-only; also available as `conversations`): "
|
|
284
|
+
"sessions [NAME] [--grep TEXT] [--limit N] [--json]")
|
|
285
|
+
p_sessions.add_argument("name", nargs="?", metavar="NAME",
|
|
286
|
+
help="only this account (`default` is the unregistered ~/.claude)")
|
|
287
|
+
p_sessions.add_argument("--grep", metavar="TEXT",
|
|
288
|
+
help="only sessions whose title, directory or id contains TEXT (case-insensitive)")
|
|
289
|
+
p_sessions.add_argument("--limit", type=int, metavar="N",
|
|
290
|
+
help="show at most N sessions, 0 for all (default: 30, or all with --json)")
|
|
291
|
+
p_sessions.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
276
292
|
return parser
|
|
277
293
|
|
|
278
294
|
|
|
@@ -365,6 +381,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
365
381
|
if not argv:
|
|
366
382
|
# 不带参数:新用户先看到怎么上手,已有配置的用户看到账号表(方案 feature-easier-onboarding §5.1.1)。
|
|
367
383
|
return cmd_overview()
|
|
384
|
+
argv = _resolve_command_alias(argv)
|
|
368
385
|
parser = build_parser()
|
|
369
386
|
unknown = _unknown_command(parser, argv) or _misplaced_account_name(argv)
|
|
370
387
|
if unknown is not None:
|
|
@@ -400,13 +417,17 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
400
417
|
warn(notice)
|
|
401
418
|
# list 与 usage 只读:不加写锁,迁移未完成时也放行。
|
|
402
419
|
if args.command == "list":
|
|
403
|
-
return cmd_list(verbose=args.verbose, as_json=args.json, names_only=args.names, offline=args.offline
|
|
420
|
+
return cmd_list(verbose=args.verbose, as_json=args.json, names_only=args.names, offline=args.offline,
|
|
421
|
+
refresh_tokens=not args.no_refresh)
|
|
404
422
|
if args.command == "usage":
|
|
405
423
|
if args.history:
|
|
406
424
|
return cmd_usage_history(args.name, args.days, args.by or "day", args.json)
|
|
407
425
|
if args.days is not None or args.by is not None:
|
|
408
426
|
raise UsageError("--days and --by need --history")
|
|
409
427
|
return cmd_usage(args.name, args.json)
|
|
428
|
+
if args.command == "sessions":
|
|
429
|
+
# 只读:不加写锁,迁移未完成时也放行(与 list、usage 相同)。
|
|
430
|
+
return cmd_sessions(args.name, args.grep, args.limit, args.json)
|
|
410
431
|
if args.command == "which":
|
|
411
432
|
return cmd_which(args.path)
|
|
412
433
|
if args.command == "statusline":
|
|
@@ -1022,6 +1043,23 @@ All commands: multi-claude --help
|
|
|
1022
1043
|
"""
|
|
1023
1044
|
|
|
1024
1045
|
|
|
1046
|
+
# 别名 -> 子命令。不用 argparse 的 aliases:别名进了解析器,分组帮助、补全和 args.command 都要跟着处理两个名字。
|
|
1047
|
+
_COMMAND_ALIASES = {"conversations": "sessions"}
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _resolve_command_alias(argv: List[str]) -> List[str]:
|
|
1051
|
+
"""把子命令位置上的别名换成正式的子命令名;其它位置上的同名词(例如叫 conversations 的账号)不动。
|
|
1052
|
+
|
|
1053
|
+
子命令位置就是第一个不以 - 开头的词:顶层解析器没有带值的选项,_unknown_command 也按同一规则取词。
|
|
1054
|
+
"""
|
|
1055
|
+
for index, token in enumerate(argv):
|
|
1056
|
+
if not token.startswith("-"):
|
|
1057
|
+
if token in _COMMAND_ALIASES:
|
|
1058
|
+
return argv[:index] + [_COMMAND_ALIASES[token]] + argv[index + 1:]
|
|
1059
|
+
break
|
|
1060
|
+
return argv
|
|
1061
|
+
|
|
1062
|
+
|
|
1025
1063
|
def _unknown_command(parser: argparse.ArgumentParser, argv: List[str]) -> Optional[str]:
|
|
1026
1064
|
"""第一个非选项参数不是子命令时,返回带建议的报错文字;否则返回 None 交给 argparse。"""
|
|
1027
1065
|
word = next((token for token in argv if not token.startswith("-")), None)
|
|
@@ -1263,8 +1301,10 @@ def _checked_proxy(value: str) -> str:
|
|
|
1263
1301
|
raise UsageError(str(exc))
|
|
1264
1302
|
|
|
1265
1303
|
|
|
1266
|
-
def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = False, offline: bool = False
|
|
1267
|
-
|
|
1304
|
+
def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = False, offline: bool = False,
|
|
1305
|
+
refresh_tokens: bool = True) -> int:
|
|
1306
|
+
"""列出账号。除 --names 与 --offline 外,会读各账号的访问令牌并向服务端查询套餐(方案 feature-live-plan);
|
|
1307
|
+
访问令牌过期时先刷新并写回凭据,refresh_tokens 为假时不刷新(方案 feature-token-refresh)。"""
|
|
1268
1308
|
config, exists = load_config()
|
|
1269
1309
|
if names_only:
|
|
1270
1310
|
# 给补全脚本用:只输出名称,不输出任何告警。
|
|
@@ -1276,7 +1316,8 @@ def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = Fa
|
|
|
1276
1316
|
if variable in os.environ:
|
|
1277
1317
|
warn("{} is set; launchers do not change it, even with proxy off".format(variable))
|
|
1278
1318
|
# 套餐只查一次,表格与 JSON 共用;None 表示本次不查(--offline 或还没有配置)。
|
|
1279
|
-
plans = None if offline or not exists else plan.query_all(config, os.environ, datetime.now(timezone.utc)
|
|
1319
|
+
plans = None if offline or not exists else plan.query_all(config, os.environ, datetime.now(timezone.utc),
|
|
1320
|
+
allow_refresh=refresh_tokens)
|
|
1280
1321
|
if as_json:
|
|
1281
1322
|
_print_json(_list_data(config, exists, plans))
|
|
1282
1323
|
if exists:
|
|
@@ -1336,20 +1377,27 @@ def _plan_cells(plans: Optional[dict], name: str) -> tuple:
|
|
|
1336
1377
|
|
|
1337
1378
|
|
|
1338
1379
|
def _report_plan_problems(plans: Optional[dict]) -> None:
|
|
1339
|
-
"""
|
|
1380
|
+
"""套餐查询与令牌刷新的失败提示,打到 stderr;不影响表格、STATUS 列与退出码。"""
|
|
1340
1381
|
if plans is None:
|
|
1341
1382
|
return
|
|
1342
1383
|
expired = []
|
|
1343
1384
|
for name, result in plans.items():
|
|
1385
|
+
if result.refresh == refresh.SAVE_FAILED:
|
|
1386
|
+
# 新令牌只用了这一次、没存下来:旧的刷新令牌多半已作废,下次启动可能要重新登录。
|
|
1387
|
+
error("the renewed login of {0} could not be saved; if claude-{0} asks you to log in, "
|
|
1388
|
+
"run multi-claude login {0}".format(name))
|
|
1344
1389
|
if result.status == plan.STATUS_ERROR:
|
|
1345
1390
|
warn(plan.failure_text(name, result))
|
|
1346
1391
|
elif result.status == plan.STATUS_EXPIRED:
|
|
1347
1392
|
expired.append(name)
|
|
1393
|
+
if result.refresh is not None:
|
|
1394
|
+
hint = "; run multi-claude login {}".format(name) if result.refresh in refresh.NEEDS_LOGIN else ""
|
|
1395
|
+
warn("token refresh failed for {}: {}{}".format(name, result.refresh, hint))
|
|
1348
1396
|
if expired:
|
|
1349
|
-
# multi-claude 不刷新令牌:让 Claude Code 自己续期,再查一次即可。
|
|
1350
1397
|
# 走 stderr 而不是 info():info() 写 stdout,会混进 --json 的输出。
|
|
1351
|
-
print("{} expired:
|
|
1352
|
-
|
|
1398
|
+
print("{} token expired: the access token ran out, the subscription is not affected; start {} once, "
|
|
1399
|
+
"then run list again".format(LOG_PREFIX, ", ".join("claude-" + name for name in expired)),
|
|
1400
|
+
file=sys.stderr)
|
|
1353
1401
|
|
|
1354
1402
|
|
|
1355
1403
|
def _print_brief_list(config: Config, plans: Optional[dict] = None) -> int:
|
|
@@ -1688,6 +1736,129 @@ def cmd_handoff(target_name: str, source_name: Optional[str], session_id: Option
|
|
|
1688
1736
|
return accounts.EXIT_OK
|
|
1689
1737
|
|
|
1690
1738
|
|
|
1739
|
+
class _SessionSource(NamedTuple):
|
|
1740
|
+
# 账号列显示的名字;未登记的默认目录显示 default
|
|
1741
|
+
name: str
|
|
1742
|
+
directory: str
|
|
1743
|
+
# 续聊命令里启动 Claude 的那一段
|
|
1744
|
+
launcher: str
|
|
1745
|
+
registered: bool
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
_UNREGISTERED_DEFAULT = "default"
|
|
1749
|
+
# 未登记的默认目录没有启动命令,只能直接运行 claude;去掉这两个变量,在别的账号的 Claude 会话里
|
|
1750
|
+
# 运行也会落到默认目录(default 身份的启动命令同样先 unset 它们)。
|
|
1751
|
+
_DEFAULT_LAUNCHER = "env -u CLAUDE_CONFIG_DIR -u CLAUDE_SECURESTORAGE_CONFIG_DIR claude"
|
|
1752
|
+
_SESSIONS_TEXT_LIMIT = 30
|
|
1753
|
+
# 标题来自会话内容,可能是整段粘贴的提问:表格里只留开头,JSON 里也设上限
|
|
1754
|
+
_TITLE_TEXT_CHARS = 60
|
|
1755
|
+
_TITLE_JSON_CHARS = 200
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def _session_sources(config: Config, exists: bool) -> List[_SessionSource]:
|
|
1759
|
+
"""要列会话的目录:登记的每个账号,加上还没迁移成账号的默认目录 ~/.claude。"""
|
|
1760
|
+
sources = [_SessionSource(account.name, accounts.account_dir(config, account.name), "claude-" + account.name, True)
|
|
1761
|
+
for account in (config.accounts.values() if exists else [])]
|
|
1762
|
+
default = accounts.default_dir()
|
|
1763
|
+
# 做过 migrate-default 后 ~/.claude 是指向账号目录的软链,按真实路径比较才不会把同一批会话列两遍
|
|
1764
|
+
known = {os.path.realpath(source.directory) for source in sources}
|
|
1765
|
+
if os.path.isdir(os.path.join(default, "projects")) and os.path.realpath(default) not in known:
|
|
1766
|
+
sources.append(_SessionSource(_UNREGISTERED_DEFAULT, default, _DEFAULT_LAUNCHER, False))
|
|
1767
|
+
return sources
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def cmd_sessions(name: Optional[str], grep: Optional[str], limit: Optional[int], as_json: bool) -> int:
|
|
1771
|
+
"""只读列出各账号、各目录的会话,最近更新的在前,每条附续聊命令(方案 feature-session-list)。
|
|
1772
|
+
|
|
1773
|
+
会把会话标题或首条提问的开头打印出来(这是对话内容的片段),不保存、不联网、不读凭据。
|
|
1774
|
+
同一会话 ID 在多个账号里各有一份时(handoff 复制过)各列一行,不合并。
|
|
1775
|
+
"""
|
|
1776
|
+
if limit is not None and limit < 0:
|
|
1777
|
+
raise UsageError("--limit must be 0 or more")
|
|
1778
|
+
config, exists = load_config()
|
|
1779
|
+
sources = _session_sources(config, exists)
|
|
1780
|
+
if name is not None:
|
|
1781
|
+
# 名字先校验,没有配置文件时也一样:不合法的名字是用法错误(退出 2),不是“未登记”
|
|
1782
|
+
_checked_name(name)
|
|
1783
|
+
account = config.find(name) if exists else None
|
|
1784
|
+
if account is not None:
|
|
1785
|
+
# 登记账号优先:账号本身叫 default 时,`sessions default` 指的是它,不是未登记的默认目录
|
|
1786
|
+
sources = [source for source in sources if source.registered and source.name == account.name]
|
|
1787
|
+
elif name.casefold() == _UNREGISTERED_DEFAULT and any(not source.registered for source in sources):
|
|
1788
|
+
sources = [source for source in sources if not source.registered]
|
|
1789
|
+
else:
|
|
1790
|
+
error("account {!r} is not registered".format(name))
|
|
1791
|
+
return accounts.EXIT_ERROR
|
|
1792
|
+
entries = []
|
|
1793
|
+
skipped = 0
|
|
1794
|
+
needle = grep.casefold() if grep else None
|
|
1795
|
+
for source in sources:
|
|
1796
|
+
found, unreadable = sessions.list_sessions(source.directory)
|
|
1797
|
+
skipped += unreadable
|
|
1798
|
+
for session in found:
|
|
1799
|
+
full_title = sessions.clean_title(session.title, len(session.title))
|
|
1800
|
+
if needle is not None and not any(needle in text.casefold() for text in (
|
|
1801
|
+
full_title, session.directory or session.project, session.session_id)):
|
|
1802
|
+
continue
|
|
1803
|
+
entries.append((source, session, full_title))
|
|
1804
|
+
entries.sort(key=lambda entry: (-entry[1].updated.timestamp(), entry[0].name, entry[1].session_id))
|
|
1805
|
+
total = len(entries)
|
|
1806
|
+
if limit is None:
|
|
1807
|
+
limit = 0 if as_json else _SESSIONS_TEXT_LIMIT
|
|
1808
|
+
if limit:
|
|
1809
|
+
entries = entries[:limit]
|
|
1810
|
+
if skipped:
|
|
1811
|
+
warn("skipped {} session file(s) that could not be read".format(skipped))
|
|
1812
|
+
if as_json:
|
|
1813
|
+
_print_json({"schema_version": 1, "sessions": [{
|
|
1814
|
+
"account": source.name, "registered": source.registered, "directory": session.directory,
|
|
1815
|
+
"project": session.project, "session_id": session.session_id,
|
|
1816
|
+
"title": sessions.clean_title(full_title, _TITLE_JSON_CHARS), "title_source": session.title_source,
|
|
1817
|
+
"created": _iso(session.created), "updated": _iso(session.updated), "file": session.path,
|
|
1818
|
+
"resume": _resume_command(source, session)} for source, session, full_title in entries],
|
|
1819
|
+
"total": total, "skipped": skipped})
|
|
1820
|
+
return accounts.EXIT_OK
|
|
1821
|
+
if not entries:
|
|
1822
|
+
print("no sessions found")
|
|
1823
|
+
return accounts.EXIT_OK
|
|
1824
|
+
# 不用 _print_table:每条会话下面要插一行续聊命令。标题放最后一列,中文标题的显示宽度不影响前面各列对齐。
|
|
1825
|
+
rows = [("ACCOUNT", "UPDATED", "CREATED", "SESSION ID", "TITLE", None)]
|
|
1826
|
+
for source, session, full_title in entries:
|
|
1827
|
+
resume = _resume_command(source, session)
|
|
1828
|
+
if session.directory is None:
|
|
1829
|
+
resume += " (start directory unknown; project {})".format(
|
|
1830
|
+
sessions.clean_title(session.project, len(session.project)))
|
|
1831
|
+
rows.append((source.name, _local_minute(session.updated), _local_minute(session.created), session.session_id,
|
|
1832
|
+
sessions.clean_title(full_title, _TITLE_TEXT_CHARS) or "-", resume))
|
|
1833
|
+
widths = [max(len(row[index]) for row in rows) for index in range(4)]
|
|
1834
|
+
for row in rows:
|
|
1835
|
+
print(" ".join([cell.ljust(width) for cell, width in zip(row[:4], widths)] + [row[4]]).rstrip())
|
|
1836
|
+
if row[5] is not None:
|
|
1837
|
+
print(" " + row[5])
|
|
1838
|
+
if total > len(entries):
|
|
1839
|
+
print("showing {} of {} sessions; use --limit 0 for all, or --grep TEXT to narrow".format(len(entries), total))
|
|
1840
|
+
return accounts.EXIT_OK
|
|
1841
|
+
|
|
1842
|
+
|
|
1843
|
+
def _resume_command(source: _SessionSource, session: sessions.SessionInfo) -> str:
|
|
1844
|
+
"""续聊命令。Claude 按当前目录找会话,所以先 cd 到会话启动时的目录;目录未知时只给后半段。"""
|
|
1845
|
+
resume = "{} --resume {}".format(source.launcher, session.session_id)
|
|
1846
|
+
if session.directory is None:
|
|
1847
|
+
return resume
|
|
1848
|
+
return "cd {} && {}".format(shlex.quote(session.directory), resume)
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
def _local_minute(moment: Optional[datetime]) -> str:
|
|
1852
|
+
if moment is None:
|
|
1853
|
+
return "-"
|
|
1854
|
+
try:
|
|
1855
|
+
return moment.astimezone().strftime("%Y-%m-%d %H:%M")
|
|
1856
|
+
except (OverflowError, OSError, ValueError):
|
|
1857
|
+
# 会话文件里的时间戳可以是任何合法的 ISO 时间;公元 1 年、9999 年这类值换算成本地时间会越界。
|
|
1858
|
+
# 当作没有时间显示,不让一条坏记录中断整张表
|
|
1859
|
+
return "-"
|
|
1860
|
+
|
|
1861
|
+
|
|
1691
1862
|
def cmd_statusline(action: str, path: str, dry_run: bool) -> int:
|
|
1692
1863
|
"""包装或还原设置文件中的 statusLine 命令。文件不合法时退出 1 且不改动;其它写入错误由 main 统一处理。"""
|
|
1693
1864
|
try:
|
|
@@ -20,7 +20,7 @@ POSITIONAL_KINDS = {
|
|
|
20
20
|
"route": ["dir", "name"], "which": ["dir"], "rename": ["name"], "mcp": ["name"],
|
|
21
21
|
"statusline": ["statusline", "file"], "handoff": ["name"], "login": ["name"], "set": ["name"],
|
|
22
22
|
"run": ["name"], "path": ["name"], "restore": ["name"], "code": ["name", "file"],
|
|
23
|
-
"export": ["name", "file"], "import": ["file", "name"],
|
|
23
|
+
"export": ["name", "file"], "import": ["file", "name"], "sessions": ["name"],
|
|
24
24
|
}
|
|
25
25
|
# 选项值是路径、应按文件补全的选项。
|
|
26
26
|
PATH_OPTIONS = ("-f", "--file", "--root", "--bin-dir", "--shared-dir")
|