multi-claude-cli 0.6.0__tar.gz → 0.7.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.
Files changed (58) hide show
  1. {multi_claude_cli-0.6.0/src/multi_claude_cli.egg-info → multi_claude_cli-0.7.0}/PKG-INFO +28 -3
  2. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/README.md +27 -2
  3. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/__init__.py +1 -1
  4. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/cli.py +104 -6
  5. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/doctor.py +2 -1
  6. multi_claude_cli-0.7.0/src/multi_claude/history.py +178 -0
  7. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0/src/multi_claude_cli.egg-info}/PKG-INFO +28 -3
  8. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude_cli.egg-info/SOURCES.txt +2 -0
  9. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_help.py +2 -2
  10. multi_claude_cli-0.7.0/tests/test_history.py +194 -0
  11. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/LICENSE +0 -0
  12. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/pyproject.toml +0 -0
  13. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/setup.cfg +0 -0
  14. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/__main__.py +0 -0
  15. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/accounts.py +0 -0
  16. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/actions.py +0 -0
  17. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/completion.py +0 -0
  18. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/config.py +0 -0
  19. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/env.py +0 -0
  20. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/fsutil.py +0 -0
  21. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/hook.py +0 -0
  22. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/identity.py +0 -0
  23. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/launcher.py +0 -0
  24. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/lock.py +0 -0
  25. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/migrate.py +0 -0
  26. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/platform.py +0 -0
  27. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/restore.py +0 -0
  28. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/routes.py +0 -0
  29. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/sessions.py +0 -0
  30. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/shared.py +0 -0
  31. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/shellpath.py +0 -0
  32. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/statusline.py +0 -0
  33. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude/usage.py +0 -0
  34. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude_cli.egg-info/dependency_links.txt +0 -0
  35. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude_cli.egg-info/entry_points.txt +0 -0
  36. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/src/multi_claude_cli.egg-info/top_level.txt +0 -0
  37. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_accounts.py +0 -0
  38. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_completion.py +0 -0
  39. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_config.py +0 -0
  40. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_doctor.py +0 -0
  41. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_everyday.py +0 -0
  42. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_fsutil.py +0 -0
  43. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_handoff.py +0 -0
  44. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_identity.py +0 -0
  45. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_install.py +0 -0
  46. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_mcp.py +0 -0
  47. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_migrate.py +0 -0
  48. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_onboarding.py +0 -0
  49. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_packaging.py +0 -0
  50. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_platform.py +0 -0
  51. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_rename.py +0 -0
  52. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_routes.py +0 -0
  53. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_set.py +0 -0
  54. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_shared_exclude.py +0 -0
  55. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_statusline.py +0 -0
  56. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_usability.py +0 -0
  57. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.0}/tests/test_usage.py +0 -0
  58. {multi_claude_cli-0.6.0 → multi_claude_cli-0.7.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.6.0
3
+ Version: 0.7.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,6 +37,8 @@ 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 reads your login, never connects to the network and never edits your shell startup files.
41
+
40
42
  ```sh
41
43
  claude-work # Claude Code with your work account
42
44
  claude-personal # Claude Code with your personal account, in another terminal
@@ -62,6 +64,10 @@ The launchers are plain shell scripts. They keep working even if you uninstall m
62
64
 
63
65
  ## Features
64
66
 
67
+ - **Never reads your login** — the only keychain call checks whether an item exists; the contents of keychain items and `.credentials.json` are never opened. `migrate-default` and `restore` move whole account directories, and on Linux the credentials file moves with its directory without being opened.
68
+ - **No network** — multi-claude itself makes no network connections: no telemetry, no usage reporting, no update checks. Only the installer, pipx or Homebrew download the package.
69
+ - **Leaves your shell alone** — when `PATH` needs a change, it prints the line to add instead of editing `~/.zshrc` or `~/.bashrc`.
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`).
65
71
  - **Migrate the default account** — move your existing `~/.claude` into the account root without logging out. `claude` keeps working exactly as before.
66
72
  - **Add accounts** — create an account directory and a `claude-<name>` launcher, or adopt a directory you already have.
67
73
  - **Per-account proxy** — a local port, an HTTP(S) proxy URL, `off`, or `inherit`.
@@ -69,7 +75,6 @@ The launchers are plain shell scripts. They keep working even if you uninstall m
69
75
  - **One-step deployment** — `install.sh --config accounts.json` installs the tool and creates every account in the file.
70
76
  - **Idempotent** — every command can be re-run safely. Write commands print only what changes (`--verbose` lists everything); conflicts with files multi-claude does not own are reported without changing anything; an interrupted migration resumes where it stopped.
71
77
  - **Optional shared resources** — link `CLAUDE.md`, `skills`, `agents` and `commands` from one shared directory into selected accounts.
72
- - **Hands off your credentials** — multi-claude never reads, copies or deletes logins. Its only keychain call checks whether an item exists.
73
78
 
74
79
  ## Requirements
75
80
 
@@ -245,6 +250,23 @@ The command becomes `<path of multi-claude> statusline-hook '<original command>'
245
250
  - If several settings files define `statusLine`, wrap the one that wins (for example a file passed with `--settings` in the launcher arguments).
246
251
  - Run `multi-claude statusline uninstall FILE` before uninstalling multi-claude; otherwise the status line stays empty because the wrapped command cannot be found. Running `install` again after multi-claude moves updates the path.
247
252
 
253
+ #### Token history
254
+
255
+ ```sh
256
+ multi-claude usage --history # tokens per day, last 7 days, every account
257
+ multi-claude usage --history work --days 30 --by model
258
+ ```
259
+
260
+ ```text
261
+ work:
262
+ DATE INPUT OUTPUT CACHE READ CACHE WRITE REPLIES
263
+ 2026-10-01 1,246 499,681 273,254,361 5,926,527 612
264
+ ```
265
+
266
+ `--history` counts the replies in the account's session files on this machine (`projects/**/*.jsonl`, including subagents): input, output, cache-read and cache-write tokens per day (local time) or per model. Only these counters are read; nothing else from the conversations is printed or stored, and nothing is sent anywhere. Sessions on other machines and deleted sessions are not counted. A session copied with `handoff` appears in both accounts; the `TOTAL` block counts each reply once. `--json` prints the same numbers for scripts.
267
+
268
+ `list` shows when each account was last used (`LAST USED`), from the newest session file or prompt history in its directory; a `handoff` into an account counts as use.
269
+
248
270
  ### Handing a session over to another account
249
271
 
250
272
  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:
@@ -363,6 +385,8 @@ With pipx, run `pipx upgrade multi-claude-cli`; with Homebrew, `brew upgrade mul
363
385
 
