multi-claude-cli 0.12.0__tar.gz → 0.13.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.0 → multi_claude_cli-0.13.0}/PKG-INFO +17 -4
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/README.md +16 -3
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/__init__.py +1 -1
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/cli.py +161 -19
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/config.py +29 -3
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/refresh.py +15 -2
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude_cli.egg-info/PKG-INFO +17 -4
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude_cli.egg-info/SOURCES.txt +1 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_refresh.py +33 -0
- multi_claude_cli-0.13.0/tests/test_renewal.py +245 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/LICENSE +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/pyproject.toml +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/setup.cfg +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/__main__.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/accounts.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/actions.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/artifacts.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/bundle.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/completion.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/doctor.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/env.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/fsutil.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/history.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/hook.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/identity.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/launcher.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/lock.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/migrate.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/plan.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/platform.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/restore.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/routes.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/sessions.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/shared.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/shellpath.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/statusline.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude/usage.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude_cli.egg-info/dependency_links.txt +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude_cli.egg-info/entry_points.txt +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude_cli.egg-info/top_level.txt +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_accounts.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_artifacts.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_bundle.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_completion.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_config.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_doctor.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_everyday.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_fsutil.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_handoff.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_help.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_history.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_identity.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_install.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_mcp.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_migrate.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_onboarding.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_packaging.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_plan.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_platform.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_rename.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_routes.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_sessions.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_set.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_shared_exclude.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_statusline.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_usability.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/tests/test_usage.py +0 -0
- {multi_claude_cli-0.12.0 → multi_claude_cli-0.13.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.13.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:
|
|
@@ -529,14 +542,14 @@ It never copies, exports, uploads or backs up a login. One command touches one:
|
|
|
529
542
|
| `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
543
|
| `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
544
|
| `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. |
|
|
545
|
+
| `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. |
|
|
546
|
+
| `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
547
|
| `multi-claude proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
|
|
535
548
|
| `multi-claude env (NAME \| --defaults) [K=V ...] [--unset K ...]` | Set or remove extra environment variables. |
|
|
536
549
|
| `multi-claude args (NAME \| --defaults) -- [ARG ...]` | Replace the fixed arguments; `--` with nothing after it clears them. |
|
|
537
550
|
| `multi-claude remove NAME` | Unregister an account and delete its launcher. **The directory and the login are kept.** |
|
|
538
551
|
| `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). |
|
|
552
|
+
| `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
553
|
| `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
|
|
541
554
|
| `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
555
|
| `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:
|
|
@@ -502,14 +515,14 @@ It never copies, exports, uploads or backs up a login. One command touches one:
|
|
|
502
515
|
| `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
516
|
| `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
517
|
| `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. |
|
|
518
|
+
| `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. |
|
|
519
|
+
| `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
520
|
| `multi-claude proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
|
|
508
521
|
| `multi-claude env (NAME \| --defaults) [K=V ...] [--unset K ...]` | Set or remove extra environment variables. |
|
|
509
522
|
| `multi-claude args (NAME \| --defaults) -- [ARG ...]` | Replace the fixed arguments; `--` with nothing after it clears them. |
|
|
510
523
|
| `multi-claude remove NAME` | Unregister an account and delete its launcher. **The directory and the login are kept.** |
|
|
511
524
|
| `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). |
|
|
525
|
+
| `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
526
|
| `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
|
|
514
527
|
| `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
528
|
| `multi-claude doctor [--json] [--verbose]` | Check the configuration, launchers, account directories, shared links, logins and environment. |
|
|
@@ -16,7 +16,7 @@ import stat
|
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
18
|
import unicodedata
|
|
19
|
-
from datetime import datetime, timedelta, timezone
|
|
19
|
+
from datetime import date, datetime, timedelta, timezone
|
|
20
20
|
from typing import List, NamedTuple, Optional, Tuple
|
|
21
21
|
|
|
22
22
|
from . import (__version__, accounts, artifacts, bundle, completion, doctor, history, hook, identity, launcher, migrate,
|
|
@@ -24,7 +24,7 @@ from . import (__version__, accounts, artifacts, bundle, completion, doctor, his
|
|
|
24
24
|
from .actions import LOG_PREFIX, error, info, warn
|
|
25
25
|
from .config import (DEFAULT_SHARED_DIR, DEFAULT_SHARED_ITEMS, IDENTITY_DEFAULT, IDENTITY_DIR, Account, Config, ConfigError, RouteRule,
|
|
26
26
|
is_unshareable, load_config, normalize_proxy, parse_config, validate_env_key, validate_name,
|
|
27
|
-
validate_route_path, validate_value, config_path)
|
|
27
|
+
validate_renews, validate_route_path, validate_value, config_path)
|
|
28
28
|
from .env import account_env
|
|
29
29
|
from .fsutil import KIND_FILE, KIND_LINK, KIND_MISSING, atomic_write, entry_kind, expand, read_text
|
|
30
30
|
from .lock import LockBusyError, WriteLock
|
|
@@ -46,7 +46,7 @@ COMMAND_GROUPS = [
|
|
|
46
46
|
("list", "show accounts, logins and usage"),
|
|
47
47
|
]),
|
|
48
48
|
("Account settings", [
|
|
49
|
-
("set NAME", "change an account: proxy
|
|
49
|
+
("set NAME", "change an account: proxy, sharing, renewal date"),
|
|
50
50
|
("proxy, env, args", "set the proxy, extra variables or fixed arguments of a launcher"),
|
|
51
51
|
("rename, remove", "rename or unregister an account"),
|
|
52
52
|
("migrate-default, restore", "turn the existing ~/.claude into an account, or back"),
|
|
@@ -74,6 +74,7 @@ _EXAMPLES = [
|
|
|
74
74
|
("multi-claude add work --proxy 7901", ""),
|
|
75
75
|
("multi-claude login work", ""),
|
|
76
76
|
("multi-claude route ~/work work", ""),
|
|
77
|
+
("multi-claude set work --renews 2026-11-10", "record when the plan renews"),
|
|
77
78
|
("multi-claude add --help", "details of one command"),
|
|
78
79
|
]
|
|
79
80
|
|
|
@@ -95,12 +96,62 @@ def _help_epilog() -> str:
|
|
|
95
96
|
for shown, text in items:
|
|
96
97
|
lines.append(" {:<18}{}".format(shown, text))
|
|
97
98
|
lines.append("")
|
|
98
|
-
lines.
|
|
99
|
-
for command, text in _EXAMPLES:
|
|
100
|
-
lines.append(" {:<36}{}".format(command, text).rstrip())
|
|
99
|
+
lines.extend(_examples_lines(_EXAMPLES))
|
|
101
100
|
return "\n".join(lines)
|
|
102
101
|
|
|
103
102
|
|
|
103
|
+
def _examples_lines(examples: List[Tuple[str, str]]) -> List[str]:
|
|
104
|
+
"""帮助末尾的 Examples: 段。列宽按最长的命令算:固定列宽遇到长命令时说明会紧贴在命令后面,
|
|
105
|
+
测试按“两个以上空格”切分命令与说明,贴住就切不开(方案 feature-renewal-date §5.1.4)。"""
|
|
106
|
+
width = max(len(command) for command, _text in examples) + 2
|
|
107
|
+
lines = ["Examples:"]
|
|
108
|
+
for command, text in examples:
|
|
109
|
+
lines.append(" {}{}".format(command.ljust(width), text).rstrip())
|
|
110
|
+
return lines
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# 子命令帮助末尾的示例:覆盖该命令的全部选项(公共写选项 --verbose 除外),测试逐条解析核对。
|
|
114
|
+
_SET_EXAMPLES = [
|
|
115
|
+
("multi-claude set work --proxy 7901", "set the proxy to http://127.0.0.1:7901"),
|
|
116
|
+
("multi-claude set work --proxy off", "start claude-work without a proxy"),
|
|
117
|
+
("multi-claude set work --shared", "link the shared items (default {})".format(DEFAULT_SHARED_DIR)),
|
|
118
|
+
("multi-claude set work --shared ~/claude-common", "make ~/claude-common the shared directory for every account"),
|
|
119
|
+
("multi-claude set work --no-shared", "remove the shared links of this account"),
|
|
120
|
+
("multi-claude set work --shared-exclude skills", "do not link skills into this account"),
|
|
121
|
+
("multi-claude set work --shared-include skills", "link skills again"),
|
|
122
|
+
("multi-claude set work --renews 2026-11-10", "record the renewal date shown by the App Store / Google Play"),
|
|
123
|
+
("multi-claude set work --renews off", "forget the renewal date"),
|
|
124
|
+
("multi-claude set work --renews 2026-11-10 --dry-run", "show what would change without writing"),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
_ADD_EXAMPLES = [
|
|
128
|
+
("multi-claude add work", "create account work and its launcher claude-work"),
|
|
129
|
+
("multi-claude add work --proxy 7901 --shared", "with a proxy and the shared items"),
|
|
130
|
+
("multi-claude add work --no-shared", "keep the account without shared links"),
|
|
131
|
+
("multi-claude add work --shared-exclude skills", "share everything except skills"),
|
|
132
|
+
("multi-claude add work --shared-include skills", "share skills again"),
|
|
133
|
+
("multi-claude add work --renews 2026-11-10", "also record the renewal date"),
|
|
134
|
+
("multi-claude add work --shared --adopt", "take over shared links that already exist"),
|
|
135
|
+
("multi-claude add work --config-from home", "copy settings.json from account home once"),
|
|
136
|
+
("multi-claude add work --dry-run", "show what would be created"),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
_LIST_EXAMPLES = [
|
|
140
|
+
("multi-claude list", "accounts, plan, usage; RENEWS appears once a renewal date is set"),
|
|
141
|
+
("multi-claude list --offline", "no token read, no network (RENEWS still shown)"),
|
|
142
|
+
("multi-claude list --no-refresh", "never renew an expired access token"),
|
|
143
|
+
("multi-claude list --verbose", "every field and the global settings"),
|
|
144
|
+
("multi-claude list --json", 'for scripts; each account has "renews": {"date", "days_left"} or null'),
|
|
145
|
+
("multi-claude list --names", "account names only"),
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
_RENEWS_LEGEND = [
|
|
149
|
+
"RENEWS: the date you recorded with `multi-claude set NAME --renews YYYY-MM-DD`;",
|
|
150
|
+
"(25d) days left, (3d!) three days or less, (today!) renews today,",
|
|
151
|
+
"(6d ago) passed: update it after the store renews.",
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
|
|
104
155
|
def build_parser() -> argparse.ArgumentParser:
|
|
105
156
|
# 子命令不传 help=,argparse 就不会把它们平铺成一列;改由 epilog 按用途分组列出(方案 feature-clearer-help §5.1.1)。
|
|
106
157
|
# 新增子命令时必须同时写进 COMMAND_GROUPS,测试会核对两者一致。
|
|
@@ -134,13 +185,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
134
185
|
help="do not check whether Claude Code is running (at your own risk)")
|
|
135
186
|
_add_dry_run(p_mig)
|
|
136
187
|
|
|
137
|
-
|
|
188
|
+
# 带 epilog 示例的子命令用 RawDescriptionHelpFormatter 保留换行;description 因此不再自动折行,须手动加 \n。
|
|
189
|
+
p_add = sub.add_parser("add", description="add an account, adopt an existing directory, or change its options",
|
|
190
|
+
epilog="\n".join(_examples_lines(_ADD_EXAMPLES)),
|
|
191
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
138
192
|
_add_account_options(p_add, adopt=True)
|
|
139
193
|
p_add.add_argument("--config-from", metavar="OTHER",
|
|
140
194
|
help="copy settings.json from account OTHER into this account once")
|
|
141
195
|
|
|
142
|
-
p_set = sub.add_parser("set", description="change an existing account: proxy and
|
|
143
|
-
"(use env, args, rename for the rest)"
|
|
196
|
+
p_set = sub.add_parser("set", description="change an existing account: proxy, sharing and renewal date\n"
|
|
197
|
+
"(use env, args, rename for the rest)",
|
|
198
|
+
epilog="\n".join(_examples_lines(_SET_EXAMPLES)),
|
|
199
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
144
200
|
_add_account_options(p_set, adopt=False)
|
|
145
201
|
|
|
146
202
|
p_proxy = sub.add_parser("proxy", description="set the proxy of an account")
|
|
@@ -169,8 +225,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
169
225
|
p_apply.add_argument("-f", "--file", help="use this file as the new configuration")
|
|
170
226
|
_add_write_options(p_apply)
|
|
171
227
|
|
|
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"
|
|
228
|
+
p_list = sub.add_parser("list", description="show accounts and their status; looks up each account's plan\n"
|
|
229
|
+
"and billing channel from Anthropic unless --offline is given",
|
|
230
|
+
epilog="\n".join(_examples_lines(_LIST_EXAMPLES) + [""] + _RENEWS_LEGEND),
|
|
231
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
174
232
|
list_mode = p_list.add_mutually_exclusive_group()
|
|
175
233
|
list_mode.add_argument("--verbose", action="store_true",
|
|
176
234
|
help="show every field, the global settings and the keychain details")
|
|
@@ -341,6 +399,9 @@ def _add_account_options(parser: argparse.ArgumentParser, adopt: bool) -> None:
|
|
|
341
399
|
"the link multi-claude made is removed")
|
|
342
400
|
parser.add_argument("--shared-include", action="append", default=[], metavar="ITEM",
|
|
343
401
|
help="undo --shared-exclude for this item (repeatable)")
|
|
402
|
+
# 续费日由用户照着 App Store / Google Play 的订阅页手填:服务端接口给不出准确的到期日(方案 feature-renewal-date §1)。
|
|
403
|
+
parser.add_argument("--renews", metavar="DATE",
|
|
404
|
+
help="renewal date of the subscription, YYYY-MM-DD as shown by the store; off to clear it")
|
|
344
405
|
_add_write_options(parser)
|
|
345
406
|
|
|
346
407
|
|
|
@@ -606,6 +667,8 @@ def dispatch(args: argparse.Namespace) -> int:
|
|
|
606
667
|
account.shared = True
|
|
607
668
|
elif args.no_shared:
|
|
608
669
|
account.shared = False
|
|
670
|
+
if args.renews is not None:
|
|
671
|
+
account.renews = None if args.renews == "off" else _checked_renews(args.renews)
|
|
609
672
|
_apply_shared_exclude(new, account, args.shared_exclude, args.shared_include)
|
|
610
673
|
if getattr(args, "adopt", False):
|
|
611
674
|
# 接管只对开启了共享的账号有意义;关闭状态下工具本来就不管这些软链。
|
|
@@ -1178,7 +1241,7 @@ def _hint_next_step(config: Config, name: str) -> None:
|
|
|
1178
1241
|
|
|
1179
1242
|
def _has_account_option(args: argparse.Namespace) -> bool:
|
|
1180
1243
|
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)
|
|
1244
|
+
or args.shared_exclude or args.shared_include or args.renews is not None)
|
|
1182
1245
|
|
|
1183
1246
|
|
|
1184
1247
|
def _looks_like_path(value: str) -> bool:
|
|
@@ -1328,6 +1391,13 @@ def _checked_proxy(value: str) -> str:
|
|
|
1328
1391
|
raise UsageError(str(exc))
|
|
1329
1392
|
|
|
1330
1393
|
|
|
1394
|
+
def _checked_renews(value: str) -> str:
|
|
1395
|
+
try:
|
|
1396
|
+
return validate_renews(value)
|
|
1397
|
+
except ValueError as exc:
|
|
1398
|
+
raise UsageError(str(exc))
|
|
1399
|
+
|
|
1400
|
+
|
|
1331
1401
|
def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = False, offline: bool = False,
|
|
1332
1402
|
refresh_tokens: bool = True) -> int:
|
|
1333
1403
|
"""列出账号。除 --names 与 --offline 外,会读各账号的访问令牌并向服务端查询套餐(方案 feature-live-plan);
|
|
@@ -1346,9 +1416,12 @@ def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = Fa
|
|
|
1346
1416
|
plans = None if offline or not exists else plan.query_all(config, os.environ, datetime.now(timezone.utc),
|
|
1347
1417
|
allow_refresh=refresh_tokens)
|
|
1348
1418
|
if as_json:
|
|
1349
|
-
|
|
1419
|
+
# 只取一次今天:JSON 里的 days_left 与 stderr 的过期提示在跨午夜时也一致。
|
|
1420
|
+
today = _local_today()
|
|
1421
|
+
_print_json(_list_data(config, exists, plans, today))
|
|
1350
1422
|
if exists:
|
|
1351
1423
|
_report_plan_problems(plans)
|
|
1424
|
+
_report_renewals(config, today)
|
|
1352
1425
|
accounts.warn_launch_settings(config, None, note_invalid=True)
|
|
1353
1426
|
return accounts.EXIT_OK
|
|
1354
1427
|
if not exists:
|
|
@@ -1365,7 +1438,10 @@ def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = Fa
|
|
|
1365
1438
|
return accounts.EXIT_OK
|
|
1366
1439
|
if "CLAUDE_CODE_CUSTOM_OAUTH_URL" in os.environ:
|
|
1367
1440
|
warn("CLAUDE_CODE_CUSTOM_OAUTH_URL is set; LOGIN assumes the production service and may be wrong")
|
|
1368
|
-
|
|
1441
|
+
today = _local_today()
|
|
1442
|
+
renews_shown = _has_renewals(config)
|
|
1443
|
+
rows = [("NAME", "IDENTITY", "DIR", "PROXY", "SHARED", "LAUNCHER", "LOGIN") + _plan_header(plans)
|
|
1444
|
+
+ _renews_header(renews_shown) + ("LINK",)]
|
|
1369
1445
|
details = []
|
|
1370
1446
|
default_account = None
|
|
1371
1447
|
for entry in _account_entries(config):
|
|
@@ -1373,7 +1449,9 @@ def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = Fa
|
|
|
1373
1449
|
default_account = entry["name"]
|
|
1374
1450
|
rows.append((entry["name"], entry["identity"], "ok" if entry["dir_exists"] else "missing-dir",
|
|
1375
1451
|
entry["proxy"], _shared_cell(entry), entry["launcher"], entry["login"])
|
|
1376
|
-
+ _plan_cells(plans, entry["name"])
|
|
1452
|
+
+ _plan_cells(plans, entry["name"])
|
|
1453
|
+
+ _renews_cells(renews_shown, config.find(entry["name"]).renews, today)
|
|
1454
|
+
+ (entry["link"] or "-",))
|
|
1377
1455
|
if verbose:
|
|
1378
1456
|
details.append(" {}: keychain service {!r}, credentials file {}".format(
|
|
1379
1457
|
entry["name"], entry["keychain_service"], entry["credentials_file"]))
|
|
@@ -1385,6 +1463,7 @@ def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = Fa
|
|
|
1385
1463
|
for line in details:
|
|
1386
1464
|
print(line)
|
|
1387
1465
|
_report_plan_problems(plans)
|
|
1466
|
+
_report_renewals(config, today)
|
|
1388
1467
|
accounts.warn_launch_settings(config, None, note_invalid=True)
|
|
1389
1468
|
return accounts.EXIT_OK
|
|
1390
1469
|
|
|
@@ -1403,6 +1482,61 @@ def _plan_cells(plans: Optional[dict], name: str) -> tuple:
|
|
|
1403
1482
|
return (plan.plan_cell(plans[name]), plan.billing_cell(plans[name]))
|
|
1404
1483
|
|
|
1405
1484
|
|
|
1485
|
+
def _local_today() -> date:
|
|
1486
|
+
"""本机时区的今天;续费日按商店页面显示的日期手填,剩余天数也按本地日期算。"""
|
|
1487
|
+
return datetime.now().astimezone().date()
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _has_renewals(config: Config) -> bool:
|
|
1491
|
+
"""没有任何账号填过续费日时不加 RENEWS 列,表格与改动前完全相同。"""
|
|
1492
|
+
return any(account.renews for account in config.accounts.values())
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _renews_header(shown: bool) -> tuple:
|
|
1496
|
+
return ("RENEWS",) if shown else ()
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def _renews_cells(shown: bool, renews: Optional[str], today: date) -> tuple:
|
|
1500
|
+
return (_renews_cell(renews, today),) if shown else ()
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _renews_days(renews: str, today: date) -> int:
|
|
1504
|
+
return (datetime.strptime(renews, "%Y-%m-%d").date() - today).days
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def _renews_cell(renews: Optional[str], today: date) -> str:
|
|
1508
|
+
"""RENEWS 列的一格:同年只写月-日,括号里是剩余天数;3 天内加 !,已过写 ago,提醒去商店页核对后更新。"""
|
|
1509
|
+
if not renews:
|
|
1510
|
+
return "-"
|
|
1511
|
+
day = datetime.strptime(renews, "%Y-%m-%d").date()
|
|
1512
|
+
shown = day.strftime("%m-%d") if day.year == today.year else renews
|
|
1513
|
+
days = (day - today).days
|
|
1514
|
+
if days == 0:
|
|
1515
|
+
return "{} (today!)".format(shown)
|
|
1516
|
+
if days < 0:
|
|
1517
|
+
return "{} ({}d ago)".format(shown, -days)
|
|
1518
|
+
if days <= 3:
|
|
1519
|
+
return "{} ({}d!)".format(shown, days)
|
|
1520
|
+
return "{} ({}d)".format(shown, days)
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def _renews_dict(renews: Optional[str], today: date) -> Optional[dict]:
|
|
1524
|
+
"""--json 的 renews 键:没填为 None;days_left 可为负数(日期已过)。"""
|
|
1525
|
+
if not renews:
|
|
1526
|
+
return None
|
|
1527
|
+
return {"date": renews, "days_left": _renews_days(renews, today)}
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _report_renewals(config: Config, today: date) -> None:
|
|
1531
|
+
"""续费日已过的账号打一行到 stderr:多半是商店已自动续费、用户还没更新日期。走 stderr 不混进 --json。"""
|
|
1532
|
+
passed = [name for name, account in config.accounts.items()
|
|
1533
|
+
if account.renews and _renews_days(account.renews, today) < 0]
|
|
1534
|
+
if passed:
|
|
1535
|
+
print("{} renewal date has passed for {}: check the store's subscription page, then run "
|
|
1536
|
+
"multi-claude set NAME --renews YYYY-MM-DD (or --renews off)".format(LOG_PREFIX, ", ".join(passed)),
|
|
1537
|
+
file=sys.stderr)
|
|
1538
|
+
|
|
1539
|
+
|
|
1406
1540
|
def _report_plan_problems(plans: Optional[dict]) -> None:
|
|
1407
1541
|
"""套餐查询与令牌刷新的失败提示,打到 stderr;不影响表格、STATUS 列与退出码。"""
|
|
1408
1542
|
if plans is None:
|
|
@@ -1436,13 +1570,17 @@ def _print_brief_list(config: Config, plans: Optional[dict] = None) -> int:
|
|
|
1436
1570
|
# 与完整输出相同:LOGIN 与由它得出的 STATUS 都按正式服务探测,这时可能不准。
|
|
1437
1571
|
warn("CLAUDE_CODE_CUSTOM_OAUTH_URL is set; LOGIN assumes the production service and may be wrong")
|
|
1438
1572
|
now = datetime.now(timezone.utc)
|
|
1439
|
-
|
|
1573
|
+
today = _local_today()
|
|
1574
|
+
renews_shown = _has_renewals(config)
|
|
1575
|
+
rows = [("NAME", "LOGIN") + _plan_header(plans) + _renews_header(renews_shown)
|
|
1576
|
+
+ ("PROXY", "SHARED", "5H", "7D", "LAST USED", "STATUS")]
|
|
1440
1577
|
has_problem = False
|
|
1441
1578
|
for entry in _account_entries(config):
|
|
1442
1579
|
report = usage.read_account_usage(config, config.find(entry["name"]), now)
|
|
1443
1580
|
status = _status_cell(entry)
|
|
1444
1581
|
has_problem = has_problem or status != "ok"
|
|
1445
1582
|
rows.append((entry["name"], entry["login"]) + _plan_cells(plans, entry["name"])
|
|
1583
|
+
+ _renews_cells(renews_shown, config.find(entry["name"]).renews, today)
|
|
1446
1584
|
+ (entry["proxy"], _shared_cell(entry),
|
|
1447
1585
|
_window_cell(report.five_hour, now), _window_cell(report.seven_day, now),
|
|
1448
1586
|
_last_used_cell(entry["last_used"], now), status))
|
|
@@ -1451,6 +1589,7 @@ def _print_brief_list(config: Config, plans: Optional[dict] = None) -> int:
|
|
|
1451
1589
|
if has_problem:
|
|
1452
1590
|
print("run multi-claude doctor for details")
|
|
1453
1591
|
_report_plan_problems(plans)
|
|
1592
|
+
_report_renewals(config, today)
|
|
1454
1593
|
accounts.warn_launch_settings(config, None, note_invalid=True)
|
|
1455
1594
|
return accounts.EXIT_OK
|
|
1456
1595
|
|
|
@@ -1493,9 +1632,11 @@ def _shared_cell(entry: dict) -> str:
|
|
|
1493
1632
|
return "yes"
|
|
1494
1633
|
|
|
1495
1634
|
|
|
1496
|
-
def _account_entries(config: Config, with_usage: bool = False, plans: Optional[dict] = None
|
|
1635
|
+
def _account_entries(config: Config, with_usage: bool = False, plans: Optional[dict] = None,
|
|
1636
|
+
today: Optional[date] = None) -> List[dict]:
|
|
1497
1637
|
"""list 的表格与 JSON 共用的每账号数据;表格的列取自这里,保证两种输出口径一致。"""
|
|
1498
1638
|
now = datetime.now(timezone.utc)
|
|
1639
|
+
today = today or _local_today()
|
|
1499
1640
|
entries = []
|
|
1500
1641
|
for name, account in config.accounts.items():
|
|
1501
1642
|
directory = accounts.account_dir(config, name)
|
|
@@ -1515,6 +1656,7 @@ def _account_entries(config: Config, with_usage: bool = False, plans: Optional[d
|
|
|
1515
1656
|
"credentials_file": identity.credentials_file(directory),
|
|
1516
1657
|
# history.jsonl 与会话文件的最大 mtime;只 stat 不读内容(方案 feature-usage-history §5.1.3)
|
|
1517
1658
|
"last_used": _iso(history.last_used(directory)),
|
|
1659
|
+
"renews": _renews_dict(account.renews, today),
|
|
1518
1660
|
}
|
|
1519
1661
|
if with_usage:
|
|
1520
1662
|
entry["usage"] = usage.report_to_dict(usage.read_account_usage(config, account, now), now)
|
|
@@ -1525,7 +1667,7 @@ def _account_entries(config: Config, with_usage: bool = False, plans: Optional[d
|
|
|
1525
1667
|
return entries
|
|
1526
1668
|
|
|
1527
1669
|
|
|
1528
|
-
def _list_data(config: Config, exists: bool, plans: Optional[dict] = None) -> dict:
|
|
1670
|
+
def _list_data(config: Config, exists: bool, plans: Optional[dict] = None, today: Optional[date] = None) -> dict:
|
|
1529
1671
|
if not exists:
|
|
1530
1672
|
return {"schema_version": 1, "configured": False, "accounts": [],
|
|
1531
1673
|
"routes": {"default": None, "rules": []}}
|
|
@@ -1535,7 +1677,7 @@ def _list_data(config: Config, exists: bool, plans: Optional[dict] = None) -> di
|
|
|
1535
1677
|
"root": expand(config.root),
|
|
1536
1678
|
"bin_dir": expand(config.bin_dir),
|
|
1537
1679
|
"shared_dir": expand(config.shared_dir) if config.shared_dir else None,
|
|
1538
|
-
"accounts": _account_entries(config, with_usage=True, plans=plans),
|
|
1680
|
+
"accounts": _account_entries(config, with_usage=True, plans=plans, today=today),
|
|
1539
1681
|
"routes": {"default": config.route_default,
|
|
1540
1682
|
"rules": [{"path": rule.path, "account": rule.account, "exists": os.path.isdir(expand(rule.path))}
|
|
1541
1683
|
for rule in config.route_rules]},
|
|
@@ -4,6 +4,7 @@ config.json 是唯一的权威源,启动命令、共享链接都是从它推
|
|
|
4
4
|
本模块只负责数据本身,不创建或删除任何账号文件。
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import datetime
|
|
7
8
|
import json
|
|
8
9
|
import os
|
|
9
10
|
import re
|
|
@@ -80,7 +81,8 @@ class Account(object):
|
|
|
80
81
|
def __init__(self, name: str, proxy: str = PROXY_INHERIT, shared: bool = False,
|
|
81
82
|
managed_links: Optional[List[str]] = None, identity: str = IDENTITY_DIR,
|
|
82
83
|
env: Optional[Dict[str, str]] = None, args: Optional[List[str]] = None,
|
|
83
|
-
dir: Optional[str] = None, shared_exclude: Optional[List[str]] = None
|
|
84
|
+
dir: Optional[str] = None, shared_exclude: Optional[List[str]] = None,
|
|
85
|
+
renews: Optional[str] = None) -> None:
|
|
84
86
|
self.name = name
|
|
85
87
|
# 账号目录在根目录下的名字。缺省等于账号名;rename 只改 name、不改 dir,
|
|
86
88
|
# 目录路径不变,macOS 上按路径绑定的登录就不会丢(方案 feature-rename-mcp §3)。
|
|
@@ -93,10 +95,14 @@ class Account(object):
|
|
|
93
95
|
self.identity = identity
|
|
94
96
|
self.env = dict(env or {})
|
|
95
97
|
self.args = list(args or [])
|
|
98
|
+
# 订阅续费日 YYYY-MM-DD,用户照着 App Store / Google Play 订阅页手填;None 表示没填。
|
|
99
|
+
# 服务端接口给不出准确的到期日,所以只能手填(方案 feature-renewal-date §1)。
|
|
100
|
+
self.renews = renews
|
|
96
101
|
|
|
97
102
|
def copy(self) -> "Account":
|
|
103
|
+
# migrate-default、restore 靠 config.copy() 复制配置,漏掉任何字段都会让这两个命令把它清掉。
|
|
98
104
|
return Account(self.name, self.proxy, self.shared, list(self.managed_links), self.identity,
|
|
99
|
-
dict(self.env), list(self.args), self.dir, list(self.shared_exclude))
|
|
105
|
+
dict(self.env), list(self.args), self.dir, list(self.shared_exclude), self.renews)
|
|
100
106
|
|
|
101
107
|
def to_dict(self) -> dict:
|
|
102
108
|
data = {"identity": self.identity, "proxy": self.proxy, "shared": self.shared,
|
|
@@ -107,6 +113,9 @@ class Account(object):
|
|
|
107
113
|
# 同理只在非空时写出,没有退出项的账号配置内容不变。
|
|
108
114
|
if self.shared_exclude:
|
|
109
115
|
data["shared_exclude"] = list(self.shared_exclude)
|
|
116
|
+
# 同理只在填过续费日时写出。
|
|
117
|
+
if self.renews:
|
|
118
|
+
data["renews"] = self.renews
|
|
110
119
|
return data
|
|
111
120
|
|
|
112
121
|
|
|
@@ -372,7 +381,24 @@ def _parse_account(name: str, value: object, source: str) -> Account:
|
|
|
372
381
|
exclude = value.get("shared_exclude", [])
|
|
373
382
|
if not isinstance(exclude, list) or not all(_valid_item(item) for item in exclude):
|
|
374
383
|
raise ConfigError("{}: 'shared_exclude' must be a list of names".format(where))
|
|
375
|
-
|
|
384
|
+
renews = value.get("renews")
|
|
385
|
+
if renews is not None:
|
|
386
|
+
try:
|
|
387
|
+
renews = validate_renews(renews)
|
|
388
|
+
except ValueError:
|
|
389
|
+
raise ConfigError("{}: 'renews' must be a date YYYY-MM-DD".format(where))
|
|
390
|
+
return Account(name, proxy, shared, links, identity, env, args, directory, _unique(exclude), renews)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def validate_renews(value: object) -> str:
|
|
394
|
+
"""校验续费日:整串 YYYY-MM-DD 且是真实存在的日期(拒绝 2026-02-30)。"""
|
|
395
|
+
if not isinstance(value, str) or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
|
|
396
|
+
raise ValueError("renewal date must be YYYY-MM-DD, e.g. 2026-11-10")
|
|
397
|
+
try:
|
|
398
|
+
datetime.datetime.strptime(value, "%Y-%m-%d")
|
|
399
|
+
except ValueError:
|
|
400
|
+
raise ValueError("renewal date must be YYYY-MM-DD, e.g. 2026-11-10")
|
|
401
|
+
return value
|
|
376
402
|
|
|
377
403
|
|
|
378
404
|
def _unique(items: List[str]) -> List[str]:
|
|
@@ -230,13 +230,25 @@ class _Lock(object):
|
|
|
230
230
|
"""自己建的一把锁,以及用来确认“这个路径上还是我建的那个目录”的指纹。
|
|
231
231
|
|
|
232
232
|
指纹是设备号、inode 和自己最后一次设置的修改时间(proper-lockfile 也靠修改时间判断锁有没有被别人动过)。
|
|
233
|
-
只比 inode
|
|
233
|
+
只比 inode 不够:别人删掉重建时文件系统可能复用同一个编号。修改时间也不能交给内核取:
|
|
234
|
+
ext4 上几毫秒内删掉重建的目录,inode 和内核记的修改时间多半与原来相同(实测 2000 次里 1993 次),会把别人的锁认成自己的。
|
|
235
|
+
所以建锁和保活时都用 _set_mtime_now 写入此刻的精细时间。
|
|
236
|
+
不能改成往锁目录里放标记文件:proper-lockfile 用 rmdir 清理过期锁,目录非空就删不掉,锁会一直卡住。
|
|
234
237
|
"""
|
|
235
238
|
|
|
236
239
|
def __init__(self, path: str) -> None:
|
|
237
240
|
self.path = path
|
|
241
|
+
try:
|
|
242
|
+
self._set_mtime_now()
|
|
243
|
+
except OSError:
|
|
244
|
+
# 设不了时间就用内核写的时间记指纹:锁照样能用,只是分辨不出同一时钟节拍里的删掉重建。
|
|
245
|
+
pass
|
|
238
246
|
self._stamp = self._read()
|
|
239
247
|
|
|
248
|
+
def _set_mtime_now(self) -> None:
|
|
249
|
+
now_ns = time.time_ns()
|
|
250
|
+
os.utime(self.path, ns=(now_ns, now_ns))
|
|
251
|
+
|
|
240
252
|
def _read(self) -> Optional[Tuple[int, int, int]]:
|
|
241
253
|
try:
|
|
242
254
|
status = os.lstat(self.path)
|
|
@@ -252,7 +264,8 @@ class _Lock(object):
|
|
|
252
264
|
if not self.is_mine():
|
|
253
265
|
return
|
|
254
266
|
try:
|
|
255
|
-
os.utime(
|
|
267
|
+
# 不能用 os.utime(path, None):那样又由内核取时间,保活一次后指纹就退回分辨不出重建的状态。
|
|
268
|
+
self._set_mtime_now()
|
|
256
269
|
except OSError:
|
|
257
270
|
return
|
|
258
271
|
fresh = self._read()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multi-claude-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.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:
|
|
@@ -529,14 +542,14 @@ It never copies, exports, uploads or backs up a login. One command touches one:
|
|
|
529
542
|
| `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
543
|
| `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
544
|
| `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. |
|
|
545
|
+
| `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. |
|
|
546
|
+
| `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
547
|
| `multi-claude proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
|
|
535
548
|
| `multi-claude env (NAME \| --defaults) [K=V ...] [--unset K ...]` | Set or remove extra environment variables. |
|
|
536
549
|
| `multi-claude args (NAME \| --defaults) -- [ARG ...]` | Replace the fixed arguments; `--` with nothing after it clears them. |
|
|
537
550
|
| `multi-claude remove NAME` | Unregister an account and delete its launcher. **The directory and the login are kept.** |
|
|
538
551
|
| `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). |
|
|
552
|
+
| `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
553
|
| `multi-claude usage [NAME] [--json]` | Show the last known 5-hour and 7-day usage of each account. |
|
|
541
554
|
| `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
555
|
| `multi-claude doctor [--json] [--verbose]` | Check the configuration, launchers, account directories, shared links, logins and environment. |
|
|
@@ -7,13 +7,16 @@
|
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
|
+
import shutil
|
|
10
11
|
import signal
|
|
11
12
|
import stat
|
|
12
13
|
import subprocess
|
|
13
14
|
import sys
|
|
15
|
+
import tempfile
|
|
14
16
|
import threading
|
|
15
17
|
import time
|
|
16
18
|
import types
|
|
19
|
+
import unittest
|
|
17
20
|
from http.server import BaseHTTPRequestHandler
|
|
18
21
|
from unittest import mock
|
|
19
22
|
|
|
@@ -482,6 +485,36 @@ class RenewTest(RefreshTestCase):
|
|
|
482
485
|
self.assertNotIn("add-generic-password", call)
|
|
483
486
|
|
|
484
487
|
|
|
488
|
+
class LockStampTest(unittest.TestCase):
|
|
489
|
+
"""锁指纹里的修改时间必须是本工具写入的精细时间,不能交给内核取。
|
|
490
|
+
|
|
491
|
+
ext4 上几毫秒内删掉重建的锁目录,inode 和内核记的修改时间都与原来相同;只有自己写入的时间能分辨出来。
|
|
492
|
+
这里不依赖 inode 复用,在任何文件系统上确定性地检查“建锁和保活都写入了 time_ns 的值”。
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
# 低位非零:内核按时钟节拍记的时间不会恰好是这个值
|
|
496
|
+
STAMP_NS = 1_700_000_000_123_456_789
|
|
497
|
+
|
|
498
|
+
def setUp(self):
|
|
499
|
+
self.tmp = tempfile.mkdtemp(prefix="mcl-lock-")
|
|
500
|
+
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
501
|
+
self.path = os.path.join(self.tmp, refresh.LOCK_NAME)
|
|
502
|
+
os.mkdir(self.path)
|
|
503
|
+
|
|
504
|
+
def test_creating_and_touching_write_the_fine_clock(self):
|
|
505
|
+
# refresh.time 就是标准库 time:桩只包住这两次调用,不影响进程里的其它代码
|
|
506
|
+
with mock.patch.object(refresh.time, "time_ns", return_value=self.STAMP_NS):
|
|
507
|
+
lock = refresh._Lock(self.path)
|
|
508
|
+
self.assertEqual(os.lstat(self.path).st_mtime_ns, self.STAMP_NS)
|
|
509
|
+
self.assertTrue(lock.is_mine())
|
|
510
|
+
|
|
511
|
+
later = self.STAMP_NS + 5_000_000_000
|
|
512
|
+
with mock.patch.object(refresh.time, "time_ns", return_value=later):
|
|
513
|
+
lock.touch()
|
|
514
|
+
self.assertEqual(os.lstat(self.path).st_mtime_ns, later)
|
|
515
|
+
self.assertTrue(lock.is_mine())
|
|
516
|
+
|
|
517
|
+
|
|
485
518
|
class KeychainWriteTest(CliTestCase):
|
|
486
519
|
"""macOS 分支:凭据在钥匙串里,写回走 `security -i`;用假 security,任何平台都能跑。"""
|
|
487
520
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""方案 feature-renewal-date §8:手填续费日的写入、校验、list 显示与帮助示例。"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import unittest
|
|
14
|
+
from datetime import date, datetime, timedelta
|
|
15
|
+
|
|
16
|
+
from helpers import CliTestCase, SRC
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, SRC)
|
|
19
|
+
|
|
20
|
+
from multi_claude import cli # noqa: E402
|
|
21
|
+
from multi_claude.config import dump_config, parse_config # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _today():
|
|
25
|
+
return datetime.now().astimezone().date()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RenewsSettingTest(CliTestCase):
|
|
29
|
+
def account_data(self, name="work"):
|
|
30
|
+
return self.config_data()["accounts"][name]
|
|
31
|
+
|
|
32
|
+
def test_t1_set_records_date(self):
|
|
33
|
+
self.ok("add", "work")
|
|
34
|
+
self.ok("set", "work", "--renews", "2026-11-10")
|
|
35
|
+
self.assertEqual(self.account_data()["renews"], "2026-11-10")
|
|
36
|
+
|
|
37
|
+
def test_t2_off_removes_key(self):
|
|
38
|
+
self.ok("add", "work", "--renews", "2026-11-10")
|
|
39
|
+
self.assertEqual(self.account_data()["renews"], "2026-11-10")
|
|
40
|
+
self.ok("set", "work", "--renews", "off")
|
|
41
|
+
self.assertNotIn("renews", self.account_data())
|
|
42
|
+
|
|
43
|
+
def test_t3_bad_dates_rejected(self):
|
|
44
|
+
self.ok("add", "work", "--renews", "2026-11-10")
|
|
45
|
+
before = self.config_data()
|
|
46
|
+
for value in ("2026-02-30", "11-10", "tomorrow", "2026-11-10"):
|
|
47
|
+
with self.subTest(value=value):
|
|
48
|
+
result = self.run_cli("set", "work", "--renews", value)
|
|
49
|
+
self.assertEqual(result.code, 2, result)
|
|
50
|
+
self.assertIn("YYYY-MM-DD", result.err)
|
|
51
|
+
self.assertEqual(self.config_data(), before)
|
|
52
|
+
|
|
53
|
+
def test_t4_apply_file_validates(self):
|
|
54
|
+
self.ok("add", "work")
|
|
55
|
+
data = self.config_data()
|
|
56
|
+
path = os.path.join(self.tmp, "apply.json")
|
|
57
|
+
for bad in ("x", 5):
|
|
58
|
+
with self.subTest(value=bad):
|
|
59
|
+
data["accounts"]["work"]["renews"] = bad
|
|
60
|
+
self.write(path, json.dumps(data))
|
|
61
|
+
result = self.run_cli("apply", "-f", path)
|
|
62
|
+
self.assertEqual(result.code, 1, result)
|
|
63
|
+
self.assertIn("'renews'", result.err)
|
|
64
|
+
data["accounts"]["work"]["renews"] = "2026-11-10"
|
|
65
|
+
self.write(path, json.dumps(data))
|
|
66
|
+
self.ok("apply", "-f", path)
|
|
67
|
+
self.assertEqual(self.account_data()["renews"], "2026-11-10")
|
|
68
|
+
|
|
69
|
+
def test_t5_kept_by_other_changes(self):
|
|
70
|
+
self.ok("add", "work", "--renews", "2026-11-10")
|
|
71
|
+
self.ok("set", "work", "--proxy", "7901")
|
|
72
|
+
self.assertEqual(self.account_data()["renews"], "2026-11-10")
|
|
73
|
+
self.ok("rename", "work", "home")
|
|
74
|
+
self.assertEqual(self.account_data("home")["renews"], "2026-11-10")
|
|
75
|
+
# migrate-default 与 restore 都靠 config.copy() 复制配置。
|
|
76
|
+
with open(os.path.join(self.state, "config.json")) as handle:
|
|
77
|
+
config = parse_config(handle.read(), "config.json")
|
|
78
|
+
self.assertEqual(config.copy().accounts["home"].renews, "2026-11-10")
|
|
79
|
+
self.assertEqual(dump_config(config.copy()), dump_config(config))
|
|
80
|
+
|
|
81
|
+
def test_t10_dry_run_writes_nothing(self):
|
|
82
|
+
self.ok("add", "work")
|
|
83
|
+
before = self.config_data()
|
|
84
|
+
self.ok("set", "work", "--renews", "2026-11-10", "--dry-run")
|
|
85
|
+
self.assertEqual(self.config_data(), before)
|
|
86
|
+
|
|
87
|
+
def test_t13_apply_file_without_renews_clears_it(self):
|
|
88
|
+
self.ok("add", "work", "--renews", "2026-11-10")
|
|
89
|
+
data = self.config_data()
|
|
90
|
+
del data["accounts"]["work"]["renews"]
|
|
91
|
+
path = self.write(os.path.join(self.tmp, "apply.json"), json.dumps(data))
|
|
92
|
+
self.ok("apply", "-f", path)
|
|
93
|
+
self.assertNotIn("renews", self.account_data())
|
|
94
|
+
|
|
95
|
+
@unittest.skipUnless(shutil.which("bash"), "bash is not installed")
|
|
96
|
+
def test_t11_bash_completion(self):
|
|
97
|
+
script = self.write(os.path.join(self.tmp, "c.bash"), self.ok("completion", "bash").out)
|
|
98
|
+
runner = self.write(os.path.join(self.tmp, "run.bash"), (
|
|
99
|
+
'source "$1"\n'
|
|
100
|
+
'COMP_LINE="$2"; COMP_POINT=${#COMP_LINE}\n'
|
|
101
|
+
'COMP_WORDS=($COMP_LINE); COMP_CWORD=${#COMP_WORDS[@]}\n'
|
|
102
|
+
'_multi_claude\n'
|
|
103
|
+
'printf "%s\\n" "${COMPREPLY[@]}"\n'))
|
|
104
|
+
self.write(os.path.join(self.fakebin, "multi-claude"),
|
|
105
|
+
'#!/bin/sh\nexec "{}" -m multi_claude "$@"\n'.format(sys.executable), 0o755)
|
|
106
|
+
env = self._merged({"PATH": os.pathsep.join([os.path.dirname(sys.executable), self.fakebin,
|
|
107
|
+
"/usr/bin", "/bin"])})
|
|
108
|
+
proc = subprocess.run(["bash", runner, script, "multi-claude set x --ren"], env=env,
|
|
109
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
|
|
110
|
+
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
111
|
+
self.assertIn("--renews", proc.stdout.split())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RenewsListTest(CliTestCase):
|
|
115
|
+
def test_t6_no_renewal_dates_no_change(self):
|
|
116
|
+
self.ok("add", "work")
|
|
117
|
+
brief = self.ok("list")
|
|
118
|
+
self.assertRegex(brief.out.splitlines()[0],
|
|
119
|
+
r"^NAME\s+LOGIN\s+PLAN\s+BILLING\s+PROXY\s+SHARED\s+5H\s+7D\s+LAST USED\s+STATUS$")
|
|
120
|
+
self.assertNotIn("renewal date", brief.err)
|
|
121
|
+
verbose = self.ok("list", "--verbose")
|
|
122
|
+
header = [line for line in verbose.out.splitlines() if line.startswith("NAME")][0]
|
|
123
|
+
self.assertNotIn("RENEWS", header)
|
|
124
|
+
self.assertNotIn("renewal date", verbose.err)
|
|
125
|
+
as_json = self.ok("list", "--json")
|
|
126
|
+
self.assertNotIn("renewal date", as_json.err)
|
|
127
|
+
data = json.loads(as_json.out)
|
|
128
|
+
self.assertEqual([entry["renews"] for entry in data["accounts"]], [None])
|
|
129
|
+
|
|
130
|
+
def _list_with_dates(self):
|
|
131
|
+
"""一个账号 +25 天、另一个 -6 天;前后两次取到的今天不同(跨午夜)就重跑一次。"""
|
|
132
|
+
for _attempt in range(2):
|
|
133
|
+
today = _today()
|
|
134
|
+
self.ok("set", "soon", "--renews", (today + timedelta(days=25)).isoformat())
|
|
135
|
+
self.ok("set", "late", "--renews", (today - timedelta(days=6)).isoformat())
|
|
136
|
+
brief = self.ok("list")
|
|
137
|
+
offline = self.ok("list", "--offline")
|
|
138
|
+
verbose = self.ok("list", "--verbose")
|
|
139
|
+
as_json = self.ok("list", "--json")
|
|
140
|
+
if _today() == today:
|
|
141
|
+
return brief, offline, verbose, as_json
|
|
142
|
+
self.fail("the date changed twice while the test was running")
|
|
143
|
+
|
|
144
|
+
def test_t7b_and_t9_columns_and_json(self):
|
|
145
|
+
self.ok("add", "soon")
|
|
146
|
+
self.ok("add", "late")
|
|
147
|
+
brief, offline, verbose, as_json = self._list_with_dates()
|
|
148
|
+
for result in (brief, offline):
|
|
149
|
+
header = result.out.splitlines()[0]
|
|
150
|
+
self.assertIn("RENEWS", header)
|
|
151
|
+
self.assertLess(header.index("RENEWS"), header.index("PROXY"))
|
|
152
|
+
self.assertRegex(result.out, r"\nsoon\s.*\(25d\)\s")
|
|
153
|
+
self.assertRegex(result.out, r"\nlate\s.*\(6d ago\)\s")
|
|
154
|
+
self.assertIn("LOGIN RENEWS", offline.out.splitlines()[0])
|
|
155
|
+
self.assertIn("BILLING RENEWS", brief.out.splitlines()[0])
|
|
156
|
+
verbose_header = [line for line in verbose.out.splitlines() if line.startswith("NAME")][0]
|
|
157
|
+
self.assertLess(verbose_header.index("RENEWS"), verbose_header.index("LINK"))
|
|
158
|
+
for result in (brief, offline, verbose, as_json):
|
|
159
|
+
self.assertIn("renewal date has passed for late", result.err)
|
|
160
|
+
self.assertNotIn("soon", result.err.split("renewal date has passed for")[1].splitlines()[0])
|
|
161
|
+
data = json.loads(as_json.out)
|
|
162
|
+
days = {entry["name"]: entry["renews"]["days_left"] for entry in data["accounts"]}
|
|
163
|
+
self.assertEqual(days, {"soon": 25, "late": -6})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class RenewsCellTest(unittest.TestCase):
|
|
167
|
+
def test_t7_cells(self):
|
|
168
|
+
today = date(2026, 10, 16)
|
|
169
|
+
cases = [("2026-11-10", "11-10 (25d)"), ("2026-10-19", "10-19 (3d!)"), ("2026-10-16", "10-16 (today!)"),
|
|
170
|
+
("2026-10-10", "10-10 (6d ago)"), ("2027-01-05", "2027-01-05 (81d)"), (None, "-")]
|
|
171
|
+
for value, shown in cases:
|
|
172
|
+
with self.subTest(value=value):
|
|
173
|
+
self.assertEqual(cli._renews_cell(value, today), shown)
|
|
174
|
+
self.assertEqual(cli._renews_dict("2026-10-10", today), {"date": "2026-10-10", "days_left": -6})
|
|
175
|
+
self.assertIsNone(cli._renews_dict(None, today))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class RenewsHelpTest(unittest.TestCase):
|
|
179
|
+
def help_text(self, *argv):
|
|
180
|
+
out = io.StringIO()
|
|
181
|
+
with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as raised:
|
|
182
|
+
cli.build_parser().parse_args(list(argv) + ["--help"])
|
|
183
|
+
self.assertEqual(raised.exception.code, 0)
|
|
184
|
+
return out.getvalue()
|
|
185
|
+
|
|
186
|
+
def example_commands(self, text):
|
|
187
|
+
self.assertIn("Examples:", text)
|
|
188
|
+
lines = text.split("Examples:", 1)[1].splitlines()
|
|
189
|
+
commands = []
|
|
190
|
+
for line in lines[1:]:
|
|
191
|
+
if not line.startswith(" "):
|
|
192
|
+
break
|
|
193
|
+
commands.append(re.split(r"\s{2,}", line.strip())[0])
|
|
194
|
+
return commands
|
|
195
|
+
|
|
196
|
+
def parse_example(self, command):
|
|
197
|
+
words = shlex.split(command)
|
|
198
|
+
self.assertEqual(words[0], "multi-claude")
|
|
199
|
+
if "--help" in words:
|
|
200
|
+
with contextlib.redirect_stdout(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
|
201
|
+
cli.build_parser().parse_args(words[1:])
|
|
202
|
+
self.assertEqual(raised.exception.code, 0)
|
|
203
|
+
return None
|
|
204
|
+
return cli.build_parser().parse_args(words[1:])
|
|
205
|
+
|
|
206
|
+
def subparser(self, name):
|
|
207
|
+
parser = cli.build_parser()
|
|
208
|
+
for action in parser._actions:
|
|
209
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
210
|
+
return action.choices[name]
|
|
211
|
+
raise AssertionError("no subparsers")
|
|
212
|
+
|
|
213
|
+
def test_t8_examples_parse_and_cover_options(self):
|
|
214
|
+
for argv in ((), ("set",), ("add",), ("list",)):
|
|
215
|
+
with self.subTest(command=argv):
|
|
216
|
+
commands = self.example_commands(self.help_text(*argv))
|
|
217
|
+
self.assertTrue(commands)
|
|
218
|
+
for command in commands:
|
|
219
|
+
self.parse_example(command)
|
|
220
|
+
if not argv:
|
|
221
|
+
continue
|
|
222
|
+
options = {option for action in self.subparser(argv[0])._actions
|
|
223
|
+
for option in action.option_strings} - {"-h", "--help"}
|
|
224
|
+
if argv[0] in ("set", "add"):
|
|
225
|
+
options.discard("--verbose")
|
|
226
|
+
used = {word for command in commands for word in shlex.split(command)}
|
|
227
|
+
self.assertEqual(sorted(options - used), [])
|
|
228
|
+
|
|
229
|
+
def test_t14_main_examples_keep_descriptions(self):
|
|
230
|
+
lines = self.help_text().split("Examples:", 1)[1].splitlines()
|
|
231
|
+
for command, text in cli._EXAMPLES:
|
|
232
|
+
if not text:
|
|
233
|
+
continue
|
|
234
|
+
with self.subTest(command=command):
|
|
235
|
+
self.assertTrue(any(command + " " in line and line.endswith(text) for line in lines))
|
|
236
|
+
|
|
237
|
+
def test_descriptions_fit_80_columns(self):
|
|
238
|
+
for name in ("set", "add", "list"):
|
|
239
|
+
with self.subTest(command=name):
|
|
240
|
+
for line in (self.subparser(name).description or "").splitlines():
|
|
241
|
+
self.assertLessEqual(len(line), 80)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{multi_claude_cli-0.12.0 → multi_claude_cli-0.13.0}/src/multi_claude_cli.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|