multi-claude-cli 0.9.1__tar.gz → 0.11.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.1 → multi_claude_cli-0.11.0}/PKG-INFO +29 -1
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/README.md +28 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/__init__.py +1 -1
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/cli.py +225 -1
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/completion.py +1 -1
- multi_claude_cli-0.11.0/src/multi_claude/sessions.py +436 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude_cli.egg-info/PKG-INFO +29 -1
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude_cli.egg-info/SOURCES.txt +1 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_completion.py +3 -1
- multi_claude_cli-0.11.0/tests/test_sessions.py +472 -0
- multi_claude_cli-0.9.1/src/multi_claude/sessions.py +0 -195
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/LICENSE +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/pyproject.toml +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/setup.cfg +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/__main__.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/accounts.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/actions.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/bundle.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/config.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/doctor.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/env.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/fsutil.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/history.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/hook.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/identity.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/launcher.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/lock.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/migrate.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/plan.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/platform.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/refresh.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/restore.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/routes.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/shared.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/shellpath.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/statusline.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude/usage.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude_cli.egg-info/dependency_links.txt +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude_cli.egg-info/entry_points.txt +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/src/multi_claude_cli.egg-info/top_level.txt +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_accounts.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_bundle.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_config.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_doctor.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_everyday.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_fsutil.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_handoff.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_help.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_history.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_identity.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_install.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_mcp.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_migrate.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_onboarding.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_packaging.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_plan.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_platform.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_refresh.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_rename.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_routes.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_set.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_shared_exclude.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_statusline.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_usability.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.0}/tests/test_usage.py +0 -0
- {multi_claude_cli-0.9.1 → multi_claude_cli-0.11.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.11.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
|
|
@@ -331,6 +331,29 @@ old keychain token expired - inherit no -
|
|
|
331
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`).
|
|
332
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
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; `--cmd` adds the command that resumes each one:
|
|
337
|
+
|
|
338
|
+
```sh
|
|
339
|
+
multi-claude sessions
|
|
340
|
+
# TITLE ACCOUNT CREATED UPDATED SESSION ID
|
|
341
|
+
# Fix the importer work 2026-10-11 16:05 2026-10-11 17:42 3c588340-1219-48df-a231-509866789487
|
|
342
|
+
multi-claude sessions work # one account
|
|
343
|
+
multi-claude sessions --grep importer # title, directory or session id contains the text
|
|
344
|
+
multi-claude sessions --grep importer --cmd
|
|
345
|
+
# TITLE ACCOUNT CREATED UPDATED SESSION ID CMD
|
|
346
|
+
# Fix the importer work 2026-10-11 16:05 2026-10-11 17:42 3c588340-1219-48df-a231-509866789487 cd ~/project && claude-work --resume 3c588340-1219-48df-a231-509866789487
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
- `--cmd` puts the resume command in a `CMD` column when the whole table fits the width of the terminal; otherwise each command goes on its own line below its session. The home directory is written as `~`. When the output is piped, the width counts as 80 columns unless `COLUMNS` is set.
|
|
350
|
+
- 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.
|
|
351
|
+
- 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.
|
|
352
|
+
- A `~/.claude` that has not been turned into an account is listed as `default`.
|
|
353
|
+
- A session copied with `handoff` appears once per account that has a copy.
|
|
354
|
+
- 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.
|
|
355
|
+
- 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.
|
|
356
|
+
|
|
334
357
|
### Handing a session over to another account
|
|
335
358
|
|
|
336
359
|
Each account keeps its own sessions, so `claude --resume` in one account cannot see the sessions of another. `handoff` copies one session (its `.jsonl` file and the folder next to it with subagent transcripts and saved tool output) into the same project folder of another account:
|
|
@@ -451,6 +474,10 @@ The one in `~/.claude`, as before: multi-claude does not change it. After [migra
|
|
|
451
474
|
|
|
452
475
|
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`.
|
|
453
476
|
|
|
477
|
+
### Which account or directory was that session in?
|
|
478
|
+
|
|
479
|
+
`multi-claude sessions --grep TEXT` searches the titles, directories and ids of the sessions of all accounts; add `--cmd` to print the command that resumes each match. See [Finding a session](#finding-a-session).
|
|
480
|
+
|
|
454
481
|
### How do I continue a conversation with another account?
|
|
455
482
|
|
|
456
483
|
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).
|
|
@@ -498,6 +525,7 @@ It never copies, exports, uploads or backs up a login. One command touches one:
|
|
|
498
525
|
| `multi-claude path NAME` | Print the account's directory. |
|
|
499
526
|
| `multi-claude code NAME [PATH] [-- ARGS ...]` | Open a separate VS Code instance with the account's environment (experimental). |
|
|
500
527
|
| `multi-claude handoff TARGET [--from NAME] [--session ID] [--force]` | Copy a session to another account and print the command that resumes it there. |
|
|
528
|
+
| `multi-claude sessions [NAME] [--grep TEXT] [--limit N] [--cmd] [--json]` | List the sessions of every account and directory, newest first; `--cmd` adds the command that resumes each. Also `conversations`. |
|
|
501
529
|
| `multi-claude statusline install\|uninstall FILE` | Wrap the statusLine command in a settings file so that `usage` gets fresh numbers, or restore it. |
|
|
502
530
|
| `multi-claude completion bash\|zsh\|fish` | Print a shell completion script. |
|
|
503
531
|
|
|
@@ -304,6 +304,29 @@ old keychain token expired - inherit no -
|
|
|
304
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`).
|
|
305
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
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; `--cmd` adds the command that resumes each one:
|
|
310
|
+
|
|
311
|
+
```sh
|
|
312
|
+
multi-claude sessions
|
|
313
|
+
# TITLE ACCOUNT CREATED UPDATED SESSION ID
|
|
314
|
+
# Fix the importer work 2026-10-11 16:05 2026-10-11 17:42 3c588340-1219-48df-a231-509866789487
|
|
315
|
+
multi-claude sessions work # one account
|
|
316
|
+
multi-claude sessions --grep importer # title, directory or session id contains the text
|
|
317
|
+
multi-claude sessions --grep importer --cmd
|
|
318
|
+
# TITLE ACCOUNT CREATED UPDATED SESSION ID CMD
|
|
319
|
+
# Fix the importer work 2026-10-11 16:05 2026-10-11 17:42 3c588340-1219-48df-a231-509866789487 cd ~/project && claude-work --resume 3c588340-1219-48df-a231-509866789487
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
- `--cmd` puts the resume command in a `CMD` column when the whole table fits the width of the terminal; otherwise each command goes on its own line below its session. The home directory is written as `~`. When the output is piped, the width counts as 80 columns unless `COLUMNS` is set.
|
|
323
|
+
- 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.
|
|
324
|
+
- 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.
|
|
325
|
+
- A `~/.claude` that has not been turned into an account is listed as `default`.
|
|
326
|
+
- A session copied with `handoff` appears once per account that has a copy.
|
|
327
|
+
- 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.
|
|
328
|
+
- 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.
|
|
329
|
+
|
|
307
330
|
### Handing a session over to another account
|
|
308
331
|
|
|
309
332
|
Each account keeps its own sessions, so `claude --resume` in one account cannot see the sessions of another. `handoff` copies one session (its `.jsonl` file and the folder next to it with subagent transcripts and saved tool output) into the same project folder of another account:
|
|
@@ -424,6 +447,10 @@ The one in `~/.claude`, as before: multi-claude does not change it. After [migra
|
|
|
424
447
|
|
|
425
448
|
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`.
|
|
426
449
|
|
|
450
|
+
### Which account or directory was that session in?
|
|
451
|
+
|
|
452
|
+
`multi-claude sessions --grep TEXT` searches the titles, directories and ids of the sessions of all accounts; add `--cmd` to print the command that resumes each match. See [Finding a session](#finding-a-session).
|
|
453
|
+
|
|
427
454
|
### How do I continue a conversation with another account?
|
|
428
455
|
|
|
429
456
|
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).
|
|
@@ -471,6 +498,7 @@ It never copies, exports, uploads or backs up a login. One command touches one:
|
|
|
471
498
|
| `multi-claude path NAME` | Print the account's directory. |
|
|
472
499
|
| `multi-claude code NAME [PATH] [-- ARGS ...]` | Open a separate VS Code instance with the account's environment (experimental). |
|
|
473
500
|
| `multi-claude handoff TARGET [--from NAME] [--session ID] [--force]` | Copy a session to another account and print the command that resumes it there. |
|
|
501
|
+
| `multi-claude sessions [NAME] [--grep TEXT] [--limit N] [--cmd] [--json]` | List the sessions of every account and directory, newest first; `--cmd` adds the command that resumes each. Also `conversations`. |
|
|
474
502
|
| `multi-claude statusline install\|uninstall FILE` | Wrap the statusLine command in a settings file so that `usage` gets fresh numbers, or restore it. |
|
|
475
503
|
| `multi-claude completion bash\|zsh\|fish` | Print a shell completion script. |
|
|
476
504
|
|
|
@@ -15,8 +15,9 @@ import shutil
|
|
|
15
15
|
import stat
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
|
+
import unicodedata
|
|
18
19
|
from datetime import datetime, timedelta, timezone
|
|
19
|
-
from typing import List, Optional, Tuple
|
|
20
|
+
from typing import List, NamedTuple, Optional, Tuple
|
|
20
21
|
|
|
21
22
|
from . import (__version__, accounts, bundle, completion, doctor, history, hook, identity, launcher, migrate,
|
|
22
23
|
plan, platform, refresh, restore, routes, sessions, shellpath, statusline, usage)
|
|
@@ -55,6 +56,7 @@ COMMAND_GROUPS = [
|
|
|
55
56
|
("usage", "5-hour and 7-day usage of each account"),
|
|
56
57
|
("route, which", "choose an account by directory (launcher claude-auto)"),
|
|
57
58
|
("handoff", "copy a session to another account"),
|
|
59
|
+
("sessions", "find a session across accounts and directories"),
|
|
58
60
|
("mcp", "run `claude mcp` as an account"),
|
|
59
61
|
("run, path", "run any command as an account; print its directory"),
|
|
60
62
|
("code", "open VS Code for an account (experimental)"),
|
|
@@ -276,6 +278,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
276
278
|
p_handoff.add_argument("--force", action="store_true",
|
|
277
279
|
help="back up and replace a different copy of the session in TARGET")
|
|
278
280
|
_add_dry_run(p_handoff)
|
|
281
|
+
|
|
282
|
+
p_sessions = sub.add_parser("sessions", description="list the Claude sessions of every account and every directory, "
|
|
283
|
+
"newest first; --cmd adds the command that resumes each "
|
|
284
|
+
"(read-only; also available as `conversations`): "
|
|
285
|
+
"sessions [NAME] [--grep TEXT] [--limit N] [--cmd] [--json]")
|
|
286
|
+
p_sessions.add_argument("name", nargs="?", metavar="NAME",
|
|
287
|
+
help="only this account (`default` is the unregistered ~/.claude)")
|
|
288
|
+
p_sessions.add_argument("--grep", metavar="TEXT",
|
|
289
|
+
help="only sessions whose title, directory or id contains TEXT (case-insensitive)")
|
|
290
|
+
p_sessions.add_argument("--limit", type=int, metavar="N",
|
|
291
|
+
help="show at most N sessions, 0 for all (default: 30, or all with --json)")
|
|
292
|
+
p_sessions.add_argument("--cmd", action="store_true",
|
|
293
|
+
help="also print the command that resumes each session (a CMD column when the "
|
|
294
|
+
"terminal is wide enough, otherwise a line below each session)")
|
|
295
|
+
p_sessions.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
279
296
|
return parser
|
|
280
297
|
|
|
281
298
|
|
|
@@ -368,6 +385,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
368
385
|
if not argv:
|
|
369
386
|
# 不带参数:新用户先看到怎么上手,已有配置的用户看到账号表(方案 feature-easier-onboarding §5.1.1)。
|
|
370
387
|
return cmd_overview()
|
|
388
|
+
argv = _resolve_command_alias(argv)
|
|
371
389
|
parser = build_parser()
|
|
372
390
|
unknown = _unknown_command(parser, argv) or _misplaced_account_name(argv)
|
|
373
391
|
if unknown is not None:
|
|
@@ -411,6 +429,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
411
429
|
if args.days is not None or args.by is not None:
|
|
412
430
|
raise UsageError("--days and --by need --history")
|
|
413
431
|
return cmd_usage(args.name, args.json)
|
|
432
|
+
if args.command == "sessions":
|
|
433
|
+
# 只读:不加写锁,迁移未完成时也放行(与 list、usage 相同)。
|
|
434
|
+
return cmd_sessions(args.name, args.grep, args.limit, args.json, args.cmd)
|
|
414
435
|
if args.command == "which":
|
|
415
436
|
return cmd_which(args.path)
|
|
416
437
|
if args.command == "statusline":
|
|
@@ -1026,6 +1047,23 @@ All commands: multi-claude --help
|
|
|
1026
1047
|
"""
|
|
1027
1048
|
|
|
1028
1049
|
|
|
1050
|
+
# 别名 -> 子命令。不用 argparse 的 aliases:别名进了解析器,分组帮助、补全和 args.command 都要跟着处理两个名字。
|
|
1051
|
+
_COMMAND_ALIASES = {"conversations": "sessions"}
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _resolve_command_alias(argv: List[str]) -> List[str]:
|
|
1055
|
+
"""把子命令位置上的别名换成正式的子命令名;其它位置上的同名词(例如叫 conversations 的账号)不动。
|
|
1056
|
+
|
|
1057
|
+
子命令位置就是第一个不以 - 开头的词:顶层解析器没有带值的选项,_unknown_command 也按同一规则取词。
|
|
1058
|
+
"""
|
|
1059
|
+
for index, token in enumerate(argv):
|
|
1060
|
+
if not token.startswith("-"):
|
|
1061
|
+
if token in _COMMAND_ALIASES:
|
|
1062
|
+
return argv[:index] + [_COMMAND_ALIASES[token]] + argv[index + 1:]
|
|
1063
|
+
break
|
|
1064
|
+
return argv
|
|
1065
|
+
|
|
1066
|
+
|
|
1029
1067
|
def _unknown_command(parser: argparse.ArgumentParser, argv: List[str]) -> Optional[str]:
|
|
1030
1068
|
"""第一个非选项参数不是子命令时,返回带建议的报错文字;否则返回 None 交给 argparse。"""
|
|
1031
1069
|
word = next((token for token in argv if not token.startswith("-")), None)
|
|
@@ -1702,6 +1740,192 @@ def cmd_handoff(target_name: str, source_name: Optional[str], session_id: Option
|
|
|
1702
1740
|
return accounts.EXIT_OK
|
|
1703
1741
|
|
|
1704
1742
|
|
|
1743
|
+
class _SessionSource(NamedTuple):
|
|
1744
|
+
# 账号列显示的名字;未登记的默认目录显示 default
|
|
1745
|
+
name: str
|
|
1746
|
+
directory: str
|
|
1747
|
+
# 续聊命令里启动 Claude 的那一段
|
|
1748
|
+
launcher: str
|
|
1749
|
+
registered: bool
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
_UNREGISTERED_DEFAULT = "default"
|
|
1753
|
+
# 未登记的默认目录没有启动命令,只能直接运行 claude;去掉这两个变量,在别的账号的 Claude 会话里
|
|
1754
|
+
# 运行也会落到默认目录(default 身份的启动命令同样先 unset 它们)。
|
|
1755
|
+
_DEFAULT_LAUNCHER = "env -u CLAUDE_CONFIG_DIR -u CLAUDE_SECURESTORAGE_CONFIG_DIR claude"
|
|
1756
|
+
_SESSIONS_TEXT_LIMIT = 30
|
|
1757
|
+
# 标题来自会话内容,可能是整段粘贴的提问:表格里只留开头,JSON 里也设上限
|
|
1758
|
+
_TITLE_TEXT_CHARS = 60
|
|
1759
|
+
_TITLE_JSON_CHARS = 200
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
def _session_sources(config: Config, exists: bool) -> List[_SessionSource]:
|
|
1763
|
+
"""要列会话的目录:登记的每个账号,加上还没迁移成账号的默认目录 ~/.claude。"""
|
|
1764
|
+
sources = [_SessionSource(account.name, accounts.account_dir(config, account.name), "claude-" + account.name, True)
|
|
1765
|
+
for account in (config.accounts.values() if exists else [])]
|
|
1766
|
+
default = accounts.default_dir()
|
|
1767
|
+
# 做过 migrate-default 后 ~/.claude 是指向账号目录的软链,按真实路径比较才不会把同一批会话列两遍
|
|
1768
|
+
known = {os.path.realpath(source.directory) for source in sources}
|
|
1769
|
+
if os.path.isdir(os.path.join(default, "projects")) and os.path.realpath(default) not in known:
|
|
1770
|
+
sources.append(_SessionSource(_UNREGISTERED_DEFAULT, default, _DEFAULT_LAUNCHER, False))
|
|
1771
|
+
return sources
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def cmd_sessions(name: Optional[str], grep: Optional[str], limit: Optional[int], as_json: bool,
|
|
1775
|
+
show_cmd: bool = False) -> int:
|
|
1776
|
+
"""只读列出各账号、各目录的会话,最近更新的在前(方案 feature-session-list、feature-sessions-cmd-column)。
|
|
1777
|
+
|
|
1778
|
+
show_cmd 只影响文本输出:为真时才打印续聊命令。JSON 输出始终带 resume 字段,不受它影响。
|
|
1779
|
+
|
|
1780
|
+
会把会话标题或首条提问的开头打印出来(这是对话内容的片段),不保存、不联网、不读凭据。
|
|
1781
|
+
同一会话 ID 在多个账号里各有一份时(handoff 复制过)各列一行,不合并。
|
|
1782
|
+
"""
|
|
1783
|
+
if limit is not None and limit < 0:
|
|
1784
|
+
raise UsageError("--limit must be 0 or more")
|
|
1785
|
+
config, exists = load_config()
|
|
1786
|
+
sources = _session_sources(config, exists)
|
|
1787
|
+
if name is not None:
|
|
1788
|
+
# 名字先校验,没有配置文件时也一样:不合法的名字是用法错误(退出 2),不是“未登记”
|
|
1789
|
+
_checked_name(name)
|
|
1790
|
+
account = config.find(name) if exists else None
|
|
1791
|
+
if account is not None:
|
|
1792
|
+
# 登记账号优先:账号本身叫 default 时,`sessions default` 指的是它,不是未登记的默认目录
|
|
1793
|
+
sources = [source for source in sources if source.registered and source.name == account.name]
|
|
1794
|
+
elif name.casefold() == _UNREGISTERED_DEFAULT and any(not source.registered for source in sources):
|
|
1795
|
+
sources = [source for source in sources if not source.registered]
|
|
1796
|
+
else:
|
|
1797
|
+
error("account {!r} is not registered".format(name))
|
|
1798
|
+
return accounts.EXIT_ERROR
|
|
1799
|
+
entries = []
|
|
1800
|
+
skipped = 0
|
|
1801
|
+
needle = grep.casefold() if grep else None
|
|
1802
|
+
for source in sources:
|
|
1803
|
+
found, unreadable = sessions.list_sessions(source.directory)
|
|
1804
|
+
skipped += unreadable
|
|
1805
|
+
for session in found:
|
|
1806
|
+
full_title = sessions.clean_title(session.title, len(session.title))
|
|
1807
|
+
if needle is not None and not any(needle in text.casefold() for text in (
|
|
1808
|
+
full_title, session.directory or session.project, session.session_id)):
|
|
1809
|
+
continue
|
|
1810
|
+
entries.append((source, session, full_title))
|
|
1811
|
+
entries.sort(key=lambda entry: (-entry[1].updated.timestamp(), entry[0].name, entry[1].session_id))
|
|
1812
|
+
total = len(entries)
|
|
1813
|
+
if limit is None:
|
|
1814
|
+
limit = 0 if as_json else _SESSIONS_TEXT_LIMIT
|
|
1815
|
+
if limit:
|
|
1816
|
+
entries = entries[:limit]
|
|
1817
|
+
if skipped:
|
|
1818
|
+
warn("skipped {} session file(s) that could not be read".format(skipped))
|
|
1819
|
+
if as_json:
|
|
1820
|
+
_print_json({"schema_version": 1, "sessions": [{
|
|
1821
|
+
"account": source.name, "registered": source.registered, "directory": session.directory,
|
|
1822
|
+
"project": session.project, "session_id": session.session_id,
|
|
1823
|
+
"title": sessions.clean_title(full_title, _TITLE_JSON_CHARS), "title_source": session.title_source,
|
|
1824
|
+
"created": _iso(session.created), "updated": _iso(session.updated), "file": session.path,
|
|
1825
|
+
"resume": _resume_command(source, session)} for source, session, full_title in entries],
|
|
1826
|
+
"total": total, "skipped": skipped})
|
|
1827
|
+
return accounts.EXIT_OK
|
|
1828
|
+
if not entries:
|
|
1829
|
+
print("no sessions found")
|
|
1830
|
+
return accounts.EXIT_OK
|
|
1831
|
+
# 不用 _print_table:标题在第一列且可能是中文,要按终端显示宽度对齐;带 --cmd 时还要在两种排版之间选。
|
|
1832
|
+
header = ["TITLE", "ACCOUNT", "CREATED", "UPDATED", "SESSION ID"]
|
|
1833
|
+
rows = []
|
|
1834
|
+
commands = []
|
|
1835
|
+
for source, session, full_title in entries:
|
|
1836
|
+
rows.append([sessions.clean_title(full_title, _TITLE_TEXT_CHARS) or "-", source.name,
|
|
1837
|
+
_local_minute(session.created), _local_minute(session.updated), session.session_id])
|
|
1838
|
+
resume = _resume_command(source, session, tilde=True)
|
|
1839
|
+
if session.directory is None:
|
|
1840
|
+
resume += " (start directory unknown; project {})".format(
|
|
1841
|
+
sessions.clean_title(session.project, len(session.project)))
|
|
1842
|
+
commands.append(resume)
|
|
1843
|
+
if not show_cmd:
|
|
1844
|
+
for line in _aligned([header] + rows):
|
|
1845
|
+
print(line)
|
|
1846
|
+
else:
|
|
1847
|
+
# 续聊命令很长:整张表(表头与数据行,不含末尾的截断提示)放得下终端宽度才并成 CMD 列,
|
|
1848
|
+
# 否则命令在窄终端里会从中间折行,比单独一行更难读、也更难复制。按整张表决定,不逐行切换。
|
|
1849
|
+
wide = _aligned([header + ["CMD"]] + [row + [command] for row, command in zip(rows, commands)])
|
|
1850
|
+
if max(_display_width(line) for line in wide) <= shutil.get_terminal_size((80, 24)).columns:
|
|
1851
|
+
for line in wide:
|
|
1852
|
+
print(line)
|
|
1853
|
+
else:
|
|
1854
|
+
narrow = _aligned([header] + rows)
|
|
1855
|
+
print(narrow[0])
|
|
1856
|
+
for line, command in zip(narrow[1:], commands):
|
|
1857
|
+
print(line)
|
|
1858
|
+
print(" " + command)
|
|
1859
|
+
if total > len(entries):
|
|
1860
|
+
print("showing {} of {} sessions; use --limit 0 for all, or --grep TEXT to narrow".format(len(entries), total))
|
|
1861
|
+
return accounts.EXIT_OK
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
def _resume_command(source: _SessionSource, session: sessions.SessionInfo, tilde: bool = False) -> str:
|
|
1865
|
+
"""续聊命令。Claude 按当前目录找会话,所以先 cd 到会话启动时的目录;目录未知时只给后半段。
|
|
1866
|
+
|
|
1867
|
+
tilde 为真时把主目录写成 ~,只用于给人看的文本输出;JSON 的 resume 字段保持绝对路径。
|
|
1868
|
+
"""
|
|
1869
|
+
resume = "{} --resume {}".format(source.launcher, session.session_id)
|
|
1870
|
+
if session.directory is None:
|
|
1871
|
+
return resume
|
|
1872
|
+
target = _tilde_path(session.directory) if tilde else shlex.quote(session.directory)
|
|
1873
|
+
return "cd {} && {}".format(target, resume)
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
def _tilde_path(directory: str) -> str:
|
|
1877
|
+
"""把主目录之下的目录写成 ~/…,可直接拼在 cd 后面;不在主目录之下时与 shlex.quote 相同。
|
|
1878
|
+
|
|
1879
|
+
按字符串前缀比较,不解析软链:会话里记的是当时的目录原文。
|
|
1880
|
+
"""
|
|
1881
|
+
home = os.path.expanduser("~")
|
|
1882
|
+
if not os.path.isabs(home) or home == os.sep:
|
|
1883
|
+
return shlex.quote(directory)
|
|
1884
|
+
if directory == home:
|
|
1885
|
+
return "~"
|
|
1886
|
+
if not directory.startswith(home + os.sep):
|
|
1887
|
+
return shlex.quote(directory)
|
|
1888
|
+
rest = directory[len(home) + 1:]
|
|
1889
|
+
# 波浪号必须留在引号外面,放进引号里 shell 不会把它展开成主目录
|
|
1890
|
+
return "~/" + shlex.quote(rest) if rest else "~"
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
def _display_width(text: str) -> int:
|
|
1894
|
+
"""文字在终端里占的格数:中日韩等宽字符占 2 格,组合字符和零宽连接符不占格。
|
|
1895
|
+
|
|
1896
|
+
表情符号的组合序列在不同终端里宽度不一,这里可能差一格。
|
|
1897
|
+
"""
|
|
1898
|
+
width = 0
|
|
1899
|
+
for char in text:
|
|
1900
|
+
# U+200C、U+200D 是 clean_title 特意保留的零宽字符
|
|
1901
|
+
if char in "\u200c\u200d" or unicodedata.category(char) in ("Mn", "Me"):
|
|
1902
|
+
continue
|
|
1903
|
+
width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
|
|
1904
|
+
return width
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
def _aligned(rows: List[List[str]]) -> List[str]:
|
|
1908
|
+
"""按显示宽度对齐各列,返回每行的文字;列间两个空格,最后一列不补空格。"""
|
|
1909
|
+
widths = [max(_display_width(row[index]) for row in rows) for index in range(len(rows[0]))]
|
|
1910
|
+
lines = []
|
|
1911
|
+
for row in rows:
|
|
1912
|
+
# str.ljust 按字符数补空格,中文标题会少补;这里按显示宽度补
|
|
1913
|
+
cells = [cell + " " * (width - _display_width(cell)) for cell, width in zip(row, widths)]
|
|
1914
|
+
lines.append(" ".join(cells).rstrip())
|
|
1915
|
+
return lines
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
def _local_minute(moment: Optional[datetime]) -> str:
|
|
1919
|
+
if moment is None:
|
|
1920
|
+
return "-"
|
|
1921
|
+
try:
|
|
1922
|
+
return moment.astimezone().strftime("%Y-%m-%d %H:%M")
|
|
1923
|
+
except (OverflowError, OSError, ValueError):
|
|
1924
|
+
# 会话文件里的时间戳可以是任何合法的 ISO 时间;公元 1 年、9999 年这类值换算成本地时间会越界。
|
|
1925
|
+
# 当作没有时间显示,不让一条坏记录中断整张表
|
|
1926
|
+
return "-"
|
|
1927
|
+
|
|
1928
|
+
|
|
1705
1929
|
def cmd_statusline(action: str, path: str, dry_run: bool) -> int:
|
|
1706
1930
|
"""包装或还原设置文件中的 statusLine 命令。文件不合法时退出 1 且不改动;其它写入错误由 main 统一处理。"""
|
|
1707
1931
|
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")
|