364
386
  The one in `~/.claude`, as before: multi-claude does not change it. After [migrating `~/.claude`](#migrating-claude) it is the migrated account. `claude-auto` picks an account by directory instead; see [Choosing an account by directory](#choosing-an-account-by-directory).
365
387
 
388
+ 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`.
389
+
366
390
  ### How do I continue a conversation with another account?
367
391
 
368
392
  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).
@@ -377,7 +401,7 @@ By default the numbers come from the cache that Claude Code writes in `.claude.j
377
401
 
378
402
  ### Does multi-claude read or copy my login?
379
403
 
380
- No. It never reads, copies or deletes credentials; its only keychain call checks whether an item exists. On macOS a login is tied to the account directory's path, which is why `rename` keeps the directory.
404
+ It never reads it: its only keychain call checks whether an item exists, and it never opens `.credentials.json`. It does not export, upload or back up logins. `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.
381
405
 
382
406
  ## Reference
383
407
 
@@ -397,6 +421,7 @@ No. It never reads, copies or deletes credentials; its only keychain call checks
397
421
  | `multi-claude apply [-f FILE]` | Converge everything to the configuration (or to `FILE`). |
398
422
  | `multi-claude list [--verbose \| --json \| --names]` | Show accounts, launchers and login state. `--json` adds usage and is meant for scripts; `--names` prints only the names. |
399
423
  | `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
424
+ | `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). |
400
425
  | `multi-claude doctor [--json] [--verbose]` | Check the configuration, launchers, account directories, shared links, logins and environment. |
401
426
  | `multi-claude route DIR NAME`, `route DIR --remove`, `route --default NAME`, `route --no-default` | Choose an account by directory for `claude-auto`. |
402
427
  | `multi-claude which [DIR]` | Show which account `claude-auto` would use in `DIR` (default: the current directory). |
@@ -10,6 +10,8 @@
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 reads your login, never connects to the network and never edits your shell startup files.
14
+
13
15
  ```sh
14
16
  claude-work # Claude Code with your work account
15
17
  claude-personal # Claude Code with your personal account, in another terminal
@@ -35,6 +37,10 @@ The launchers are plain shell scripts. They keep working even if you uninstall m
35
37
 
36
38
  ## Features
37
39
 
40
+ - **Never reads your login** — the only keychain call checks whether an item exists; the contents of keychain items and `.credentials.json` are never opened. `migrate-default` and `restore` move whole account directories, and on Linux the credentials file moves with its directory without being opened.
41
+ - **No network** — multi-claude itself makes no network connections: no telemetry, no usage reporting, no update checks. Only the installer, pipx or Homebrew download the package.
42
+ - **Leaves your shell alone** — when `PATH` needs a change, it prints the line to add instead of editing `~/.zshrc` or `~/.bashrc`.
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`).
38
44
  - **Migrate the default account** — move your existing `~/.claude` into the account root without logging out. `claude` keeps working exactly as before.
39
45
  - **Add accounts** — create an account directory and a `claude-<name>` launcher, or adopt a directory you already have.
40
46
  - **Per-account proxy** — a local port, an HTTP(S) proxy URL, `off`, or `inherit`.
@@ -42,7 +48,6 @@ The launchers are plain shell scripts. They keep working even if you uninstall m
42
48
  - **One-step deployment** — `install.sh --config accounts.json` installs the tool and creates every account in the file.
43
49
  - **Idempotent** — every command can be re-run safely. Write commands print only what changes (`--verbose` lists everything); conflicts with files multi-claude does not own are reported without changing anything; an interrupted migration resumes where it stopped.
44
50
  - **Optional shared resources** — link `CLAUDE.md`, `skills`, `agents` and `commands` from one shared directory into selected accounts.
45
- - **Hands off your credentials** — multi-claude never reads, copies or deletes logins. Its only keychain call checks whether an item exists.
46
51
 
47
52
  ## Requirements
48
53
 
@@ -218,6 +223,23 @@ The command becomes `<path of multi-claude> statusline-hook '<original command>'
218
223
  - If several settings files define `statusLine`, wrap the one that wins (for example a file passed with `--settings` in the launcher arguments).
219
224
  - Run `multi-claude statusline uninstall FILE` before uninstalling multi-claude; otherwise the status line stays empty because the wrapped command cannot be found. Running `install` again after multi-claude moves updates the path.
220
225
 
226
+ #### Token history
227
+
228
+ ```sh
229
+ multi-claude usage --history # tokens per day, last 7 days, every account
230
+ multi-claude usage --history work --days 30 --by model
231
+ ```
232
+
233
+ ```text
234
+ work:
235
+ DATE INPUT OUTPUT CACHE READ CACHE WRITE REPLIES
236
+ 2026-10-01 1,246 499,681 273,254,361 5,926,527 612
237
+ ```
238
+
239
+ `--history` counts the replies in the account's session files on this machine (`projects/**/*.jsonl`, including subagents): input, output, cache-read and cache-write tokens per day (local time) or per model. Only these counters are read; nothing else from the conversations is printed or stored, and nothing is sent anywhere. Sessions on other machines and deleted sessions are not counted. A session copied with `handoff` appears in both accounts; the `TOTAL` block counts each reply once. `--json` prints the same numbers for scripts.
240
+
241
+ `list` shows when each account was last used (`LAST USED`), from the newest session file or prompt history in its directory; a `handoff` into an account counts as use.
242
+
221
243
  ### Handing a session over to another account
222
244
 
223
245
  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:
@@ -336,6 +358,8 @@ With pipx, run `pipx upgrade multi-claude-cli`; with Homebrew, `brew upgrade mul
336
358
 
337
359
  The one in `~/.claude`, as before: multi-claude does not change it. After [migrating `~/.claude`](#migrating-claude) it is the migrated account. `claude-auto` picks an account by directory instead; see [Choosing an account by directory](#choosing-an-account-by-directory).
338
360
 
361
+ 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`.
362
+
339
363
  ### How do I continue a conversation with another account?
340
364
 
341
365
  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).
@@ -350,7 +374,7 @@ By default the numbers come from the cache that Claude Code writes in `.claude.j
350
374
 
351
375
  ### Does multi-claude read or copy my login?
352
376
 
353
- No. It never reads, copies or deletes credentials; its only keychain call checks whether an item exists. On macOS a login is tied to the account directory's path, which is why `rename` keeps the directory.
377
+ It never reads it: its only keychain call checks whether an item exists, and it never opens `.credentials.json`. It does not export, upload or back up logins. `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.
354
378
 
355
379
  ## Reference
356
380
 
@@ -370,6 +394,7 @@ No. It never reads, copies or deletes credentials; its only keychain call checks
370
394
  | `multi-claude apply [-f FILE]` | Converge everything to the configuration (or to `FILE`). |
