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