multi-claude-cli 0.12.1__tar.gz → 0.14.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 (69) hide show
  1. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/PKG-INFO +19 -4
  2. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/README.md +18 -3
  3. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/__init__.py +1 -1
  4. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/cli.py +307 -19
  5. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/config.py +29 -3
  6. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude_cli.egg-info/PKG-INFO +19 -4
  7. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude_cli.egg-info/SOURCES.txt +2 -0
  8. multi_claude_cli-0.14.0/tests/test_help_examples.py +224 -0
  9. multi_claude_cli-0.14.0/tests/test_renewal.py +245 -0
  10. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/LICENSE +0 -0
  11. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/pyproject.toml +0 -0
  12. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/setup.cfg +0 -0
  13. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/__main__.py +0 -0
  14. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/accounts.py +0 -0
  15. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/actions.py +0 -0
  16. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/artifacts.py +0 -0
  17. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/bundle.py +0 -0
  18. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/completion.py +0 -0
  19. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/doctor.py +0 -0
  20. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/env.py +0 -0
  21. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/fsutil.py +0 -0
  22. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/history.py +0 -0
  23. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/hook.py +0 -0
  24. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/identity.py +0 -0
  25. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/launcher.py +0 -0
  26. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/lock.py +0 -0
  27. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/migrate.py +0 -0
  28. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/plan.py +0 -0
  29. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/platform.py +0 -0
  30. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/refresh.py +0 -0
  31. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/restore.py +0 -0
  32. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/routes.py +0 -0
  33. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/sessions.py +0 -0
  34. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/shared.py +0 -0
  35. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/shellpath.py +0 -0
  36. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/statusline.py +0 -0
  37. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/usage.py +0 -0
  38. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude_cli.egg-info/dependency_links.txt +0 -0
  39. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude_cli.egg-info/entry_points.txt +0 -0
  40. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude_cli.egg-info/top_level.txt +0 -0
  41. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_accounts.py +0 -0
  42. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_artifacts.py +0 -0
  43. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_bundle.py +0 -0
  44. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_completion.py +0 -0
  45. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_config.py +0 -0
  46. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_doctor.py +0 -0
  47. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_everyday.py +0 -0
  48. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_fsutil.py +0 -0
  49. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_handoff.py +0 -0
  50. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_help.py +0 -0
  51. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_history.py +0 -0
  52. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_identity.py +0 -0
  53. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_install.py +0 -0
  54. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_mcp.py +0 -0
  55. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_migrate.py +0 -0
  56. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_onboarding.py +0 -0
  57. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_packaging.py +0 -0
  58. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_plan.py +0 -0
  59. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_platform.py +0 -0
  60. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_refresh.py +0 -0
  61. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_rename.py +0 -0
  62. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_routes.py +0 -0
  63. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_sessions.py +0 -0
  64. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_set.py +0 -0
  65. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_shared_exclude.py +0 -0
  66. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_statusline.py +0 -0
  67. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_usability.py +0 -0
  68. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_usage.py +0 -0
  69. {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.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.12.1
3
+ Version: 0.14.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,19 @@ 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
+ #### Renewal date
335
+
336
+ Anthropic's interface does not say when a subscription renews, and the date it implies is wrong for an account whose subscription was stopped and started again. Copy the date from the store's subscription page (App Store or Google Play) instead:
337
+
338
+ ```sh
339
+ multi-claude set work --renews 2026-11-10 # the date the store shows
340
+ multi-claude set work --renews off # forget it
341
+ ```
342
+
343
+ Once any account has a date, `list` adds a `RENEWS` column (also with `--offline`, since nothing is looked up): `11-10 (25d)` is 25 days away, `10-19 (3d!)` three days or less, `10-16 (today!)` today, `10-10 (6d ago)` already passed. A date in another year is written in full, `2027-01-05 (81d)`. Days are counted in your local time zone. When a date has passed — usually because the store renewed the subscription — `list` reminds you on stderr to look up the new date. Accounts without a date show `-`; without any dates the table is unchanged.
344
+
345
+ `list --json` gives each account `"renews": {"date": "2026-11-10", "days_left": 25}` (`days_left` is negative once the date has passed), or `null`. The date is stored as `renews` in `config.json`; `apply -f FILE` treats it like any other account setting, so an account whose entry in FILE has no `renews` loses its date.
346
+
334
347
  ### Finding a session
335
348
 
336
349
  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:
@@ -522,6 +535,8 @@ It never copies, exports, uploads or backs up a login. One command touches one:
522
535
 
523
536
  ### Commands
524
537
 
538
+ Every command's `--help` ends with examples you can copy, covering its options, e.g. `multi-claude route --help`.
539
+
525
540
  | Command | What it does |
526
541
  | ---- | ---- |
527
542
  | `multi-claude init [--root DIR] [--bin-dir DIR] [--shared-dir DIR] [--shared-items A,B]` | Create or change global settings. |
@@ -529,14 +544,14 @@ It never copies, exports, uploads or backs up a login. One command touches one:
529
544
  | `multi-claude restore NAME [--skip-process-check] [--dry-run]` | Undo `migrate-default`: move the account back to `~/.claude` and unregister it. The login is kept. |
530
545
  | `multi-claude export NAME FILE` | Write the account's settings, `CLAUDE.md`, agents, commands, skills, output styles and MCP servers to FILE. No logins or sessions. |
531
546
  | `multi-claude import FILE NAME [--force] [--dry-run]` | Import such a file into an existing account; conflicts are refused unless `--force` (which backs up first). |
532
- | `multi-claude add NAME [--proxy P] [--shared [DIR] \| --no-shared] [--adopt] [--shared-exclude ITEM] [--shared-include ITEM] [--config-from OTHER]` | Add an account, adopt an existing directory, or change its options. `--config-from` copies `settings.json` from OTHER once. `--shared DIR` also makes DIR the shared directory for every account (default `~/.claude-shared`). `--shared-exclude` keeps one shared item out of this account; `--shared-include` undoes it. |
533
- | `multi-claude set NAME [--proxy P] [--shared [DIR] \| --no-shared] [--shared-exclude ITEM] [--shared-include ITEM]` | Change an existing account; same options as `add` without `--adopt`. Returns 1 for an account that is not registered. |
547
+ | `multi-claude add NAME [--proxy P] [--shared [DIR] \| --no-shared] [--adopt] [--shared-exclude ITEM] [--shared-include ITEM] [--renews DATE\|off] [--config-from OTHER]` | Add an account, adopt an existing directory, or change its options. `--config-from` copies `settings.json` from OTHER once. `--renews` records the renewal date (see [Renewal date](#renewal-date)). `--shared DIR` also makes DIR the shared directory for every account (default `~/.claude-shared`). `--shared-exclude` keeps one shared item out of this account; `--shared-include` undoes it. |
548
+ | `multi-claude set NAME [--proxy P] [--shared [DIR] \| --no-shared] [--shared-exclude ITEM] [--shared-include ITEM] [--renews DATE\|off]` | Change an existing account; same options as `add` without `--adopt`. Returns 1 for an account that is not registered. |
534
549
  | `multi-claude proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
535
550
  | `multi-claude env (NAME \| --defaults) [K=V ...] [--unset K ...]` | Set or remove extra environment variables. |
536
551
  | `multi-claude args (NAME \| --defaults) -- [ARG ...]` | Replace the fixed arguments; `--` with nothing after it clears them. |
537
552
  | `multi-claude remove NAME` | Unregister an account and delete its launcher. **The directory and the login are kept.** |
538
553
  | `multi-claude apply [-f FILE]` | Converge everything to the configuration (or to `FILE`). |
539
- | `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). |
554
+ | `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). A `RENEWS` column (and a `renews` key in `--json`) shows the renewal dates you recorded. |
540
555
  | `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
541
556
  | `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). |
542
557
  | `multi-claude doctor [--json] [--verbose]` | Check the configuration, launchers, account directories, shared links, logins and environment. |
@@ -304,6 +304,19 @@ 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
+ #### Renewal date
308
+
309
+ Anthropic's interface does not say when a subscription renews, and the date it implies is wrong for an account whose subscription was stopped and started again. Copy the date from the store's subscription page (App Store or Google Play) instead:
310
+
311
+ ```sh
312
+ multi-claude set work --renews 2026-11-10 # the date the store shows
313
+ multi-claude set work --renews off # forget it
314
+ ```
315
+
316
+ Once any account has a date, `list` adds a `RENEWS` column (also with `--offline`, since nothing is looked up): `11-10 (25d)` is 25 days away, `10-19 (3d!)` three days or less, `10-16 (today!)` today, `10-10 (6d ago)` already passed. A date in another year is written in full, `2027-01-05 (81d)`. Days are counted in your local time zone. When a date has passed — usually because the store renewed the subscription — `list` reminds you on stderr to look up the new date. Accounts without a date show `-`; without any dates the table is unchanged.
317
+
318
+ `list --json` gives each account `"renews": {"date": "2026-11-10", "days_left": 25}` (`days_left` is negative once the date has passed), or `null`. The date is stored as `renews` in `config.json`; `apply -f FILE` treats it like any other account setting, so an account whose entry in FILE has no `renews` loses its date.
319
+
307
320
  ### Finding a session
308
321
 
309
322
  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:
@@ -495,6 +508,8 @@ It never copies, exports, uploads or backs up a login. One command touches one:
495
508
 
496
509
  ### Commands
497
510
 
511
+ Every command's `--help` ends with examples you can copy, covering its options, e.g. `multi-claude route --help`.
512
+
498
513
  | Command | What it does |
499
514
  | ---- | ---- |
500
515
  | `multi-claude init [--root DIR] [--bin-dir DIR] [--shared-dir DIR] [--shared-items A,B]` | Create or change global settings. |
@@ -502,14 +517,14 @@ It never copies, exports, uploads or backs up a login. One command touches one:
502
517
  | `multi-claude restore NAME [--skip-process-check] [--dry-run]` | Undo `migrate-default`: move the account back to `~/.claude` and unregister it. The login is kept. |
503
518
  | `multi-claude export NAME FILE` | Write the account's settings, `CLAUDE.md`, agents, commands, skills, output styles and MCP servers to FILE. No logins or sessions. |
504
519
  | `multi-claude import FILE NAME [--force] [--dry-run]` | Import such a file into an existing account; conflicts are refused unless `--force` (which backs up first). |
505
- | `multi-claude add NAME [--proxy P] [--shared [DIR] \| --no-shared] [--adopt] [--shared-exclude ITEM] [--shared-include ITEM] [--config-from OTHER]` | Add an account, adopt an existing directory, or change its options. `--config-from` copies `settings.json` from OTHER once. `--shared DIR` also makes DIR the shared directory for every account (default `~/.claude-shared`). `--shared-exclude` keeps one shared item out of this account; `--shared-include` undoes it. |
506
- | `multi-claude set NAME [--proxy P] [--shared [DIR] \| --no-shared] [--shared-exclude ITEM] [--shared-include ITEM]` | Change an existing account; same options as `add` without `--adopt`. Returns 1 for an account that is not registered. |
520
+ | `multi-claude add NAME [--proxy P] [--shared [DIR] \| --no-shared] [--adopt] [--shared-exclude ITEM] [--shared-include ITEM] [--renews DATE\|off] [--config-from OTHER]` | Add an account, adopt an existing directory, or change its options. `--config-from` copies `settings.json` from OTHER once. `--renews` records the renewal date (see [Renewal date](#renewal-date)). `--shared DIR` also makes DIR the shared directory for every account (default `~/.claude-shared`). `--shared-exclude` keeps one shared item out of this account; `--shared-include` undoes it. |
521
+ | `multi-claude set NAME [--proxy P] [--shared [DIR] \| --no-shared] [--shared-exclude ITEM] [--shared-include ITEM] [--renews DATE\|off]` | Change an existing account; same options as `add` without `--adopt`. Returns 1 for an account that is not registered. |
507
522
  | `multi-claude proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
508
523
  | `multi-claude env (NAME \| --defaults) [K=V ...] [--unset K ...]` | Set or remove extra environment variables. |
509
524
  | `multi-claude args (NAME \| --defaults) -- [ARG ...]` | Replace the fixed arguments; `--` with nothing after it clears them. |
510
525
  | `multi-claude remove NAME` | Unregister an account and delete its launcher. **The directory and the login are kept.** |
511
526
  | `multi-claude apply [-f FILE]` | Converge everything to the configuration (or to `FILE`). |
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). |
527
+ | `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). A `RENEWS` column (and a `renews` key in `--json`) shows the renewal dates you recorded. |
513
528
  | `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
514
529
  | `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). |
515
530
  | `multi-claude doctor [--json] [--verbose]` | Check the configuration, launchers, account directories, shared links, logins and environment. |
@@ -1,3 +1,3 @@
1
1
  """multi-claude:管理多个 Claude Code 账号的配置目录、启动命令、代理与启动参数。"""
2
2
 
3
- __version__ = "0.12.1"
3
+ __version__ = "0.14.0"
@@ -15,8 +15,9 @@ import shutil
15
15
  import stat
16
16
  import subprocess
17
17
  import sys
18
+ import textwrap
18
19
  import unicodedata
19
- from datetime import datetime, timedelta, timezone
20
+ from datetime import date, datetime, timedelta, timezone
20
21
  from typing import List, NamedTuple, Optional, Tuple
21
22
 
22
23
  from . import (__version__, accounts, artifacts, bundle, completion, doctor, history, hook, identity, launcher, migrate,
@@ -24,7 +25,7 @@ from . import (__version__, accounts, artifacts, bundle, completion, doctor, his
24
25
  from .actions import LOG_PREFIX, error, info, warn
25
26
  from .config import (DEFAULT_SHARED_DIR, DEFAULT_SHARED_ITEMS, IDENTITY_DEFAULT, IDENTITY_DIR, Account, Config, ConfigError, RouteRule,
26
27
  is_unshareable, load_config, normalize_proxy, parse_config, validate_env_key, validate_name,
27
- validate_route_path, validate_value, config_path)
28
+ validate_renews, validate_route_path, validate_value, config_path)
28
29
  from .env import account_env
29
30
  from .fsutil import KIND_FILE, KIND_LINK, KIND_MISSING, atomic_write, entry_kind, expand, read_text
30
31
  from .lock import LockBusyError, WriteLock
@@ -46,7 +47,7 @@ COMMAND_GROUPS = [
46
47
  ("list", "show accounts, logins and usage"),
47
48
  ]),
48
49
  ("Account settings", [
49
- ("set NAME", "change an account: proxy and sharing"),
50
+ ("set NAME", "change an account: proxy, sharing, renewal date"),
50
51
  ("proxy, env, args", "set the proxy, extra variables or fixed arguments of a launcher"),
51
52
  ("rename, remove", "rename or unregister an account"),
52
53
  ("migrate-default, restore", "turn the existing ~/.claude into an account, or back"),
@@ -74,6 +75,7 @@ _EXAMPLES = [
74
75
  ("multi-claude add work --proxy 7901", ""),
75
76
  ("multi-claude login work", ""),
76
77
  ("multi-claude route ~/work work", ""),
78
+ ("multi-claude set work --renews 2026-11-10", "record when the plan renews"),
77
79
  ("multi-claude add --help", "details of one command"),
78
80
  ]
79
81
 
@@ -95,12 +97,204 @@ def _help_epilog() -> str:
95
97
  for shown, text in items:
96
98
  lines.append(" {:<18}{}".format(shown, text))
97
99
  lines.append("")
98
- lines.append("Examples:")
99
- for command, text in _EXAMPLES:
100
- lines.append(" {:<36}{}".format(command, text).rstrip())
100
+ lines.extend(_examples_lines(_EXAMPLES))
101
101
  return "\n".join(lines)
102
102
 
103
103
 
104
+ def _examples_lines(examples: List[Tuple[str, str]]) -> List[str]:
105
+ """帮助末尾的 Examples: 段。列宽按最长的命令算:固定列宽遇到长命令时说明会紧贴在命令后面,
106
+ 测试按“两个以上空格”切分命令与说明,贴住就切不开(方案 feature-renewal-date §5.1.4)。"""
107
+ width = max(len(command) for command, _text in examples) + 2
108
+ lines = ["Examples:"]
109
+ for command, text in examples:
110
+ lines.append(" {}{}".format(command.ljust(width), text).rstrip())
111
+ return lines
112
+
113
+
114
+ def _attach_examples(parser: argparse.ArgumentParser, examples: List[Tuple[str, str]]) -> None:
115
+ """给子命令帮助末尾加 Examples: 段。示例要靠 RawDescriptionHelpFormatter 才能保留列对齐,
116
+ 但它对说明文字也不再折行,所以先把说明按 79 列折好;已手动加过 \\n 的说明是作者自己排过版的,原样保留。
117
+ argparse 在打印帮助时才读这三个属性,子解析器建好后再设置同样生效(方案 feature-help-examples §3)。"""
118
+ if "\n" not in (parser.description or ""):
119
+ parser.description = "\n".join(textwrap.wrap(parser.description or "", 79))
120
+ parser.epilog = "\n".join(_examples_lines(examples))
121
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
122
+
123
+
124
+ # 子命令帮助末尾的示例:覆盖该命令的全部选项(公共写选项 --verbose 除外),测试逐条解析核对。
125
+ _SET_EXAMPLES = [
126
+ ("multi-claude set work --proxy 7901", "set the proxy to http://127.0.0.1:7901"),
127
+ ("multi-claude set work --proxy off", "start claude-work without a proxy"),
128
+ ("multi-claude set work --shared", "link the shared items (default {})".format(DEFAULT_SHARED_DIR)),
129
+ ("multi-claude set work --shared ~/claude-common", "make ~/claude-common the shared directory for every account"),
130
+ ("multi-claude set work --no-shared", "remove the shared links of this account"),
131
+ ("multi-claude set work --shared-exclude skills", "do not link skills into this account"),
132
+ ("multi-claude set work --shared-include skills", "link skills again"),
133
+ ("multi-claude set work --renews 2026-11-10", "record the renewal date shown by the App Store / Google Play"),
134
+ ("multi-claude set work --renews off", "forget the renewal date"),
135
+ ("multi-claude set work --renews 2026-11-10 --dry-run", "show what would change without writing"),
136
+ ]
137
+
138
+ _ADD_EXAMPLES = [
139
+ ("multi-claude add work", "create account work and its launcher claude-work"),
140
+ ("multi-claude add work --proxy 7901 --shared", "with a proxy and the shared items"),
141
+ ("multi-claude add work --no-shared", "keep the account without shared links"),
142
+ ("multi-claude add work --shared-exclude skills", "share everything except skills"),
143
+ ("multi-claude add work --shared-include skills", "share skills again"),
144
+ ("multi-claude add work --renews 2026-11-10", "also record the renewal date"),
145
+ ("multi-claude add work --shared --adopt", "take over shared links that already exist"),
146
+ ("multi-claude add work --config-from home", "copy settings.json from account home once"),
147
+ ("multi-claude add work --dry-run", "show what would be created"),
148
+ ]
149
+
150
+ _LIST_EXAMPLES = [
151
+ ("multi-claude list", "accounts, plan, usage; RENEWS appears once a renewal date is set"),
152
+ ("multi-claude list --offline", "no token read, no network (RENEWS still shown)"),
153
+ ("multi-claude list --no-refresh", "never renew an expired access token"),
154
+ ("multi-claude list --verbose", "every field and the global settings"),
155
+ ("multi-claude list --json", 'for scripts; each account has "renews": {"date", "days_left"} or null'),
156
+ ("multi-claude list --names", "account names only"),
157
+ ]
158
+
159
+ _RENEWS_LEGEND = [
160
+ "RENEWS: the date you recorded with `multi-claude set NAME --renews YYYY-MM-DD`;",
161
+ "(25d) days left, (3d!) three days or less, (today!) renews today,",
162
+ "(6d ago) passed: update it after the store renews.",
163
+ ]
164
+
165
+ # 其余子命令帮助末尾的示例(方案 feature-help-examples §5.1.2):合起来覆盖每个选项与固定取值,照抄即可运行;
166
+ # tests/test_help_examples.py 逐条解析、在沙箱里实跑,并核对没有漏掉的选项。add、set、list 的示例在上面单独定义。
167
+ _SUBCOMMAND_EXAMPLES = {
168
+ "init": [
169
+ ("multi-claude init --root ~/.cc --bin-dir ~/.local/bin", "where account directories and launchers go"),
170
+ ("multi-claude init --shared-dir ~/.claude-shared", "directory whose items can be linked into accounts"),
171
+ ("multi-claude init --shared-items agents,commands,skills,CLAUDE.md", "items to share (this is the default)"),
172
+ ("multi-claude init --root ~/.cc --dry-run --verbose", "list every planned action without writing"),
173
+ ],
174
+ "migrate-default": [
175
+ ("multi-claude migrate-default --dry-run", "preview; without NAME, usually the login e-mail"),
176
+ ("multi-claude migrate-default home", "turn ~/.claude into account home, login kept"),
177
+ ("multi-claude migrate-default home --proxy 7901", "and give it a proxy"),
178
+ ("multi-claude migrate-default home --copy --keep-backup", "copy instead of rename; keep the original"),
179
+ ("multi-claude migrate-default home --skip-process-check", "do not check for running Claude Code (risky)"),
180
+ ],
181
+ "proxy": [
182
+ ("multi-claude proxy work 7901", "same as http://127.0.0.1:7901"),
183
+ ("multi-claude proxy work https://proxy.example.com:8443", "any http(s) URL without user or password"),
184
+ ("multi-claude proxy work off", "unset the proxy variables in claude-work"),
185
+ ("multi-claude proxy work inherit --dry-run", "keep the shell's proxy (default); preview only"),
186
+ ],
187
+ "env": [
188
+ ("multi-claude env work MAX_THINKING_TOKENS=8000", "add or change a variable"),
189
+ ("multi-claude env work --unset MAX_THINKING_TOKENS", "remove it again"),
190
+ ("multi-claude env --defaults MAX_THINKING_TOKENS=8000", "for every account"),
191
+ ("multi-claude env work DISABLE_TELEMETRY=1 --dry-run", "preview only"),
192
+ ],
193
+ "args": [
194
+ ("multi-claude args work -- --ide", "claude-work always starts with --ide"),
195
+ ("multi-claude args --defaults -- --ide", "the same for every account"),
196
+ ("multi-claude args work --", "clear the fixed arguments of work"),
197
+ ("multi-claude args work --dry-run -- --ide", "preview only"),
198
+ ],
199
+ "remove": [
200
+ ("multi-claude remove work", "unregister work; directory and login stay"),
201
+ ("multi-claude remove work --dry-run", "preview only"),
202
+ ],
203
+ "apply": [
204
+ ("multi-claude apply", "rewrite launchers and links to match config.json"),
205
+ ("multi-claude apply --dry-run --verbose", "list every planned action without writing"),
206
+ ("multi-claude apply -f accounts.json", "adopt accounts.json as the new configuration"),
207
+ ],
208
+ "usage": [
209
+ ("multi-claude usage", "5-hour and 7-day usage of every account"),
210
+ ("multi-claude usage work --json", "one account, for scripts"),
211
+ ("multi-claude usage --history", "tokens per day, last 7 days"),
212
+ ("multi-claude usage --history work --days 30 --by model", "one account, 30 days, per model"),
213
+ ("multi-claude usage --history --by day", "per day (the default grouping)"),
214
+ ],
215
+ "doctor": [
216
+ ("multi-claude doctor", "check the setup and suggest fixes"),
217
+ ("multi-claude doctor --verbose", "also list the checks that passed"),
218
+ ("multi-claude doctor --json", "for scripts"),
219
+ ],
220
+ "route": [
221
+ ("multi-claude route ~/work work", "claude-auto uses work under ~/work"),
222
+ ("multi-claude route ~/work --remove", "remove that route"),
223
+ ("multi-claude route --default home", "use home where no route matches"),
224
+ ("multi-claude route --no-default", "run plain claude where no route matches"),
225
+ ("multi-claude route ~/work work --dry-run", "preview only"),
226
+ ],
227
+ "rename": [
228
+ ("multi-claude rename work job", "claude-work becomes claude-job"),
229
+ ("multi-claude rename work job --dry-run", "preview only"),
230
+ ],
231
+ "mcp": [
232
+ ("multi-claude mcp work list", "the MCP servers of work"),
233
+ ("multi-claude mcp work add --scope user github -- npx -y @modelcontextprotocol/server-github", "everything after the name goes to `claude mcp`"),
234
+ ],
235
+ "login": [
236
+ ("multi-claude login work", "runs `claude auth login` as work"),
237
+ ],
238
+ "which": [
239
+ ("multi-claude which", "the account claude-auto would use here"),
240
+ ("multi-claude which ~/work", "... in ~/work"),
241
+ ],
242
+ "run": [
243
+ ("multi-claude run work", "same as claude-work"),
244
+ ("multi-claude run work -- claude --version", "any command, with the environment of claude-work"),
245
+ ("multi-claude run -- env", "without a name: the account claude-auto would use here"),
246
+ ],
247
+ "path": [
248
+ ("multi-claude path work", "print the directory of work"),
249
+ ],
250
+ "code": [
251
+ ("multi-claude code work ~/work", "a separate VS Code window for work"),
252
+ ("multi-claude code work . -- --disable-gpu", "arguments after -- go to VS Code"),
253
+ ],
254
+ "restore": [
255
+ ("multi-claude restore home --dry-run", "preview moving home back to ~/.claude"),
256
+ ("multi-claude restore home", "undo migrate-default, login kept"),
257
+ ("multi-claude restore home --skip-process-check", "do not check for running Claude Code (risky)"),
258
+ ],
259
+ "export": [
260
+ ("multi-claude export work ~/work-settings.tar.gz", "settings, CLAUDE.md, agents, skills, MCP; no logins"),
261
+ ],
262
+ "import": [
263
+ ("multi-claude import ~/work-settings.tar.gz home", "into an existing account"),
264
+ ("multi-claude import ~/work-settings.tar.gz home --dry-run", "preview only"),
265
+ ("multi-claude import ~/work-settings.tar.gz home --force", "back up and replace items that differ"),
266
+ ],
267
+ "completion": [
268
+ ("multi-claude completion bash > ~/.local/share/bash-completion/completions/multi-claude", "bash completion for your user"),
269
+ ('multi-claude completion zsh > "${fpath[1]}/_multi-claude"', "zsh: any directory on your fpath"),
270
+ ("multi-claude completion fish > ~/.config/fish/completions/multi-claude.fish", "fish completion"),
271
+ ],
272
+ "statusline": [
273
+ ("multi-claude statusline install ~/.claude/settings.json", "record fresh usage from the status line"),
274
+ ("multi-claude statusline uninstall ~/.claude/settings.json --dry-run", "preview restoring the original command"),
275
+ ],
276
+ "handoff": [
277
+ ("! multi-claude handoff home", "inside a Claude session: hand it to home"),
278
+ ("multi-claude handoff home --from work", "the latest session of work in this directory"),
279
+ ("multi-claude handoff home --from work --session 3c588340-1219-48df-a231-509866789487", "a given session"),
280
+ ("multi-claude handoff home --from work --force --dry-run", "preview replacing a different copy"),
281
+ ],
282
+ "sessions": [
283
+ ("multi-claude sessions", "the 30 newest sessions of every account"),
284
+ ("multi-claude sessions work --limit 10", "one account, 10 sessions"),
285
+ ("multi-claude sessions --grep importer --cmd", "with the command that resumes each"),
286
+ ("multi-claude sessions --limit 0 --json", "every session, for scripts"),
287
+ ("multi-claude conversations --grep importer", "another name for sessions"),
288
+ ],
289
+ "artifacts": [
290
+ ("multi-claude artifacts", "the 30 newest artifacts of every account"),
291
+ ("multi-claude artifacts work --grep report", "one account, matching text"),
292
+ ("multi-claude artifacts --path --cmd", "with the source file and the resume command"),
293
+ ("multi-claude artifacts --limit 0 --json", "every artifact, for scripts"),
294
+ ],
295
+ }
296
+
297
+
104
298
  def build_parser() -> argparse.ArgumentParser:
105
299
  # 子命令不传 help=,argparse 就不会把它们平铺成一列;改由 epilog 按用途分组列出(方案 feature-clearer-help §5.1.1)。
106
300
  # 新增子命令时必须同时写进 COMMAND_GROUPS,测试会核对两者一致。
@@ -134,13 +328,18 @@ def build_parser() -> argparse.ArgumentParser:
134
328
  help="do not check whether Claude Code is running (at your own risk)")
135
329
  _add_dry_run(p_mig)
136
330
 
137
- p_add = sub.add_parser("add", description="add an account, adopt an existing directory, or change its options")
331
+ # 带 epilog 示例的子命令用 RawDescriptionHelpFormatter 保留换行;description 因此不再自动折行,须手动加 \n。
332
+ p_add = sub.add_parser("add", description="add an account, adopt an existing directory, or change its options",
333
+ epilog="\n".join(_examples_lines(_ADD_EXAMPLES)),
334
+ formatter_class=argparse.RawDescriptionHelpFormatter)
138
335
  _add_account_options(p_add, adopt=True)
139
336
  p_add.add_argument("--config-from", metavar="OTHER",
140
337
  help="copy settings.json from account OTHER into this account once")
141
338
 
142
- p_set = sub.add_parser("set", description="change an existing account: proxy and sharing "
143
- "(use env, args, rename for the rest)")
339
+ p_set = sub.add_parser("set", description="change an existing account: proxy, sharing and renewal date\n"
340
+ "(use env, args, rename for the rest)",
341
+ epilog="\n".join(_examples_lines(_SET_EXAMPLES)),
342
+ formatter_class=argparse.RawDescriptionHelpFormatter)
144
343
  _add_account_options(p_set, adopt=False)
145
344
 
146
345
  p_proxy = sub.add_parser("proxy", description="set the proxy of an account")
@@ -169,8 +368,10 @@ def build_parser() -> argparse.ArgumentParser:
169
368
  p_apply.add_argument("-f", "--file", help="use this file as the new configuration")
170
369
  _add_write_options(p_apply)
171
370
 
172
- p_list = sub.add_parser("list", description="show accounts and their status; looks up each account's plan "
173
- "and billing channel from Anthropic unless --offline is given")
371
+ p_list = sub.add_parser("list", description="show accounts and their status; looks up each account's plan\n"
372
+ "and billing channel from Anthropic unless --offline is given",
373
+ epilog="\n".join(_examples_lines(_LIST_EXAMPLES) + [""] + _RENEWS_LEGEND),
374
+ formatter_class=argparse.RawDescriptionHelpFormatter)
174
375
  list_mode = p_list.add_mutually_exclusive_group()
175
376
  list_mode.add_argument("--verbose", action="store_true",
176
377
  help="show every field, the global settings and the keychain details")
@@ -313,6 +514,9 @@ def build_parser() -> argparse.ArgumentParser:
313
514
  help="also print, below each artifact, the local file it was published from, marked "
314
515
  "(missing) when it no longer exists")
315
516
  p_artifacts.add_argument("--json", action="store_true", help="print machine-readable JSON")
517
+ # add、set、list 的示例在 add_parser 时已传入;其余子命令在这里统一挂上(键必须是已定义的子命令名)。
518
+ for name, examples in _SUBCOMMAND_EXAMPLES.items():
519
+ _attach_examples(sub.choices[name], examples)
316
520
  return parser
317
521
 
318
522
 
@@ -341,6 +545,9 @@ def _add_account_options(parser: argparse.ArgumentParser, adopt: bool) -> None:
341
545
  "the link multi-claude made is removed")
342
546
  parser.add_argument("--shared-include", action="append", default=[], metavar="ITEM",
343
547
  help="undo --shared-exclude for this item (repeatable)")
548
+ # 续费日由用户照着 App Store / Google Play 的订阅页手填:服务端接口给不出准确的到期日(方案 feature-renewal-date §1)。
549
+ parser.add_argument("--renews", metavar="DATE",
550
+ help="renewal date of the subscription, YYYY-MM-DD as shown by the store; off to clear it")
344
551
  _add_write_options(parser)
345
552
 
346
553
 
@@ -606,6 +813,8 @@ def dispatch(args: argparse.Namespace) -> int:
606
813
  account.shared = True
607
814
  elif args.no_shared:
608
815
  account.shared = False
816
+ if args.renews is not None:
817
+ account.renews = None if args.renews == "off" else _checked_renews(args.renews)
609
818
  _apply_shared_exclude(new, account, args.shared_exclude, args.shared_include)
610
819
  if getattr(args, "adopt", False):
611
820
  # 接管只对开启了共享的账号有意义;关闭状态下工具本来就不管这些软链。
@@ -1178,7 +1387,7 @@ def _hint_next_step(config: Config, name: str) -> None:
1178
1387
 
1179
1388
  def _has_account_option(args: argparse.Namespace) -> bool:
1180
1389
  return bool(args.proxy is not None or args.shared_to is not None or args.no_shared
1181
- or args.shared_exclude or args.shared_include)
1390
+ or args.shared_exclude or args.shared_include or args.renews is not None)
1182
1391
 
1183
1392
 
1184
1393
  def _looks_like_path(value: str) -> bool:
@@ -1328,6 +1537,13 @@ def _checked_proxy(value: str) -> str:
1328
1537
  raise UsageError(str(exc))
1329
1538
 
1330
1539
 
1540
+ def _checked_renews(value: str) -> str:
1541
+ try:
1542
+ return validate_renews(value)
1543
+ except ValueError as exc:
1544
+ raise UsageError(str(exc))
1545
+
1546
+
1331
1547
  def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = False, offline: bool = False,
1332
1548
  refresh_tokens: bool = True) -> int:
1333
1549
  """列出账号。除 --names 与 --offline 外,会读各账号的访问令牌并向服务端查询套餐(方案 feature-live-plan);
@@ -1346,9 +1562,12 @@ def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = Fa
1346
1562
  plans = None if offline or not exists else plan.query_all(config, os.environ, datetime.now(timezone.utc),
1347
1563
  allow_refresh=refresh_tokens)
1348
1564
  if as_json:
1349
- _print_json(_list_data(config, exists, plans))
1565
+ # 只取一次今天:JSON 里的 days_left 与 stderr 的过期提示在跨午夜时也一致。
1566
+ today = _local_today()
1567
+ _print_json(_list_data(config, exists, plans, today))
1350
1568
  if exists:
1351
1569
  _report_plan_problems(plans)
1570
+ _report_renewals(config, today)
1352
1571
  accounts.warn_launch_settings(config, None, note_invalid=True)
1353
1572
  return accounts.EXIT_OK
1354
1573
  if not exists:
@@ -1365,7 +1584,10 @@ def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = Fa
1365
1584
  return accounts.EXIT_OK
1366
1585
  if "CLAUDE_CODE_CUSTOM_OAUTH_URL" in os.environ:
1367
1586
  warn("CLAUDE_CODE_CUSTOM_OAUTH_URL is set; LOGIN assumes the production service and may be wrong")
1368
- rows = [("NAME", "IDENTITY", "DIR", "PROXY", "SHARED", "LAUNCHER", "LOGIN") + _plan_header(plans) + ("LINK",)]
1587
+ today = _local_today()
1588
+ renews_shown = _has_renewals(config)
1589
+ rows = [("NAME", "IDENTITY", "DIR", "PROXY", "SHARED", "LAUNCHER", "LOGIN") + _plan_header(plans)
1590
+ + _renews_header(renews_shown) + ("LINK",)]
1369
1591
  details = []
1370
1592
  default_account = None
1371
1593
  for entry in _account_entries(config):
@@ -1373,7 +1595,9 @@ def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = Fa
1373
1595
  default_account = entry["name"]
1374
1596
  rows.append((entry["name"], entry["identity"], "ok" if entry["dir_exists"] else "missing-dir",
1375
1597
  entry["proxy"], _shared_cell(entry), entry["launcher"], entry["login"])
1376
- + _plan_cells(plans, entry["name"]) + (entry["link"] or "-",))
1598
+ + _plan_cells(plans, entry["name"])
1599
+ + _renews_cells(renews_shown, config.find(entry["name"]).renews, today)
1600
+ + (entry["link"] or "-",))
1377
1601
  if verbose:
1378
1602
  details.append(" {}: keychain service {!r}, credentials file {}".format(
1379
1603
  entry["name"], entry["keychain_service"], entry["credentials_file"]))
@@ -1385,6 +1609,7 @@ def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = Fa
1385
1609
  for line in details:
1386
1610
  print(line)
1387
1611
  _report_plan_problems(plans)
1612
+ _report_renewals(config, today)
1388
1613
  accounts.warn_launch_settings(config, None, note_invalid=True)
1389
1614
  return accounts.EXIT_OK
1390
1615
 
@@ -1403,6 +1628,61 @@ def _plan_cells(plans: Optional[dict], name: str) -> tuple:
1403
1628
  return (plan.plan_cell(plans[name]), plan.billing_cell(plans[name]))
1404
1629
 
1405
1630
 
1631
+ def _local_today() -> date:
1632
+ """本机时区的今天;续费日按商店页面显示的日期手填,剩余天数也按本地日期算。"""
1633
+ return datetime.now().astimezone().date()
1634
+
1635
+
1636
+ def _has_renewals(config: Config) -> bool:
1637
+ """没有任何账号填过续费日时不加 RENEWS 列,表格与改动前完全相同。"""
1638
+ return any(account.renews for account in config.accounts.values())
1639
+
1640
+
1641
+ def _renews_header(shown: bool) -> tuple:
1642
+ return ("RENEWS",) if shown else ()
1643
+
1644
+
1645
+ def _renews_cells(shown: bool, renews: Optional[str], today: date) -> tuple:
1646
+ return (_renews_cell(renews, today),) if shown else ()
1647
+
1648
+
1649
+ def _renews_days(renews: str, today: date) -> int:
1650
+ return (datetime.strptime(renews, "%Y-%m-%d").date() - today).days
1651
+
1652
+
1653
+ def _renews_cell(renews: Optional[str], today: date) -> str:
1654
+ """RENEWS 列的一格:同年只写月-日,括号里是剩余天数;3 天内加 !,已过写 ago,提醒去商店页核对后更新。"""
1655
+ if not renews:
1656
+ return "-"
1657
+ day = datetime.strptime(renews, "%Y-%m-%d").date()
1658
+ shown = day.strftime("%m-%d") if day.year == today.year else renews
1659
+ days = (day - today).days
1660
+ if days == 0:
1661
+ return "{} (today!)".format(shown)
1662
+ if days < 0:
1663
+ return "{} ({}d ago)".format(shown, -days)
1664
+ if days <= 3:
1665
+ return "{} ({}d!)".format(shown, days)
1666
+ return "{} ({}d)".format(shown, days)
1667
+
1668
+
1669
+ def _renews_dict(renews: Optional[str], today: date) -> Optional[dict]:
1670
+ """--json 的 renews 键:没填为 None;days_left 可为负数(日期已过)。"""
1671
+ if not renews:
1672
+ return None
1673
+ return {"date": renews, "days_left": _renews_days(renews, today)}
1674
+
1675
+
1676
+ def _report_renewals(config: Config, today: date) -> None:
1677
+ """续费日已过的账号打一行到 stderr:多半是商店已自动续费、用户还没更新日期。走 stderr 不混进 --json。"""
1678
+ passed = [name for name, account in config.accounts.items()
1679
+ if account.renews and _renews_days(account.renews, today) < 0]
1680
+ if passed:
1681
+ print("{} renewal date has passed for {}: check the store's subscription page, then run "
1682
+ "multi-claude set NAME --renews YYYY-MM-DD (or --renews off)".format(LOG_PREFIX, ", ".join(passed)),
1683
+ file=sys.stderr)
1684
+
1685
+
1406
1686
  def _report_plan_problems(plans: Optional[dict]) -> None:
1407
1687
  """套餐查询与令牌刷新的失败提示,打到 stderr;不影响表格、STATUS 列与退出码。"""
1408
1688
  if plans is None:
@@ -1436,13 +1716,17 @@ def _print_brief_list(config: Config, plans: Optional[dict] = None) -> int:
1436
1716
  # 与完整输出相同:LOGIN 与由它得出的 STATUS 都按正式服务探测,这时可能不准。
1437
1717
  warn("CLAUDE_CODE_CUSTOM_OAUTH_URL is set; LOGIN assumes the production service and may be wrong")
1438
1718
  now = datetime.now(timezone.utc)
1439
- rows = [("NAME", "LOGIN") + _plan_header(plans) + ("PROXY", "SHARED", "5H", "7D", "LAST USED", "STATUS")]
1719
+ today = _local_today()
1720
+ renews_shown = _has_renewals(config)
1721
+ rows = [("NAME", "LOGIN") + _plan_header(plans) + _renews_header(renews_shown)
1722
+ + ("PROXY", "SHARED", "5H", "7D", "LAST USED", "STATUS")]
1440
1723
  has_problem = False
1441
1724
  for entry in _account_entries(config):
1442
1725
  report = usage.read_account_usage(config, config.find(entry["name"]), now)
1443
1726
  status = _status_cell(entry)
1444
1727
  has_problem = has_problem or status != "ok"
1445
1728
  rows.append((entry["name"], entry["login"]) + _plan_cells(plans, entry["name"])
1729
+ + _renews_cells(renews_shown, config.find(entry["name"]).renews, today)
1446
1730
  + (entry["proxy"], _shared_cell(entry),
1447
1731
  _window_cell(report.five_hour, now), _window_cell(report.seven_day, now),
1448
1732
  _last_used_cell(entry["last_used"], now), status))
@@ -1451,6 +1735,7 @@ def _print_brief_list(config: Config, plans: Optional[dict] = None) -> int:
1451
1735
  if has_problem:
1452
1736
  print("run multi-claude doctor for details")
1453
1737
  _report_plan_problems(plans)
1738
+ _report_renewals(config, today)
1454
1739
  accounts.warn_launch_settings(config, None, note_invalid=True)
1455
1740
  return accounts.EXIT_OK
1456
1741
 
@@ -1493,9 +1778,11 @@ def _shared_cell(entry: dict) -> str:
1493
1778
  return "yes"
1494
1779
 
1495
1780
 
1496
- def _account_entries(config: Config, with_usage: bool = False, plans: Optional[dict] = None) -> List[dict]:
1781
+ def _account_entries(config: Config, with_usage: bool = False, plans: Optional[dict] = None,
1782
+ today: Optional[date] = None) -> List[dict]:
1497
1783
  """list 的表格与 JSON 共用的每账号数据;表格的列取自这里,保证两种输出口径一致。"""
1498
1784
  now = datetime.now(timezone.utc)
1785
+ today = today or _local_today()
1499
1786
  entries = []
1500
1787
  for name, account in config.accounts.items():
1501
1788
  directory = accounts.account_dir(config, name)
@@ -1515,6 +1802,7 @@ def _account_entries(config: Config, with_usage: bool = False, plans: Optional[d
1515
1802
  "credentials_file": identity.credentials_file(directory),
1516
1803
  # history.jsonl 与会话文件的最大 mtime;只 stat 不读内容(方案 feature-usage-history §5.1.3)
1517
1804
  "last_used": _iso(history.last_used(directory)),
1805
+ "renews": _renews_dict(account.renews, today),
1518
1806
  }
1519
1807
  if with_usage:
1520
1808
  entry["usage"] = usage.report_to_dict(usage.read_account_usage(config, account, now), now)
@@ -1525,7 +1813,7 @@ def _account_entries(config: Config, with_usage: bool = False, plans: Optional[d
1525
1813
  return entries
1526
1814
 
1527
1815
 
1528
- def _list_data(config: Config, exists: bool, plans: Optional[dict] = None) -> dict:
1816
+ def _list_data(config: Config, exists: bool, plans: Optional[dict] = None, today: Optional[date] = None) -> dict:
1529
1817
  if not exists:
1530
1818
  return {"schema_version": 1, "configured": False, "accounts": [],
1531
1819
  "routes": {"default": None, "rules": []}}
@@ -1535,7 +1823,7 @@ def _list_data(config: Config, exists: bool, plans: Optional[dict] = None) -> di
1535
1823
  "root": expand(config.root),
1536
1824
  "bin_dir": expand(config.bin_dir),
1537
1825
  "shared_dir": expand(config.shared_dir) if config.shared_dir else None,
1538
- "accounts": _account_entries(config, with_usage=True, plans=plans),
1826
+ "accounts": _account_entries(config, with_usage=True, plans=plans, today=today),
1539
1827
  "routes": {"default": config.route_default,
1540
1828
  "rules": [{"path": rule.path, "account": rule.account, "exists": os.path.isdir(expand(rule.path))}
1541
1829
  for rule in config.route_rules]},