371
395
  | `multi-claude list [--verbose \| --json \| --names]` | Show accounts, launchers and login state. `--json` adds usage and is meant for scripts; `--names` prints only the names. |
372
396
  | `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
397
+ | `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). |
373
398
  | `multi-claude doctor [--json] [--verbose]` | Check the configuration, launchers, account directories, shared links, logins and environment. |
374
399
  | `multi-claude route DIR NAME`, `route DIR --remove`, `route --default NAME`, `route --no-default` | Choose an account by directory for `claude-auto`. |
375
400
  | `multi-claude which [DIR]` | Show which account `claude-auto` would use in `DIR` (default: the current directory). |
@@ -1,3 +1,3 @@
1
1
  """multi-claude:管理多个 Claude Code 账号的配置目录、启动命令、代理与启动参数。"""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
@@ -18,8 +18,8 @@ import sys
18
18
  from datetime import datetime, timedelta, timezone
19
19
  from typing import List, Optional, Tuple
20
20
 
21
- from . import (__version__, accounts, completion, doctor, hook, identity, launcher, migrate, platform, restore,
22
- routes, sessions, shellpath, statusline, usage)
21
+ from . import (__version__, accounts, completion, doctor, history, hook, identity, launcher, migrate, platform,
22
+ restore, routes, sessions, shellpath, statusline, usage)
23
23
  from .actions import 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,
@@ -176,6 +176,10 @@ def build_parser() -> argparse.ArgumentParser:
176
176
  "(from Claude Code's own cache; never reads credentials)")
177
177
  p_usage.add_argument("name", nargs="?")
178
178
  p_usage.add_argument("--json", action="store_true", help="print machine-readable JSON")
179
+ p_usage.add_argument("--history", action="store_true",
180
+ help="show token usage counted from the session files on this machine instead")
181
+ p_usage.add_argument("--days", type=int, metavar="N", help="with --history: the last N days (default 7)")
182
+ p_usage.add_argument("--by", choices=("day", "model"), help="with --history: group by day (default) or model")
179
183
 
180
184
  p_doctor = sub.add_parser("doctor", description="check the accounts, launchers and environment (read-only)")
181
185
  p_doctor.add_argument("--json", action="store_true", help="print machine-readable JSON")
@@ -381,6 +385,10 @@ def main(argv: Optional[List[str]] = None) -> int:
381
385
  if args.command == "list":
382
386
  return cmd_list(verbose=args.verbose, as_json=args.json, names_only=args.names)
383
387
  if args.command == "usage":
388
+ if args.history:
389
+ return cmd_usage_history(args.name, args.days, args.by or "day", args.json)
390
+ if args.days is not None or args.by is not None:
391
+ raise UsageError("--days and --by need --history")
384
392
  return cmd_usage(args.name, args.json)
385
393
  if args.command == "which":
386
394
  return cmd_which(args.path)
@@ -797,7 +805,7 @@ def _account_process_env(name: str) -> Tuple[Optional[dict], int]:
797
805
  if process_env is None:
798
806
  return None, code
799
807
  if shutil.which("claude", path=process_env.get("PATH")) is None:
800
- error("claude not found in PATH")
808
+ error("claude not found in PATH" + _CLAUDE_NOT_FOUND_HINT)
801
809
  return None, 127
802
810
  return process_env, accounts.EXIT_OK
803
811
 
@@ -843,7 +851,14 @@ def cmd_run(name: Optional[str], command: List[str]) -> int:
843
851
  if not command:
844
852
  # 固定参数按启动命令的规则展开 `~`(launcher.render),两种启动方式收到的参数才逐项相同。
845
853
  command = ["claude"] + [launcher.expand_value(arg) for arg in config.effective_args(account)]
846
- return _exec_as(config, account, command)
854
+ return _exec_as(config, account, command,
855
+ not_found_hint=_CLAUDE_NOT_FOUND_HINT if command[0] == "claude" else "")
856
+
857
+
858
+ # 找不到 claude 时附上的官方安装方法(https://code.claude.com/docs/en/setup,2026-10-03 核对)。
859
+ # 只用于 multi-claude 自己的报错:启动命令里的文字不改,改了所有启动命令都会变成过期。
860
+ _CLAUDE_NOT_FOUND_HINT = ("; install Claude Code: curl -fsSL https://claude.ai/install.sh | bash "
861
+ "(other ways: https://code.claude.com/docs/en/setup)")
847
862
 
848
863
 
849
864
  def _exec_as(config: Config, account: Account, command: List[str], not_found_hint: str = "") -> int:
@@ -1285,14 +1300,15 @@ def _print_brief_list(config: Config) -> int:
1285
1300
  # 与完整输出相同:LOGIN 与由它得出的 STATUS 都按正式服务探测,这时可能不准。
1286
1301
  warn("CLAUDE_CODE_CUSTOM_OAUTH_URL is set; LOGIN assumes the production service and may be wrong")
1287
1302
  now = datetime.now(timezone.utc)
1288
- rows = [("NAME", "LOGIN", "PROXY", "SHARED", "5H", "7D", "STATUS")]
1303
+ rows = [("NAME", "LOGIN", "PROXY", "SHARED", "5H", "7D", "LAST USED", "STATUS")]
1289
1304
  has_problem = False
1290
1305
  for entry in _account_entries(config):
1291
1306
  report = usage.read_account_usage(config, config.find(entry["name"]), now)
1292
1307
  status = _status_cell(entry)
1293
1308
  has_problem = has_problem or status != "ok"
1294
1309
  rows.append((entry["name"], entry["login"], entry["proxy"], _shared_cell(entry),
1295
- _window_cell(report.five_hour, now), _window_cell(report.seven_day, now), status))
1310
+ _window_cell(report.five_hour, now), _window_cell(report.seven_day, now),
1311
+ _last_used_cell(entry["last_used"], now), status))
1296
1312
  _print_table(rows)
1297
1313
  _print_routes(config)
1298
1314
  if has_problem:
@@ -1359,6 +1375,8 @@ def _account_entries(config: Config, with_usage: bool = False) -> List[dict]:
1359
1375
  "link": accounts.default_link_status(config, account) if is_default else None,
1360
1376
  "keychain_service": identity.service_name(account.identity, directory),
1361
1377
  "credentials_file": identity.credentials_file(directory),
1378
+ # history.jsonl 与会话文件的最大 mtime;只 stat 不读内容(方案 feature-usage-history §5.1.3)
1379
+ "last_used": _iso(history.last_used(directory)),
1362
1380
  }
1363
1381
  if with_usage:
