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.
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/PKG-INFO +19 -4
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/README.md +18 -3
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/__init__.py +1 -1
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/cli.py +307 -19
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/config.py +29 -3
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude_cli.egg-info/PKG-INFO +19 -4
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude_cli.egg-info/SOURCES.txt +2 -0
- multi_claude_cli-0.14.0/tests/test_help_examples.py +224 -0
- multi_claude_cli-0.14.0/tests/test_renewal.py +245 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/LICENSE +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/pyproject.toml +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/setup.cfg +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/__main__.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/accounts.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/actions.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/artifacts.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/bundle.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/completion.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/doctor.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/env.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/fsutil.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/history.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/hook.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/identity.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/launcher.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/lock.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/migrate.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/plan.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/platform.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/refresh.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/restore.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/routes.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/sessions.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/shared.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/shellpath.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/statusline.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude/usage.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude_cli.egg-info/dependency_links.txt +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude_cli.egg-info/entry_points.txt +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/src/multi_claude_cli.egg-info/top_level.txt +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_accounts.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_artifacts.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_bundle.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_completion.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_config.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_doctor.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_everyday.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_fsutil.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_handoff.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_help.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_history.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_identity.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_install.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_mcp.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_migrate.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_onboarding.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_packaging.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_plan.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_platform.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_refresh.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_rename.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_routes.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_sessions.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_set.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_shared_exclude.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_statusline.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_usability.py +0 -0
- {multi_claude_cli-0.12.1 → multi_claude_cli-0.14.0}/tests/test_usage.py +0 -0
- {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.
|
|
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. |
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"])
|
|
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
|
-
|
|
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
|
|
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]},
|