1364
1382
  entry["usage"] = usage.report_to_dict(usage.read_account_usage(config, account, now), now)
@@ -1453,6 +1471,86 @@ def _updated_cell(name: str, report: usage.UsageReport, now: datetime) -> str:
1453
1471
  return age + (" (statusline)" if report.source == usage.SOURCE_STATUSLINE else "")
1454
1472
 
1455
1473
 
1474
+ def _iso(moment: Optional[datetime]) -> Optional[str]:
1475
+ return None if moment is None else moment.strftime("%Y-%m-%dT%H:%M:%SZ")
1476
+
1477
+
1478
+ def _last_used_cell(value: Optional[str], now: datetime) -> str:
1479
+ if value is None:
1480
+ return "-"
1481
+ moment = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
1482
+ return _format_age(now - moment)
1483
+
1484
+
1485
+ _HISTORY_COLUMNS = ("INPUT", "OUTPUT", "CACHE READ", "CACHE WRITE", "REPLIES")
1486
+
1487
+
1488
+ def cmd_usage_history(name: Optional[str], days: Optional[int], by: str, as_json: bool) -> int:
1489
+ """按天或按模型汇总本机会话记录里的 token 用量(方案 feature-usage-history §5.1.2)。
1490
+
1491
+ 只读计数字段,不输出对话内容、不联网、不读凭据。各账号分别计数;TOTAL 跨账号按回复 ID 去重,
1492
+ 因为 handoff 复制过的会话在两个账号里都有同样的回复。
1493
+ """
1494
+ days = 7 if days is None else days
1495
+ if not 1 <= days <= 366:
1496
+ raise UsageError("--days must be between 1 and 366")
1497
+ config, exists = load_config()
1498
+ selected = list(config.accounts.values()) if exists else []
1499
+ if name is not None:
1500
+ account = config.find(_checked_name(name)) if exists else None
1501
+ if account is None:
1502
+ error("account {!r} is not registered".format(name))
1503
+ return accounts.EXIT_ERROR
1504
+ selected = [account]
1505
+ local_now = datetime.now().astimezone()
1506
+ start_local = (local_now - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
1507
+ since = start_local.astimezone(timezone.utc)
1508
+ per_account = []
1509
+ combined: dict = {}
1510
+ skipped = 0
1511
+ for account in selected:
1512
+ replies, unreadable = history.scan(accounts.account_dir(config, account.name), since)
1513
+ skipped += unreadable
1514
+ per_account.append((account.name, history.summarize(replies.values(), by)))
1515
+ for reply_id, reply in replies.items():
1516
+ # 同一回复出现在多个账号(handoff)时只算一次,计数仍按各项取最大值合并
1517
+ combined[reply_id] = reply if reply_id not in combined else history.merge(combined[reply_id], reply)
1518
+ if skipped:
1519
+ warn("skipped {} session file(s) that could not be read".format(skipped))
1520
+ total = history.summarize(combined.values(), by)
1521
+ if as_json:
1522
+ _print_json({"schema_version": 1, "days": days, "by": by,
1523
+ "accounts": [{"name": account_name, "rows": [row._asdict() for row in rows]}
1524
+ for account_name, rows in per_account],
1525
+ "total": [row._asdict() for row in total]})
1526
+ return accounts.EXIT_OK
1527
+ if not exists:
1528
+ info("no configuration yet at {}; run `multi-claude add NAME` to start".format(
1529
+ os.path.join(platform.state_dir(), "config.json")))
1530
+ return accounts.EXIT_OK
1531
+ for account_name, rows in per_account:
1532
+ _print_history_block(account_name, rows, by, days)
1533
+ if len(per_account) > 1:
1534
+ _print_history_block("TOTAL (each reply counted once)", total, by, days)
1535
+ print("Counted from the session files on this machine (not other machines, not deleted sessions); "
1536
+ "multi-claude reads only the token counts, never connects to the network and never reads credentials.")
1537
+ return accounts.EXIT_OK
1538
+
1539
+
1540
+ def _print_history_block(title: str, rows, by: str, days: int) -> None:
1541
+ print("{}:".format(title))
1542
+ if not rows:
1543
+ print(" no replies in the last {} day{}".format(days, "" if days == 1 else "s"))
1544
+ return
1545
+ table = [(" " + ("DATE" if by == "day" else "MODEL"),) + _HISTORY_COLUMNS]
1546
+ for row in rows:
1547
+ table.append((" " + row.key,) + tuple("{:,}".format(value) for value in row[1:]))
1548
+ widths = [max(len(line[index]) for line in table) for index in range(len(table[0]))]
1549
+ for line in table:
1550
+ cells = [line[0].ljust(widths[0])] + [cell.rjust(width) for cell, width in zip(line[1:], widths[1:])]
1551
+ print(" ".join(cells).rstrip())
1552
+
1553
+
1456
1554
  def _format_age(delta) -> str:
1457
1555
  minutes = max(0, int(delta.total_seconds() // 60))
1458
1556
  if minutes < 60:
@@ -100,7 +100,8 @@ def _load() -> Tuple[Optional[Config], Check]:
100
100
  def _check_claude_on_path() -> Check:
101
101
  found = shutil.which("claude")
102
102
  if found is None:
103
- return Check("claude-on-path", LEVEL_ERROR, "claude", "`claude` is not on PATH; install Claude Code")
103
+ return Check("claude-on-path", LEVEL_ERROR, "claude", "`claude` is not on PATH; install Claude Code: curl -fsSL https://claude.ai/install.sh "
104
+ "| bash (other ways: https://code.claude.com/docs/en/setup)")
104
105
  return Check("claude-on-path", LEVEL_OK, "claude", found)
105
106
 
106
107
 
@@ -0,0 +1,178 @@
1
+ """本地 token 用量历史与最近使用时间(方案 feature-usage-history §5.1.1、§5.1.3)。
2
+
3
+ 数据来源是 Claude Code 自己写在账号目录里的会话记录 `projects/**/*.jsonl`:每条 assistant 行带
4
+ `message.id`、`message.model` 与 `message.usage`。本模块只读这几个计数字段,不输出、不保存
5
+ 任何对话内容,不联网,不读凭据。记录格式未写入官方文档,任何一行解析失败都跳过,不报错。
6
+
7
+ 同一条回复会被拆成多行写入(每个内容块一行,`usage` 随之重复或逐步增大),所以按 `message.id`
8
+ 合并,每项计数取最大值;直接累加会把同一条回复重复计算好几遍。
9
+ """
10
+
11
+ import json
12
+ import os
13
+ from datetime import date, datetime, timedelta, timezone, tzinfo
14
+ from typing import Dict, Iterable, List, NamedTuple, Optional, Tuple
15
+
16
+ SYNTHETIC_MODEL = "<synthetic>"
17
+ # 缺少模型名的回复仍计入,按这个名字归组,计数不丢
18
+ UNKNOWN_MODEL = "unknown"
19
+ # mtime 过滤的余量:只有 mtime 被人为回拨时才会漏数据,多读一天的文件换更稳妥的结果
20
+ _MTIME_MARGIN = timedelta(days=1)
21
+ _COUNTERS = (("input", "input_tokens"), ("output", "output_tokens"),
22
+ ("cache_read", "cache_read_input_tokens"), ("cache_write", "cache_creation_input_tokens"))
23
+
24
+
25
+ class Reply(NamedTuple):
26
+ timestamp: datetime # UTC
27
+ model: str
28
+ input: int
29
+ output: int
30
+ cache_read: int
31
+ cache_write: int
32
+
33
+
34
+ class Row(NamedTuple):
35
+ key: str # 本地日期(YYYY-MM-DD)或模型名
36
+ input: int
37
+ output: int
38
+ cache_read: int
39
+ cache_write: int
40
+ replies: int
41
+
42
+
43
+ def _session_files(account_dir: str) -> Iterable[str]:
44
+ root = os.path.join(account_dir, "projects")
45
+ # os.walk 默认忽略读不了的目录(onerror=None);projects/ 不存在时什么也不产出,视为没有数据
46
+ for directory, _dirs, files in os.walk(root):
47
+ for name in files:
48
+ if name.endswith(".jsonl"):
49
+ yield os.path.join(directory, name)
50
+
51
+
52
+ def _parse_time(value) -> Optional[datetime]:
53
+ if not isinstance(value, str):
54
+ return None
55
+ try:
56
+ # Python 3.8 的 fromisoformat 不认末尾的 Z,先换成 +00:00
57
+ parsed = datetime.fromisoformat(value[:-1] + "+00:00" if value.endswith("Z") else value)
58
+ except ValueError:
59
+ return None
60
+ if parsed.tzinfo is None:
61
+ return None
62
+ return parsed.astimezone(timezone.utc)
63
+
64
+
65
+ def _count(usage: dict, key: str) -> int:
66
+ value = usage.get(key)
67
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
68
+ return 0
69
+ return value
70
+
71
+
72
+ def scan(account_dir: str, since: datetime) -> Tuple[Dict[str, Reply], int]:
73
+ """返回 (since 之后的回复按 message.id 合并的结果, 读不了而跳过的文件数);since 必须带时区。
74
+
75
+ 先按 ID 合并(时间取最早、计数取最大),再按最早时间判断是否在窗口内:一条回复的多行可能跨过起点,
76
+ 按行过滤会把它算进窗口。mtime 早于 since 减一天的文件整个跳过:文件 mtime 不早于其中任何一行的时间。
77
+ 文件在扫描中被删除或没有读权限时跳过并计数,命令照常给出其余结果。
78
+ """
79
+ cutoff = (since - _MTIME_MARGIN).timestamp()
80
+ merged: Dict[str, Reply] = {}
81
+ skipped = 0
82
+ for path in _session_files(account_dir):
83
+ try:
84
+ if os.stat(path).st_mtime < cutoff:
85
+ continue
86
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
87
+ for line in handle:
88
+ if '"usage"' not in line:
89
+ continue
90
+ reply_id, reply = _parse_line(line)
91
+ if reply_id is None:
92
+ continue
93
+ previous = merged.get(reply_id)
94
+ merged[reply_id] = reply if previous is None else merge(previous, reply)
95
+ except OSError:
96
+ skipped += 1
97
+ return {reply_id: reply for reply_id, reply in merged.items() if reply.timestamp >= since}, skipped
98
+
99
+
100
+ def _parse_line(line: str):
101
+ try:
102
+ data = json.loads(line)
103
+ except ValueError:
104
+ return None, None
105
+ if not isinstance(data, dict) or data.get("type") != "assistant":
106
+ return None, None
107
+ message = data.get("message")
108
+ if not isinstance(message, dict):
109
+ return None, None
110
+ reply_id = message.get("id")
111
+ usage = message.get("usage")
112
+ if not isinstance(reply_id, str) or not reply_id or not isinstance(usage, dict):
113
+ return None, None
114
+ model = message.get("model")
115
+ if model == SYNTHETIC_MODEL:
116
+ # Claude Code 自己插入的占位回复,计数为 0,不算一次回复
117
+ return None, None
118
+ if not isinstance(model, str) or not model:
119
+ model = UNKNOWN_MODEL
120
+ timestamp = _parse_time(data.get("timestamp"))
121
+ if timestamp is None:
122
+ return None, None
123
+ # usage.iterations 等其它字段与顶层计数的关系未核实,不使用
124
+ return reply_id, Reply(timestamp, model, *(_count(usage, key) for _name, key in _COUNTERS))
125
+
126
+
127
+ def merge(a: Reply, b: Reply) -> Reply:
128
+ return Reply(min(a.timestamp, b.timestamp), a.model, max(a.input, b.input), max(a.output, b.output),
129
+ max(a.cache_read, b.cache_read), max(a.cache_write, b.cache_write))
130
+
131
+
132
+ def summarize(replies: Iterable[Reply], by: str, tz: Optional[tzinfo] = None) -> List[Row]:
133
+ """按本地日期(by="day")或模型(by="model")汇总;日期升序,模型按输出 token 降序。"""
134
+ totals: Dict[str, List[int]] = {}
135
+ for reply in replies:
136
+ key = _local_date(reply.timestamp, tz).isoformat() if by == "day" else reply.model
137
+ bucket = totals.setdefault(key, [0, 0, 0, 0, 0])
138
+ bucket[0] += reply.input
139
+ bucket[1] += reply.output
140
+ bucket[2] += reply.cache_read
141
+ bucket[3] += reply.cache_write
142
+ bucket[4] += 1
143
+ rows = [Row(key, *values) for key, values in totals.items()]
144
+ if by == "day":
145
+ return sorted(rows, key=lambda row: row.key)
146
+ return sorted(rows, key=lambda row: (-row.output, row.key))
147
+
148
+
149
+ def _local_date(moment: datetime, tz: Optional[tzinfo]) -> date:
150
+ # 逐条换算:tz 为 None 时 astimezone() 用该时刻本机的偏移,跨夏令时也分对天
151
+ return moment.astimezone(tz).date()
152
+
153
+
154
+ def last_used(account_dir: str) -> Optional[datetime]:
155
+ """账号最近一次使用的时间:history.jsonl 与第一层会话文件的最大 mtime(UTC);都没有时为 None。
156
+
157
+ 只 stat 不读内容;不进 subagents/(子代理文件只会和主会话一起更新)。目录 mtime 只在增删条目时变化,不可靠。
158
+ """
159
+ candidates = [os.path.join(account_dir, "history.jsonl")]
160
+ projects = os.path.join(account_dir, "projects")
161
+ try:
162
+ project_dirs = os.listdir(projects)
163
+ except OSError:
164
+ project_dirs = []
165
+ for project in project_dirs:
166
+ try:
167
+ names = os.listdir(os.path.join(projects, project))
168
+ except OSError:
169
+ continue
170
+ candidates.extend(os.path.join(projects, project, name) for name in names if name.endswith(".jsonl"))
171
+ latest = None
172
+ for path in candidates:
173
+ try:
174
+ mtime = os.stat(path).st_mtime
175
+ except OSError:
176
+ continue
177
+ latest = mtime if latest is None else max(latest, mtime)
178
+ return None if latest is None else datetime.fromtimestamp(latest, timezone.utc)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multi-claude-cli
3
- Version: 0.6.0
3
+ Version: 0.7.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,6 +37,8 @@ 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 reads your login, never connects to the network and never edits your shell startup files.
41
+
40
42
  ```sh
41
43
  claude-work # Claude Code with your work account
42
44
  claude-personal # Claude Code with your personal account, in another terminal
@@ -62,6 +64,10 @@ The launchers are plain shell scripts. They keep working even if you uninstall m
62
64
 
63
65
  ## Features
64
66
 
67
+ - **Never reads your login** — the only keychain call checks whether an item exists; the contents of keychain items and `.credentials.json` are never opened. `migrate-default` and `restore` move whole account directories, and on Linux the credentials file moves with its directory without being opened.
68
+ - **No network** — multi-claude itself makes no network connections: no telemetry, no usage reporting, no update checks. Only the installer, pipx or Homebrew download the package.
69
+ - **Leaves your shell alone** — when `PATH` needs a change, it prints the line to add instead of editing `~/.zshrc` or `~/.bashrc`.
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`).
65
71
  - **Migrate the default account** — move your existing `~/.claude` into the account root without logging out. `claude` keeps working exactly as before.
66
72
  - **Add accounts** — create an account directory and a `claude-<name>` launcher, or adopt a directory you already have.
67
73
  - **Per-account proxy** — a local port, an HTTP(S) proxy URL, `off`, or `inherit`.
@@ -69,7 +75,6 @@ The launchers are plain shell scripts. They keep working even if you uninstall m
69
75
  - **One-step deployment** — `install.sh --config accounts.json` installs the tool and creates every account in the file.
70
76
  - **Idempotent** — every command can be re-run safely. Write commands print only what changes (`--verbose` lists everything); conflicts with files multi-claude does not own are reported without changing anything; an interrupted migration resumes where it stopped.
71
77
  - **Optional shared resources** — link `CLAUDE.md`, `skills`, `agents` and `commands` from one shared directory into selected accounts.
72
- - **Hands off your credentials** — multi-claude never reads, copies or deletes logins. Its only keychain call checks whether an item exists.
73
78
 
74
79
  ## Requirements
75
80
 
@@ -245,6 +250,23 @@ The command becomes `<path of multi-claude> statusline-hook '<original command>'
245
250
  - If several settings files define `statusLine`, wrap the one that wins (for example a file passed with `--settings` in the launcher arguments).
246
251
  - Run `multi-claude statusline uninstall FILE` before uninstalling multi-claude; otherwise the status line stays empty because the wrapped command cannot be found. Running `install` again after multi-claude moves updates the path.
247
252
 
253
+ #### Token history
254
+
255
+ ```sh
256
+ multi-claude usage --history # tokens per day, last 7 days, every account
257
+ multi-claude usage --history work --days 30 --by model
258
+ ```
259
+
260
+ ```text
261
+ work:
262
+ DATE INPUT OUTPUT CACHE READ CACHE WRITE REPLIES
263
+ 2026-10-01 1,246 499,681 273,254,361 5,926,527 612
264
+ ```
265
+
266
+ `--history` counts the replies in the account's session files on this machine (`projects/**/*.jsonl`, including subagents): input, output, cache-read and cache-write tokens per day (local time) or per model. Only these counters are read; nothing else from the conversations is printed or stored, and nothing is sent anywhere. Sessions on other machines and deleted sessions are not counted. A session copied with `handoff` appears in both accounts; the `TOTAL` block counts each reply once. `--json` prints the same numbers for scripts.
267
+
268
+ `list` shows when each account was last used (`LAST USED`), from the newest session file or prompt history in its directory; a `handoff` into an account counts as use.
269
+
248
270
  ### Handing a session over to another account
249
271
 
250
272
  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:
@@ -363,6 +385,8 @@ With pipx, run `pipx upgrade multi-claude-cli`; with Homebrew, `brew upgrade mul
363
385
 
364
386
  The one in `~/.claude`, as before: multi-claude does not change it. After [migrating `~/.claude`](#migrating-claude) it is the migrated account. `claude-auto` picks an account by directory instead; see [Choosing an account by directory](#choosing-an-account-by-directory).
365
387
 
388
+ 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`.
389
+
366
390
  ### How do I continue a conversation with another account?
367
391
 
368
392
  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).
@@ -377,7 +401,7 @@ By default the numbers come from the cache that Claude Code writes in `.claude.j
377
401
 
378
402
  ### Does multi-claude read or copy my login?
379
403
 
380
- No. It never reads, copies or deletes credentials; its only keychain call checks whether an item exists. On macOS a login is tied to the account directory's path, which is why `rename` keeps the directory.
404
+ It never reads it: its only keychain call checks whether an item exists, and it never opens `.credentials.json`. It does not export, upload or back up logins. `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.
381
405
 
382
406
  ## Reference
383
407
 
@@ -397,6 +421,7 @@ No. It never reads, copies or deletes credentials; its only keychain call checks
397
421
  | `multi-claude apply [-f FILE]` | Converge everything to the configuration (or to `FILE`). |
398
422
  | `multi-claude list [--verbose \| --json \| --names]` | Show accounts, launchers and login state. `--json` adds usage and is meant for scripts; `--names` prints only the names. |
399
423
  | `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
424
+ | `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). |
400
425
  | `multi-claude doctor [--json] [--verbose]` | Check the configuration, launchers, account directories, shared links, logins and environment. |
401
426
  | `multi-claude route DIR NAME`, `route DIR --remove`, `route --default NAME`, `route --no-default` | Choose an account by directory for `claude-auto`. |
402
427
  | `multi-claude which [DIR]` | Show which account `claude-auto` would use in `DIR` (default: the current directory). |
@@ -11,6 +11,7 @@ src/multi_claude/config.py
11
11
  src/multi_claude/doctor.py
12
12
  src/multi_claude/env.py
13
13
  src/multi_claude/fsutil.py
14
+ src/multi_claude/history.py
14
15
  src/multi_claude/hook.py
15
16
  src/multi_claude/identity.py
16
17
  src/multi_claude/launcher.py
@@ -37,6 +38,7 @@ tests/test_everyday.py
37
38
  tests/test_fsutil.py
38
39
  tests/test_handoff.py
39
40
  tests/test_help.py
41
+ tests/test_history.py
40
42
  tests/test_identity.py
41
43
  tests/test_install.py
42
44
  tests/test_mcp.py
@@ -43,9 +43,9 @@ class BriefListTest(CliTestCase):
43
43
  self.ok("add", "work", "--proxy", "7901")
44
44
  self.write_cache("work", 23)
45
45
  out = self.ok("list").out
46
- self.assertRegex(out.splitlines()[0], r"^NAME\s+LOGIN\s+PROXY\s+SHARED\s+5H\s+7D\s+STATUS$")
46
+ self.assertRegex(out.splitlines()[0], r"^NAME\s+LOGIN\s+PROXY\s+SHARED\s+5H\s+7D\s+LAST USED\s+STATUS$")
47
47
  self.assertNotIn("root:", out)
48
- self.assertRegex(out, r"work\s+none\s+http://127\.0\.0\.1:7901\s+no\s+23% \d\d:\d\d\s+-\s+not logged in")
48
+ self.assertRegex(out, r"work\s+none\s+http://127\.0\.0\.1:7901\s+no\s+23% \d\d:\d\d\s+-\s+-\s+not logged in")
49
49
  self.assertIn("run multi-claude doctor for details", out)
50
50
 
51
51
  def test_t4_status_problems(self):
@@ -0,0 +1,194 @@
1
+ """v0.7.0:usage --history、list 的 LAST USED、找不到 claude 时的安装提示
2
+ (方案 docs/feature/feature-usage-history.md §8)。全部使用合成的会话记录。"""
3
+
4
+ import json
5
+ import os
6
+ import time
7
+ import unittest
8
+ from datetime import datetime, timedelta, timezone
9
+
10
+ from helpers import CliTestCase
11
+
12
+
13
+ def _line(reply_id, model, when, input_tokens=0, output_tokens=0, cache_read=0, cache_write=0,
14
+ kind="assistant"):
15
+ return json.dumps({"type": kind, "timestamp": when.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
16
+ "message": {"id": reply_id, "model": model,
17
+ "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens,
18
+ "cache_read_input_tokens": cache_read,
19
+ "cache_creation_input_tokens": cache_write}}}) + "\n"
20
+
21
+
22
+ class HistoryBase(CliTestCase):
23
+ def setUp(self):
24
+ super().setUp()
25
+ self.ok("add", "work")
26
+ self.ok("add", "home")
27
+ # 本地今天中午:任何时区下都落在“今天”
28
+ self.today = datetime.now().astimezone().replace(hour=12, minute=0, second=0, microsecond=0)
29
+ self.yesterday = self.today - timedelta(days=1)
30
+
31
+ def session(self, account, name, lines, sub=None):
32
+ parts = [self.root, account, "projects", "-tmp-proj"] + ([name, "subagents"] if sub else [])
33
+ path = os.path.join(*parts, (sub or name) + ".jsonl")
34
+ os.makedirs(os.path.dirname(path), exist_ok=True)
35
+ with open(path, "a") as handle:
36
+ handle.writelines(lines)
37
+ return path
38
+
39
+ def history_json(self, *args):
40
+ return json.loads(self.ok("usage", "--history", "--json", *args).out)
41
+
42
+
43
+ class UsageHistoryTest(HistoryBase):
44
+ def setUp(self):
45
+ super().setUp()
46
+ t, y = self.today.astimezone(timezone.utc), self.yesterday.astimezone(timezone.utc)
47
+ self.session("work", "s1", [
48
+ _line("m1", "opus", y, 10, 100, 1000, 50),
49
+ _line("m1", "opus", y, 10, 120, 1000, 50), # 同一回复的后续内容块:取最大值,不累加
50
+ _line("m2", "sonnet", t, 1, 20, 300, 0),
51
+ "not json\n",
52
+ _line("u1", "opus", t, 99, 99, 99, 99, kind="user"),
53
+ _line("syn", "<synthetic>", t, 5, 5, 5, 5),
54
+ ])
55
+ self.session("work", "s1", [_line("a1", "haiku", t, 2, 3, 4, 5)], sub="agent-x")
56
+ self.session("home", "s9", [_line("m2", "sonnet", t, 1, 20, 300, 0), _line("h1", "opus", t, 7, 70, 0, 0)])
57
+
58
+ def test_t1_t4_t5_by_day_and_total(self):
59
+ data = self.history_json()
60
+ work = {row["key"]: row for row in data["accounts"][0]["rows"]}
61
+ self.assertEqual(data["accounts"][0]["name"], "work")
62
+ day_y, day_t = self.yesterday.date().isoformat(), self.today.date().isoformat()
63
+ self.assertEqual((work[day_y]["output"], work[day_y]["replies"]), (120, 1))
64
+ self.assertEqual((work[day_t]["input"], work[day_t]["output"], work[day_t]["replies"]), (3, 23, 2))
65
+ total = {row["key"]: row for row in data["total"]}
66
+ # m2 在两个账号里都有(模拟 handoff),TOTAL 只算一次
67
+ self.assertEqual((total[day_t]["output"], total[day_t]["replies"]), (93, 3))
68
+ self.assertEqual(data["days"], 7)
69
+
70
+ def test_t2_by_model(self):
71
+ data = self.history_json("--by", "model")
72
+ keys = [row["key"] for row in data["accounts"][0]["rows"]]
73
+ self.assertEqual(keys, ["opus", "sonnet", "haiku"])
74
+ self.assertNotIn("<synthetic>", keys)
75
+
76
+ def test_t3_days_window_and_old_files(self):
77
+ old = self.session("work", "old", [_line("o1", "opus", self.today.astimezone(timezone.utc), 1, 1, 1, 1)])
78
+ long_ago = time.time() - 30 * 86400
79
+ os.utime(old, (long_ago, long_ago))
80
+ data = self.history_json("--days", "1", "work")
81
+ rows = data["accounts"][0]["rows"]
82
+ self.assertEqual([row["key"] for row in rows], [self.today.date().isoformat()])
83
+ self.assertEqual(rows[0]["replies"], 2) # o1 所在文件 mtime 太旧被跳过;昨天的 m1 不在窗口内
84
+
85
+ def test_text_output(self):
86
+ out = self.ok("usage", "--history").out
87
+ self.assertIn("work:", out)
88
+ self.assertIn("TOTAL (each reply counted once):", out)
89
+ self.assertIn("never reads credentials", out)
90
+
91
+
92
+ class HistoryErrorsTest(HistoryBase):
93
+ def test_t6_bad_arguments(self):
94
+ for args in (("--history", "--days", "0"), ("--history", "--days", "400"), ("--history", "--by", "x"),
95
+ ("--days", "3"), ("--by", "model")):
96
+ with self.subTest(args=args):
97
+ self.assertEqual(self.run_cli("usage", *args).code, 2)
98
+ self.assertEqual(self.run_cli("usage", "--history", "nosuch").code, 1)
99
+
100
+ def test_t7_no_replies(self):
101
+ out = self.ok("usage", "--history", "home").out
102
+ self.assertIn("no replies in the last 7 days", out)
103
+
104
+
105
+ class HistoryEdgeTest(HistoryBase):
106
+ def test_unreadable_file_is_skipped_with_warning(self):
107
+ t = self.today.astimezone(timezone.utc)
108
+ self.session("work", "ok", [_line("r1", "opus", t, 1, 2)])
109
+ bad = self.session("work", "bad", [_line("r2", "opus", t, 1, 2)])
110
+ os.chmod(bad, 0)
111
+ self.addCleanup(os.chmod, bad, 0o644)
112
+ if os.access(bad, os.R_OK):
113
+ self.skipTest("running as a user who can read any file")
114
+ result = self.ok("usage", "--history", "work", "--json")
115
+ self.assertIn("skipped 1 session file", result.err)
116
+ self.assertEqual(json.loads(result.out)["accounts"][0]["rows"][0]["replies"], 1)
117
+
118
+ def test_reply_straddling_the_start_is_left_out(self):
119
+ start = self.today.replace(hour=0)
120
+ before, after = (start - timedelta(minutes=1)).astimezone(timezone.utc), (start + timedelta(minutes=1)).astimezone(timezone.utc)
121
+ self.session("work", "s", [_line("x", "opus", before, 1, 5), _line("x", "opus", after, 1, 9)])
122
+ rows = self.history_json("--days", "1", "work")["accounts"][0]["rows"]
123
+ self.assertEqual(rows, [])
124
+
125
+ def test_missing_model_counts_as_unknown(self):
126
+ line = json.loads(_line("q", "opus", self.today.astimezone(timezone.utc), 1, 4))
127
+ del line["message"]["model"]
128
+ self.session("work", "s", [json.dumps(line) + "\n"])
129
+ rows = self.history_json("--by", "model", "work")["accounts"][0]["rows"]
130
+ self.assertEqual([(row["key"], row["output"]) for row in rows], [("unknown", 4)])
131
+
132
+ def test_local_time_zone_decides_the_day(self):
133
+ # 上海(UTC+8):UTC 15:30 是当天 23:30,UTC 16:30 已是第二天 00:30
134
+ day = datetime(2026, 9, 30, tzinfo=timezone.utc)
135
+ self.session("work", "s", [_line("a", "opus", day.replace(hour=15, minute=30), 1, 1),
136
+ _line("b", "opus", day.replace(hour=16, minute=30), 1, 1)])
137
+ for path in [os.path.join(self.root, "work", "projects", "-tmp-proj", "s.jsonl")]:
138
+ os.utime(path, None)
139
+ data = json.loads(self.ok("usage", "--history", "work", "--json", "--days", "366",
140
+ env={"TZ": "Asia/Shanghai"}).out)
141
+ keys = [row["key"] for row in data["accounts"][0]["rows"]]
142
+ self.assertEqual(keys, ["2026-09-30", "2026-10-01"])
143
+
144
+ def test_no_configuration_and_bad_days(self):
145
+ import shutil
146
+ shutil.rmtree(self.state)
147
+ result = self.ok("usage", "--history")
148
+ self.assertIn("no configuration yet", result.out)
149
+ self.assertEqual(json.loads(self.ok("usage", "--history", "--json").out)["accounts"], [])
150
+ self.assertEqual(self.run_cli("usage", "--history", "--days", "abc").code, 2)
151
+
152
+
153
+ class LastUsedTest(HistoryBase):
154
+ def test_verbose_list_has_no_last_used(self):
155
+ self.assertNotIn("LAST USED", self.ok("list", "--verbose").out)
156
+
157
+
158
+ def test_t8_last_used(self):
159
+ self.session("work", "s1", [_line("m1", "opus", self.today.astimezone(timezone.utc), 1, 1)])
160
+ two_hours_ago = time.time() - 2 * 3600
161
+ for path in (os.path.join(self.root, "work", "projects", "-tmp-proj", "s1.jsonl"),):
162
+ os.utime(path, (two_hours_ago, two_hours_ago))
163
+ lines = self.ok("list").out.splitlines()
164
+ self.assertIn("LAST USED", lines[0])
165
+ row = {line.split()[0]: line for line in lines[1:3]}
166
+ self.assertIn("2h ago", row["work"])
167
+ self.assertIn(" - ", row["home"])
168
+ data = json.loads(self.ok("list", "--json").out)
169
+ entries = {entry["name"]: entry for entry in data["accounts"]}
170
+ self.assertRegex(entries["work"]["last_used"], r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$")
171
+ self.assertIsNone(entries["home"]["last_used"])
172
+
173
+
174
+ class InstallHintTest(CliTestCase):
175
+ def test_t9_claude_not_found(self):
176
+ self.ok("add", "work")
177
+ env = {"PATH": self.fakebin}
178
+ os.unlink(os.path.join(self.fakebin, "claude"))
179
+ for args, code in ((("login", "work"), 127), (("mcp", "work", "list"), 127), (("run", "work"), 127),
180
+ (("doctor",), 1)):
181
+ with self.subTest(args=args):
182
+ result = self.run_cli(*args, env=env)
183
+ self.assertEqual(result.code, code, result)
184
+ self.assertIn("curl -fsSL https://claude.ai/install.sh", result.out + result.err)
185
+ # 其它命令找不到时不带 claude 的安装提示
186
+ result = self.run_cli("run", "work", "--", "no-such-cmd-xyz", env=env)
187
+ self.assertNotIn("claude.ai/install.sh", result.err)
188
+ result = self.run_cli("code", "work", env=env)
189
+ self.assertEqual(result.code, 127, result)
190
+ self.assertNotIn("claude.ai/install.sh", result.err)
191
+
192
+
193
+ if __name__ == "__main__":
194
+ unittest.main()