multi-claude-cli 0.4.0__tar.gz → 0.5.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.4.0 → multi_claude_cli-0.5.0}/PKG-INFO +29 -7
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/README.md +28 -6
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/__init__.py +1 -1
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/cli.py +220 -13
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/completion.py +2 -1
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/migrate.py +9 -6
- multi_claude_cli-0.5.0/src/multi_claude/restore.py +149 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude_cli.egg-info/PKG-INFO +29 -7
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude_cli.egg-info/SOURCES.txt +2 -0
- multi_claude_cli-0.5.0/tests/test_everyday.py +340 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/LICENSE +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/pyproject.toml +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/setup.cfg +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/__main__.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/accounts.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/actions.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/config.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/doctor.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/env.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/fsutil.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/hook.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/identity.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/launcher.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/lock.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/platform.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/routes.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/sessions.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/shared.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/shellpath.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/statusline.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/usage.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude_cli.egg-info/dependency_links.txt +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude_cli.egg-info/entry_points.txt +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude_cli.egg-info/top_level.txt +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_accounts.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_completion.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_config.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_doctor.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_fsutil.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_handoff.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_help.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_identity.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_install.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_mcp.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_migrate.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_onboarding.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_packaging.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_platform.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_rename.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_routes.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_set.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_shared_exclude.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_statusline.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_usability.py +0 -0
- {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_usage.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.5.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
|
|
@@ -31,6 +31,7 @@ Dynamic: license-file
|
|
|
31
31
|
|
|
32
32
|
[](https://github.com/jakoes-wu/multi-claude/releases)
|
|
33
33
|
[](https://github.com/jakoes-wu/multi-claude/actions/workflows/ci.yml)
|
|
34
|
+
[](https://pypi.org/project/multi-claude-cli/)
|
|
34
35
|

|
|
35
36
|
[](LICENSE)
|
|
36
37
|
|
|
@@ -96,7 +97,9 @@ The tool goes to `~/.local/share/multi-claude` and the `multi-claude` command to
|
|
|
96
97
|
|
|
97
98
|
The `multi-claude` command runs with the `python3` found on `PATH` at install time. If that is a version-manager shim (pyenv, asdf, mise), the installer writes the interpreter behind it instead: a shim adds tens of milliseconds to every start, and with the statusline hook installed that happens on every status-line redraw. Run the installer again after removing that Python version.
|
|
98
99
|
|
|
99
|
-
Alternatively: `pipx install
|
|
100
|
+
Alternatively, from PyPI: `pipx install multi-claude-cli` (upgrade with `pipx upgrade multi-claude-cli`). The command is still `multi-claude`. Use either pipx or the installer, not both: they put the same command in `~/.local/bin`.
|
|
101
|
+
|
|
102
|
+
With Homebrew: `brew install jakoes-wu/tap/multi-claude` (upgrade with `brew upgrade multi-claude`). It installs into Homebrew's own prefix, so remove other installations first to avoid two `multi-claude` commands on your `PATH`.
|
|
100
103
|
|
|
101
104
|
**Verified downloads.** From v0.4.0 on, every release publishes `multi-claude-<tag>.tar.gz` and `SHA256SUMS`. The remote installer downloads that archive and checks its SHA-256 before installing anything; a mismatch stops the installation. Branches and older releases are installed unverified (the installer says so); set `MULTI_CLAUDE_REQUIRE_CHECKSUM=1` to refuse them. The checksum is published next to the archive, so it protects against a damaged or altered download, not against a compromised GitHub account.
|
|
102
105
|
|
|
@@ -269,6 +272,20 @@ multi-claude mcp client-a list
|
|
|
269
272
|
|
|
270
273
|
`mcp` runs `claude mcp ...` with the account's configuration directory, proxy and extra environment variables, but without its fixed arguments (options such as `--allowedTools` would otherwise swallow the `mcp` subcommand). Claude Code itself writes the server configuration. Use `--scope user` for a server that should be available in every project of that account.
|
|
271
274
|
|
|
275
|
+
### Running other commands as an account
|
|
276
|
+
|
|
277
|
+
```sh
|
|
278
|
+
multi-claude run work -- claude --version # any command, with exactly the environment of claude-work
|
|
279
|
+
multi-claude run work # same as claude-work
|
|
280
|
+
multi-claude run -- env # without NAME: the account claude-auto would use here
|
|
281
|
+
cd "$(multi-claude path work)" # the account's directory
|
|
282
|
+
multi-claude add client-b --config-from work # start client-b with a copy of work's settings.json
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`run` sets the same `CLAUDE_CONFIG_DIR`, proxy and extra variables as the launcher; everything after the first `--` is run unchanged and its exit code is returned. Without a command it runs `claude` with the account's fixed arguments. Without NAME it follows the routes of `claude-auto` and prints the chosen account on stderr; with no route and no default it asks for NAME. `path` exits with 1 if the directory is missing.
|
|
286
|
+
|
|
287
|
+
`--config-from` copies `settings.json` once; afterwards the two files are independent. A different existing `settings.json` in the new account, or a `settings.json` that the account shares, is a conflict and nothing is changed. Use [shared resources](#shared-resources) for settings that should stay the same in every account.
|
|
288
|
+
|
|
272
289
|
### Diagnostics and completion
|
|
273
290
|
|
|
274
291
|
`multi-claude doctor` checks, without changing anything: the configuration and any unfinished migration, whether `claude` and the launcher directory are on `PATH`, whether each launcher is up to date and not hidden by another file of the same name, whether each account directory exists and is private (`0700`), the default account's link, broken shared links, whether a login exists, and variables such as `ANTHROPIC_API_KEY` that override the accounts' logins. Every problem comes with the command that fixes it. It exits with 1 when there is an error; `--json` prints the results for scripts.
|
|
@@ -283,7 +300,7 @@ multi-claude completion fish > ~/.config/fish/completions/multi-claude.fish
|
|
|
283
300
|
|
|
284
301
|
The default account is made of three parts that Claude Code finds only when `CLAUDE_CONFIG_DIR` is **not** set: the directory `~/.claude`, the file `~/.claude.json`, and (on macOS) the keychain item `Claude Code-credentials`. On macOS, the keychain item of every other account is tied to the exact path string in `CLAUDE_CONFIG_DIR`, so the default account cannot simply be pointed at a new path.
|
|
285
302
|
|
|
286
|
-
`multi-claude migrate-default main` therefore:
|
|
303
|
+
`multi-claude migrate-default main` therefore (without a name it uses the email address of the login in `~/.claude.json`):
|
|
287
304
|
|
|
288
305
|
1. moves `~/.claude` to `~/.cc/main` (rename on the same file system; otherwise copy, verify every file by SHA-256, and park the original as `~/.claude.multi-claude-bak.<timestamp>`);
|
|
289
306
|
2. leaves the link `~/.claude -> ~/.cc/main`;
|
|
@@ -308,6 +325,8 @@ Sockets and FIFOs are not copied in copy mode. On macOS, copy mode does not pres
|
|
|
308
325
|
|
|
309
326
|
### Undoing a migration by hand
|
|
310
327
|
|
|
328
|
+
`multi-claude restore main` undoes the migration: it removes the link, moves `~/.cc/main` back to `~/.claude` and unregisters the account (its launcher is deleted; shared links inside the directory stay). It checks for running Claude Code sessions like `migrate-default` (`--skip-process-check` skips that) and can be run again if it is interrupted. It refuses, without changing anything, when a route still uses the account, when `~/.claude` points elsewhere, or when the two directories are on different file systems. By hand, the same steps are:
|
|
329
|
+
|
|
311
330
|
```sh
|
|
312
331
|
rm ~/.claude # remove the link (only the link)
|
|
313
332
|
mv ~/.cc/main ~/.claude # move the data back
|
|
@@ -322,7 +341,7 @@ If a migration stops with an error and you want to abandon it: the error message
|
|
|
322
341
|
|
|
323
342
|
### How do I upgrade multi-claude?
|
|
324
343
|
|
|
325
|
-
|
|
344
|
+
With pipx, run `pipx upgrade multi-claude-cli`; with Homebrew, `brew upgrade multi-claude`. Otherwise run the installer again: `curl -fsSL https://raw.githubusercontent.com/jakoes-wu/multi-claude/main/install.sh | sh`, or `./install.sh` from an updated clone. It replaces only the tool; your configuration, accounts and launchers stay. When a release changes what launchers contain, `multi-claude doctor` reports them as stale and `multi-claude apply` rewrites them.
|
|
326
345
|
|
|
327
346
|
### Which account does plain `claude` use?
|
|
328
347
|
|
|
@@ -351,8 +370,9 @@ No. It never reads, copies or deletes credentials; its only keychain call checks
|
|
|
351
370
|
| Command | What it does |
|
|
352
371
|
| ---- | ---- |
|
|
353
372
|
| `multi-claude init [--root DIR] [--bin-dir DIR] [--shared-dir DIR] [--shared-items A,B]` | Create or change global settings. |
|
|
354
|
-
| `multi-claude migrate-default NAME [--copy] [--keep-backup] [--proxy P] [--skip-process-check]` | Turn `~/.claude` into an account. |
|
|
355
|
-
| `multi-claude
|
|
373
|
+
| `multi-claude migrate-default [NAME] [--copy] [--keep-backup] [--proxy P] [--skip-process-check]` | Turn `~/.claude` into an account. Without NAME, the email address of its login is used. |
|
|
374
|
+
| `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. |
|
|
375
|
+
| `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. |
|
|
356
376
|
| `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. |
|
|
357
377
|
| `multi-claude proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
|
|
358
378
|
| `multi-claude env (NAME \| --defaults) [K=V ...] [--unset K ...]` | Set or remove extra environment variables. |
|
|
@@ -367,6 +387,8 @@ No. It never reads, copies or deletes credentials; its only keychain call checks
|
|
|
367
387
|
| `multi-claude rename OLD NEW` | Rename an account and its launcher. The directory and the login stay. |
|
|
368
388
|
| `multi-claude login NAME [ARG ...]` | Sign in to that account (runs `claude auth login ARG ...` as it; an e-mail-like name is pre-filled with `--email`). |
|
|
369
389
|
| `multi-claude mcp NAME [ARG ...]` | Run `claude mcp ARG ...` as that account. |
|
|
390
|
+
| `multi-claude run [NAME] [-- COMMAND ...]` | Run a command (default: `claude` with the fixed arguments) with the environment of the account's launcher. Without NAME, the account `claude-auto` would use. |
|
|
391
|
+
| `multi-claude path NAME` | Print the account's directory. |
|
|
370
392
|
| `multi-claude handoff TARGET [--from NAME] [--session ID] [--force]` | Copy a session to another account and print the command that resumes it there. |
|
|
371
393
|
| `multi-claude statusline install\|uninstall FILE` | Wrap the statusLine command in a settings file so that `usage` gets fresh numbers, or restore it. |
|
|
372
394
|
| `multi-claude completion bash\|zsh\|fish` | Print a shell completion script. |
|
|
@@ -434,7 +456,7 @@ If you move an account directory yourself, its launcher reports that the directo
|
|
|
434
456
|
| 1 | Runtime error (I/O, invalid configuration file, failed verification, lock held by another command, account not registered) |
|
|
435
457
|
| 2 | Invalid command-line arguments |
|
|
436
458
|
| 3 | Conflict with files multi-claude does not own, or a change that would break a login; nothing was changed |
|
|
437
|
-
| 4 | The
|
|
459
|
+
| 4 | The directory to migrate or restore is in use |
|
|
438
460
|
|
|
439
461
|
## Uninstalling
|
|
440
462
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/jakoes-wu/multi-claude/releases)
|
|
6
6
|
[](https://github.com/jakoes-wu/multi-claude/actions/workflows/ci.yml)
|
|
7
|
+
[](https://pypi.org/project/multi-claude-cli/)
|
|
7
8
|

|
|
8
9
|
[](LICENSE)
|
|
9
10
|
|
|
@@ -69,7 +70,9 @@ The tool goes to `~/.local/share/multi-claude` and the `multi-claude` command to
|
|
|
69
70
|
|
|
70
71
|
The `multi-claude` command runs with the `python3` found on `PATH` at install time. If that is a version-manager shim (pyenv, asdf, mise), the installer writes the interpreter behind it instead: a shim adds tens of milliseconds to every start, and with the statusline hook installed that happens on every status-line redraw. Run the installer again after removing that Python version.
|
|
71
72
|
|
|
72
|
-
Alternatively: `pipx install
|
|
73
|
+
Alternatively, from PyPI: `pipx install multi-claude-cli` (upgrade with `pipx upgrade multi-claude-cli`). The command is still `multi-claude`. Use either pipx or the installer, not both: they put the same command in `~/.local/bin`.
|
|
74
|
+
|
|
75
|
+
With Homebrew: `brew install jakoes-wu/tap/multi-claude` (upgrade with `brew upgrade multi-claude`). It installs into Homebrew's own prefix, so remove other installations first to avoid two `multi-claude` commands on your `PATH`.
|
|
73
76
|
|
|
74
77
|
**Verified downloads.** From v0.4.0 on, every release publishes `multi-claude-<tag>.tar.gz` and `SHA256SUMS`. The remote installer downloads that archive and checks its SHA-256 before installing anything; a mismatch stops the installation. Branches and older releases are installed unverified (the installer says so); set `MULTI_CLAUDE_REQUIRE_CHECKSUM=1` to refuse them. The checksum is published next to the archive, so it protects against a damaged or altered download, not against a compromised GitHub account.
|
|
75
78
|
|
|
@@ -242,6 +245,20 @@ multi-claude mcp client-a list
|
|
|
242
245
|
|
|
243
246
|
`mcp` runs `claude mcp ...` with the account's configuration directory, proxy and extra environment variables, but without its fixed arguments (options such as `--allowedTools` would otherwise swallow the `mcp` subcommand). Claude Code itself writes the server configuration. Use `--scope user` for a server that should be available in every project of that account.
|
|
244
247
|
|
|
248
|
+
### Running other commands as an account
|
|
249
|
+
|
|
250
|
+
```sh
|
|
251
|
+
multi-claude run work -- claude --version # any command, with exactly the environment of claude-work
|
|
252
|
+
multi-claude run work # same as claude-work
|
|
253
|
+
multi-claude run -- env # without NAME: the account claude-auto would use here
|
|
254
|
+
cd "$(multi-claude path work)" # the account's directory
|
|
255
|
+
multi-claude add client-b --config-from work # start client-b with a copy of work's settings.json
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`run` sets the same `CLAUDE_CONFIG_DIR`, proxy and extra variables as the launcher; everything after the first `--` is run unchanged and its exit code is returned. Without a command it runs `claude` with the account's fixed arguments. Without NAME it follows the routes of `claude-auto` and prints the chosen account on stderr; with no route and no default it asks for NAME. `path` exits with 1 if the directory is missing.
|
|
259
|
+
|
|
260
|
+
`--config-from` copies `settings.json` once; afterwards the two files are independent. A different existing `settings.json` in the new account, or a `settings.json` that the account shares, is a conflict and nothing is changed. Use [shared resources](#shared-resources) for settings that should stay the same in every account.
|
|
261
|
+
|
|
245
262
|
### Diagnostics and completion
|
|
246
263
|
|
|
247
264
|
`multi-claude doctor` checks, without changing anything: the configuration and any unfinished migration, whether `claude` and the launcher directory are on `PATH`, whether each launcher is up to date and not hidden by another file of the same name, whether each account directory exists and is private (`0700`), the default account's link, broken shared links, whether a login exists, and variables such as `ANTHROPIC_API_KEY` that override the accounts' logins. Every problem comes with the command that fixes it. It exits with 1 when there is an error; `--json` prints the results for scripts.
|
|
@@ -256,7 +273,7 @@ multi-claude completion fish > ~/.config/fish/completions/multi-claude.fish
|
|
|
256
273
|
|
|
257
274
|
The default account is made of three parts that Claude Code finds only when `CLAUDE_CONFIG_DIR` is **not** set: the directory `~/.claude`, the file `~/.claude.json`, and (on macOS) the keychain item `Claude Code-credentials`. On macOS, the keychain item of every other account is tied to the exact path string in `CLAUDE_CONFIG_DIR`, so the default account cannot simply be pointed at a new path.
|
|
258
275
|
|
|
259
|
-
`multi-claude migrate-default main` therefore:
|
|
276
|
+
`multi-claude migrate-default main` therefore (without a name it uses the email address of the login in `~/.claude.json`):
|
|
260
277
|
|
|
261
278
|
1. moves `~/.claude` to `~/.cc/main` (rename on the same file system; otherwise copy, verify every file by SHA-256, and park the original as `~/.claude.multi-claude-bak.<timestamp>`);
|
|
262
279
|
2. leaves the link `~/.claude -> ~/.cc/main`;
|
|
@@ -281,6 +298,8 @@ Sockets and FIFOs are not copied in copy mode. On macOS, copy mode does not pres
|
|
|
281
298
|
|
|
282
299
|
### Undoing a migration by hand
|
|
283
300
|
|
|
301
|
+
`multi-claude restore main` undoes the migration: it removes the link, moves `~/.cc/main` back to `~/.claude` and unregisters the account (its launcher is deleted; shared links inside the directory stay). It checks for running Claude Code sessions like `migrate-default` (`--skip-process-check` skips that) and can be run again if it is interrupted. It refuses, without changing anything, when a route still uses the account, when `~/.claude` points elsewhere, or when the two directories are on different file systems. By hand, the same steps are:
|
|
302
|
+
|
|
284
303
|
```sh
|
|
285
304
|
rm ~/.claude # remove the link (only the link)
|
|
286
305
|
mv ~/.cc/main ~/.claude # move the data back
|
|
@@ -295,7 +314,7 @@ If a migration stops with an error and you want to abandon it: the error message
|
|
|
295
314
|
|
|
296
315
|
### How do I upgrade multi-claude?
|
|
297
316
|
|
|
298
|
-
|
|
317
|
+
With pipx, run `pipx upgrade multi-claude-cli`; with Homebrew, `brew upgrade multi-claude`. Otherwise run the installer again: `curl -fsSL https://raw.githubusercontent.com/jakoes-wu/multi-claude/main/install.sh | sh`, or `./install.sh` from an updated clone. It replaces only the tool; your configuration, accounts and launchers stay. When a release changes what launchers contain, `multi-claude doctor` reports them as stale and `multi-claude apply` rewrites them.
|
|
299
318
|
|
|
300
319
|
### Which account does plain `claude` use?
|
|
301
320
|
|
|
@@ -324,8 +343,9 @@ No. It never reads, copies or deletes credentials; its only keychain call checks
|
|
|
324
343
|
| Command | What it does |
|
|
325
344
|
| ---- | ---- |
|
|
326
345
|
| `multi-claude init [--root DIR] [--bin-dir DIR] [--shared-dir DIR] [--shared-items A,B]` | Create or change global settings. |
|
|
327
|
-
| `multi-claude migrate-default NAME [--copy] [--keep-backup] [--proxy P] [--skip-process-check]` | Turn `~/.claude` into an account. |
|
|
328
|
-
| `multi-claude
|
|
346
|
+
| `multi-claude migrate-default [NAME] [--copy] [--keep-backup] [--proxy P] [--skip-process-check]` | Turn `~/.claude` into an account. Without NAME, the email address of its login is used. |
|
|
347
|
+
| `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. |
|
|
348
|
+
| `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. |
|
|
329
349
|
| `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. |
|
|
330
350
|
| `multi-claude proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
|
|
331
351
|
| `multi-claude env (NAME \| --defaults) [K=V ...] [--unset K ...]` | Set or remove extra environment variables. |
|
|
@@ -340,6 +360,8 @@ No. It never reads, copies or deletes credentials; its only keychain call checks
|
|
|
340
360
|
| `multi-claude rename OLD NEW` | Rename an account and its launcher. The directory and the login stay. |
|
|
341
361
|
| `multi-claude login NAME [ARG ...]` | Sign in to that account (runs `claude auth login ARG ...` as it; an e-mail-like name is pre-filled with `--email`). |
|
|
342
362
|
| `multi-claude mcp NAME [ARG ...]` | Run `claude mcp ARG ...` as that account. |
|
|
363
|
+
| `multi-claude run [NAME] [-- COMMAND ...]` | Run a command (default: `claude` with the fixed arguments) with the environment of the account's launcher. Without NAME, the account `claude-auto` would use. |
|
|
364
|
+
| `multi-claude path NAME` | Print the account's directory. |
|
|
343
365
|
| `multi-claude handoff TARGET [--from NAME] [--session ID] [--force]` | Copy a session to another account and print the command that resumes it there. |
|
|
344
366
|
| `multi-claude statusline install\|uninstall FILE` | Wrap the statusLine command in a settings file so that `usage` gets fresh numbers, or restore it. |
|
|
345
367
|
| `multi-claude completion bash\|zsh\|fish` | Print a shell completion script. |
|
|
@@ -407,7 +429,7 @@ If you move an account directory yourself, its launcher reports that the directo
|
|
|
407
429
|
| 1 | Runtime error (I/O, invalid configuration file, failed verification, lock held by another command, account not registered) |
|
|
408
430
|
| 2 | Invalid command-line arguments |
|
|
409
431
|
| 3 | Conflict with files multi-claude does not own, or a change that would break a login; nothing was changed |
|
|
410
|
-
| 4 | The
|
|
432
|
+
| 4 | The directory to migrate or restore is in use |
|
|
411
433
|
|
|
412
434
|
## Uninstalling
|
|
413
435
|
|
|
@@ -12,19 +12,20 @@ import platform as py_platform
|
|
|
12
12
|
import re
|
|
13
13
|
import shlex
|
|
14
14
|
import shutil
|
|
15
|
+
import stat
|
|
15
16
|
import subprocess
|
|
16
17
|
import sys
|
|
17
18
|
from datetime import datetime, timedelta, timezone
|
|
18
19
|
from typing import List, Optional, Tuple
|
|
19
20
|
|
|
20
|
-
from . import (__version__, accounts, completion, doctor, hook, identity, migrate, platform,
|
|
21
|
-
shellpath, statusline, usage)
|
|
21
|
+
from . import (__version__, accounts, completion, doctor, hook, identity, launcher, migrate, platform, restore,
|
|
22
|
+
routes, sessions, shellpath, statusline, usage)
|
|
22
23
|
from .actions import error, info, warn
|
|
23
24
|
from .config import (DEFAULT_SHARED_DIR, DEFAULT_SHARED_ITEMS, IDENTITY_DEFAULT, IDENTITY_DIR, Account, Config, ConfigError, RouteRule,
|
|
24
25
|
is_unshareable, load_config, normalize_proxy, parse_config, validate_env_key, validate_name,
|
|
25
26
|
validate_route_path, validate_value, config_path)
|
|
26
27
|
from .env import account_env
|
|
27
|
-
from .fsutil import expand
|
|
28
|
+
from .fsutil import KIND_FILE, KIND_LINK, KIND_MISSING, atomic_write, entry_kind, expand, read_text
|
|
28
29
|
from .lock import LockBusyError, WriteLock
|
|
29
30
|
|
|
30
31
|
# 源码中出现、官方文档未列出的代理变量(方案 §10):`off` 一期不清除它们,list 时只提示。
|
|
@@ -47,13 +48,14 @@ COMMAND_GROUPS = [
|
|
|
47
48
|
("set NAME", "change an account: proxy and sharing"),
|
|
48
49
|
("proxy, env, args", "set the proxy, extra variables or fixed arguments of a launcher"),
|
|
49
50
|
("rename, remove", "rename or unregister an account"),
|
|
50
|
-
("migrate-default", "turn the existing ~/.claude into an account"),
|
|
51
|
+
("migrate-default, restore", "turn the existing ~/.claude into an account, or back"),
|
|
51
52
|
]),
|
|
52
53
|
("Everyday", [
|
|
53
54
|
("usage", "5-hour and 7-day usage of each account"),
|
|
54
55
|
("route, which", "choose an account by directory (launcher claude-auto)"),
|
|
55
56
|
("handoff", "copy a session to another account"),
|
|
56
57
|
("mcp", "run `claude mcp` as an account"),
|
|
58
|
+
("run, path", "run any command as an account; print its directory"),
|
|
57
59
|
]),
|
|
58
60
|
("Setup and checks", [
|
|
59
61
|
("init, apply", "global settings; converge everything to config.json"),
|
|
@@ -116,7 +118,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
116
118
|
|
|
117
119
|
p_mig = sub.add_parser("migrate-default", description="turn the default ~/.claude into a named account "
|
|
118
120
|
"without losing its login")
|
|
119
|
-
|
|
121
|
+
# 省略时依次取:未完成迁移记录里的名称、已有 default 身份账号的名称、~/.claude.json 里登录账号的邮箱。
|
|
122
|
+
p_mig.add_argument("name", nargs="?")
|
|
120
123
|
p_mig.add_argument("--copy", action="store_true",
|
|
121
124
|
help="copy and verify instead of renaming (used automatically across file systems)")
|
|
122
125
|
p_mig.add_argument("--keep-backup", action="store_true",
|
|
@@ -128,6 +131,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
128
131
|
|
|
129
132
|
p_add = sub.add_parser("add", description="add an account, adopt an existing directory, or change its options")
|
|
130
133
|
_add_account_options(p_add, adopt=True)
|
|
134
|
+
p_add.add_argument("--config-from", metavar="OTHER",
|
|
135
|
+
help="copy settings.json from account OTHER into this account once")
|
|
131
136
|
|
|
132
137
|
p_set = sub.add_parser("set", description="change an existing account: proxy and sharing "
|
|
133
138
|
"(use env, args, rename for the rest)")
|
|
@@ -203,6 +208,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
203
208
|
p_which = sub.add_parser("which", description="show which account claude-auto would use in DIR (default: here)")
|
|
204
209
|
p_which.add_argument("path", nargs="?", metavar="DIR")
|
|
205
210
|
|
|
211
|
+
# run 的命令在 argparse 之前从 argv 里切走(见 _split_args_command)。
|
|
212
|
+
p_run = sub.add_parser("run", description="run a command with the environment of an account's launcher: "
|
|
213
|
+
"run [NAME] [-- COMMAND ...]; without COMMAND it runs claude, "
|
|
214
|
+
"without NAME it uses the account claude-auto would choose here")
|
|
215
|
+
p_run.add_argument("name", nargs="?")
|
|
216
|
+
|
|
217
|
+
p_path = sub.add_parser("path", description="print the directory of an account")
|
|
218
|
+
p_path.add_argument("name")
|
|
219
|
+
|
|
220
|
+
p_restore = sub.add_parser("restore", description="undo migrate-default: move the account directory back to "
|
|
221
|
+
"~/.claude and unregister the account; the login is kept")
|
|
222
|
+
p_restore.add_argument("name")
|
|
223
|
+
p_restore.add_argument("--skip-process-check", action="store_true",
|
|
224
|
+
help="do not check whether Claude Code is running (at your own risk)")
|
|
225
|
+
_add_dry_run(p_restore)
|
|
226
|
+
|
|
206
227
|
p_completion = sub.add_parser("completion", description="print a shell completion script")
|
|
207
228
|
p_completion.add_argument("shell", choices=completion.SHELLS)
|
|
208
229
|
|
|
@@ -263,7 +284,8 @@ def _add_write_options(parser: argparse.ArgumentParser) -> None:
|
|
|
263
284
|
|
|
264
285
|
def _split_args_command(argv: List[str]) -> Tuple[List[str], Optional[List[str]]]:
|
|
265
286
|
"""子命令是 `args` 时,把第一个 `--` 之后的全部内容取出来作为参数列表;
|
|
266
|
-
子命令是 `mcp` 或 `login` 时,账号名之后的全部内容原样作为 `claude mcp` / `claude auth login` 的参数(不经 argparse
|
|
287
|
+
子命令是 `mcp` 或 `login` 时,账号名之后的全部内容原样作为 `claude mcp` / `claude auth login` 的参数(不经 argparse);
|
|
288
|
+
子命令是 `run` 时,第一个 `--` 之后的内容是要运行的命令。
|
|
267
289
|
|
|
268
290
|
不能用 argparse.REMAINDER:可选位置参数加 --defaults 加 REMAINDER 时,
|
|
269
291
|
`args --defaults -- --settings s` 会被解析成账号名 `--settings`(方案 §5.1.2,实测 3.10/3.11/3.13)。
|
|
@@ -285,6 +307,12 @@ def _split_args_command(argv: List[str]) -> Tuple[List[str], Optional[List[str]]
|
|
|
285
307
|
example = "multi-claude mcp NAME list" if command == "mcp" else "multi-claude login NAME"
|
|
286
308
|
raise UsageError("{} needs an account name first, e.g. `{}`".format(command, example))
|
|
287
309
|
return argv[:command_index + 2], rest[1:]
|
|
310
|
+
if command_index is not None and argv[command_index] == "run":
|
|
311
|
+
# run 的命令从第一个 `--` 之后原样取出;没有 `--` 与 `--` 后为空都表示“没有命令”(运行 claude)。
|
|
312
|
+
if "--" not in argv[command_index + 1:]:
|
|
313
|
+
return argv, None
|
|
314
|
+
separator = argv.index("--", command_index + 1)
|
|
315
|
+
return argv[:separator], argv[separator + 1:]
|
|
288
316
|
if command_index is None or argv[command_index] != "args":
|
|
289
317
|
return argv, None
|
|
290
318
|
try:
|
|
@@ -366,6 +394,15 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
366
394
|
return accounts.EXIT_ERROR
|
|
367
395
|
# 不加写锁:凭据由 Claude 自己写入,登录过程需要浏览器交互,可能持续很久。
|
|
368
396
|
return cmd_login(args.name, args.launch_args or [])
|
|
397
|
+
if args.command in ("run", "path"):
|
|
398
|
+
if notice:
|
|
399
|
+
# 与 mcp 相同:迁移进行到一半时账号目录可能正在搬动。
|
|
400
|
+
error("finish the unfinished migration before running `{}`".format(args.command))
|
|
401
|
+
return accounts.EXIT_ERROR
|
|
402
|
+
# 只读配置、不加写锁:run 会把进程换成目标命令,可能运行很久。
|
|
403
|
+
if args.command == "path":
|
|
404
|
+
return cmd_path(args.name)
|
|
405
|
+
return cmd_run(args.name, args.launch_args or [])
|
|
369
406
|
if args.command == "mcp":
|
|
370
407
|
if notice:
|
|
371
408
|
# 迁移进行到一半时账号目录可能正在搬动,claude 写进去的内容可能落在半迁移的目录里。
|
|
@@ -417,8 +454,10 @@ def _blocked_by_migration(args: argparse.Namespace, notice: Optional[str]) -> bo
|
|
|
417
454
|
|
|
418
455
|
|
|
419
456
|
def dispatch(args: argparse.Namespace) -> int:
|
|
457
|
+
if args.command == "restore":
|
|
458
|
+
return restore.restore(_checked_name(args.name), args.skip_process_check, args.dry_run)
|
|
420
459
|
if args.command == "migrate-default":
|
|
421
|
-
name = _checked_name(args.name)
|
|
460
|
+
name = _checked_name(args.name) if args.name is not None else _default_migration_name()
|
|
422
461
|
proxy = _checked_proxy(args.proxy) if args.proxy is not None else None
|
|
423
462
|
return migrate.migrate_default(name, None, args.copy, args.keep_backup, proxy,
|
|
424
463
|
args.skip_process_check, args.dry_run)
|
|
@@ -433,6 +472,8 @@ def dispatch(args: argparse.Namespace) -> int:
|
|
|
433
472
|
renamed: Optional[Tuple[str, str]] = None
|
|
434
473
|
# --shared DIR 修改全局共享目录时记下 (旧值, 新值),收敛成功后告诉用户所有共享账号都跟着改了。
|
|
435
474
|
changed_shared_dir: Optional[Tuple[Optional[str], str]] = None
|
|
475
|
+
# add --config-from:预检通过后要写入的 (目标路径, 文本, 权限位, 来源账号名);None 表示不复制。
|
|
476
|
+
config_copy: Optional[Tuple[str, str, int, str]] = None
|
|
436
477
|
|
|
437
478
|
if args.command == "init":
|
|
438
479
|
if args.root:
|
|
@@ -481,6 +522,10 @@ def dispatch(args: argparse.Namespace) -> int:
|
|
|
481
522
|
if not account.shared:
|
|
482
523
|
raise UsageError("--adopt requires sharing to be on for {!r}; add --shared".format(account.name))
|
|
483
524
|
adopt_accounts = frozenset([account.name.casefold()])
|
|
525
|
+
if getattr(args, "config_from", None) is not None:
|
|
526
|
+
config_copy = _plan_config_copy(new, account, args.config_from, args.verbose)
|
|
527
|
+
if isinstance(config_copy, int):
|
|
528
|
+
return config_copy
|
|
484
529
|
warn_names = [account.name]
|
|
485
530
|
elif args.command == "proxy":
|
|
486
531
|
account = new.find(_checked_name(args.name))
|
|
@@ -536,6 +581,8 @@ def dispatch(args: argparse.Namespace) -> int:
|
|
|
536
581
|
if code == accounts.EXIT_OK and renamed is not None and not args.dry_run:
|
|
537
582
|
info("renamed {} to {} (directory {} unchanged)".format(
|
|
538
583
|
renamed[0], renamed[1], accounts.account_dir(new, renamed[1])))
|
|
584
|
+
if code == accounts.EXIT_OK and config_copy is not None:
|
|
585
|
+
code = _copy_settings(config_copy, args.dry_run)
|
|
539
586
|
if code == accounts.EXIT_OK:
|
|
540
587
|
accounts.warn_launch_settings(new, warn_names)
|
|
541
588
|
if adopted_dir is not None:
|
|
@@ -551,6 +598,104 @@ def dispatch(args: argparse.Namespace) -> int:
|
|
|
551
598
|
return code
|
|
552
599
|
|
|
553
600
|
|
|
601
|
+
def _default_migration_name() -> str:
|
|
602
|
+
"""migrate-default 省略名称时的名称来源(方案 feature-everyday-commands §5.1.3),按顺序:
|
|
603
|
+
|
|
604
|
+
1. 未完成迁移记录里的名称:续跑必须用同一个名字;
|
|
605
|
+
2. 已有 default 身份账号的名称:迁移后重复执行仍是 already migrated,不会因邮箱名不同而判冲突;
|
|
606
|
+
3. ~/.claude.json 里登录账号的邮箱(只读这一个字段,不读凭据)。
|
|
607
|
+
"""
|
|
608
|
+
journal = migrate.load_journal()
|
|
609
|
+
if journal is not None:
|
|
610
|
+
return journal["name"]
|
|
611
|
+
config, _ = load_config()
|
|
612
|
+
existing = config.default_account()
|
|
613
|
+
if existing is not None:
|
|
614
|
+
return existing.name
|
|
615
|
+
path = os.path.join(os.path.expanduser("~"), ".claude.json")
|
|
616
|
+
try:
|
|
617
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
618
|
+
data = json.load(handle)
|
|
619
|
+
except FileNotFoundError:
|
|
620
|
+
raise UsageError("migrate-default needs a NAME here: {} does not exist".format(path))
|
|
621
|
+
except (OSError, ValueError) as exc:
|
|
622
|
+
raise UsageError("migrate-default needs a NAME here: cannot read {} ({})".format(path, exc))
|
|
623
|
+
oauth = data.get("oauthAccount") if isinstance(data, dict) else None
|
|
624
|
+
email = oauth.get("emailAddress") if isinstance(oauth, dict) else None
|
|
625
|
+
if not isinstance(email, str) or not email:
|
|
626
|
+
raise UsageError("migrate-default needs a NAME here: {} has no login email".format(path))
|
|
627
|
+
try:
|
|
628
|
+
validate_name(email)
|
|
629
|
+
except ValueError as exc:
|
|
630
|
+
raise UsageError("migrate-default needs a NAME here: the login email cannot be an account name ({})"
|
|
631
|
+
.format(exc))
|
|
632
|
+
info("using the name {} (the email of the login in {})".format(email, path))
|
|
633
|
+
return email
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
_SETTINGS_FILE = "settings.json"
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _plan_config_copy(config: Config, account: Account, other_name: str, verbose: bool):
|
|
640
|
+
"""add --config-from 的预检,必须在收敛之前完成:任何一项不满足都退出,账号不登记、目录不创建。
|
|
641
|
+
|
|
642
|
+
返回 (目标路径, 文本, 权限位, 来源账号名)、None(目标已是相同内容,不用复制)或退出码(int)。
|
|
643
|
+
"""
|
|
644
|
+
other = config.find(_checked_name(other_name))
|
|
645
|
+
if other is None:
|
|
646
|
+
error("account {!r} is not registered".format(other_name))
|
|
647
|
+
return accounts.EXIT_ERROR
|
|
648
|
+
if other.name == account.name:
|
|
649
|
+
raise UsageError("--config-from must name another account")
|
|
650
|
+
source = os.path.join(accounts.account_dir(config, other.name), _SETTINGS_FILE)
|
|
651
|
+
source_kind = entry_kind(source)
|
|
652
|
+
if source_kind == KIND_LINK:
|
|
653
|
+
# 来源是软链说明它是共享条目:两个账号本来就读同一份,复制没有意义。
|
|
654
|
+
error("{} of {} is a link (a shared item); nothing to copy".format(_SETTINGS_FILE, other.name),
|
|
655
|
+
path=source)
|
|
656
|
+
return accounts.EXIT_ERROR
|
|
657
|
+
if source_kind != KIND_FILE:
|
|
658
|
+
error("{} has no {} to copy".format(other.name, _SETTINGS_FILE), path=source)
|
|
659
|
+
return accounts.EXIT_ERROR
|
|
660
|
+
if (account.shared and _SETTINGS_FILE in config.shared_items
|
|
661
|
+
and _SETTINGS_FILE not in account.shared_exclude):
|
|
662
|
+
# 收敛会在目标处建共享软链,复制再把它换成普通文件:共享被悄悄断开,之后每次收敛都判冲突。
|
|
663
|
+
error("{} shares {}; copying would replace the shared link. Exclude it first with "
|
|
664
|
+
"--shared-exclude {}".format(account.name, _SETTINGS_FILE, _SETTINGS_FILE))
|
|
665
|
+
return accounts.EXIT_CONFLICT
|
|
666
|
+
try:
|
|
667
|
+
with open(source, "r", encoding="utf-8") as handle:
|
|
668
|
+
text = handle.read()
|
|
669
|
+
mode = stat.S_IMODE(os.stat(source).st_mode)
|
|
670
|
+
except (OSError, ValueError) as exc:
|
|
671
|
+
error("cannot read {}: {}".format(source, exc), path=source)
|
|
672
|
+
return accounts.EXIT_ERROR
|
|
673
|
+
target = os.path.join(accounts.account_dir(config, account.name), _SETTINGS_FILE)
|
|
674
|
+
target_kind = entry_kind(target)
|
|
675
|
+
if target_kind == KIND_MISSING:
|
|
676
|
+
return (target, text, mode, other.name)
|
|
677
|
+
if target_kind == KIND_FILE and read_text(target) == text:
|
|
678
|
+
if verbose:
|
|
679
|
+
info("{} of {} already matches {} (unchanged)".format(_SETTINGS_FILE, account.name, other.name))
|
|
680
|
+
return None
|
|
681
|
+
error("{} already exists with different content; move it away first".format(target), path=target)
|
|
682
|
+
return accounts.EXIT_CONFLICT
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _copy_settings(planned: Tuple[str, str, int, str], dry_run: bool) -> int:
|
|
686
|
+
"""收敛成功后写入预检读到的文本;写之前再确认目标仍不存在,避免覆盖收敛期间出现的软链或文件。"""
|
|
687
|
+
target, text, mode, other_name = planned
|
|
688
|
+
if dry_run:
|
|
689
|
+
info("would copy {} from {}".format(_SETTINGS_FILE, other_name))
|
|
690
|
+
return accounts.EXIT_OK
|
|
691
|
+
if entry_kind(target) != KIND_MISSING:
|
|
692
|
+
warn("{} appeared while adding the account; not copied".format(target))
|
|
693
|
+
return accounts.EXIT_OK
|
|
694
|
+
atomic_write(target, text, mode=mode)
|
|
695
|
+
info("copied {} from {}".format(_SETTINGS_FILE, other_name))
|
|
696
|
+
return accounts.EXIT_OK
|
|
697
|
+
|
|
698
|
+
|
|
554
699
|
def _apply_shared_exclude(config: Config, account: Account, exclude: List[str], include: List[str]) -> None:
|
|
555
700
|
"""add 的 --shared-exclude / --shared-include:先撤销再排除。只改配置,链接由随后的收敛计划处理。"""
|
|
556
701
|
for item in exclude + include:
|
|
@@ -639,6 +784,17 @@ def _account_process_env(name: str) -> Tuple[Optional[dict], int]:
|
|
|
639
784
|
if account is None:
|
|
640
785
|
error("account {!r} is not registered".format(name))
|
|
641
786
|
return None, accounts.EXIT_ERROR
|
|
787
|
+
process_env, code = _usable_account_env(config, account)
|
|
788
|
+
if process_env is None:
|
|
789
|
+
return None, code
|
|
790
|
+
if shutil.which("claude", path=process_env.get("PATH")) is None:
|
|
791
|
+
error("claude not found in PATH")
|
|
792
|
+
return None, 127
|
|
793
|
+
return process_env, accounts.EXIT_OK
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _usable_account_env(config: Config, account: Account) -> Tuple[Optional[dict], int]:
|
|
797
|
+
"""账号能否以启动命令的身份运行:目录存在,default 身份时 ~/.claude 仍指向它。返回 (进程环境, 0) 或 (None, 1)。"""
|
|
642
798
|
directory = accounts.account_dir(config, account.name)
|
|
643
799
|
if not os.path.isdir(directory):
|
|
644
800
|
error("account directory does not exist: {}".format(directory))
|
|
@@ -648,11 +804,62 @@ def _account_process_env(name: str) -> Tuple[Optional[dict], int]:
|
|
|
648
804
|
error("{} no longer points to {}; restore the link or run multi-claude migrate-default again".format(
|
|
649
805
|
accounts.default_dir(), directory))
|
|
650
806
|
return None, accounts.EXIT_ERROR
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
807
|
+
return account_env(config, account, os.environ), accounts.EXIT_OK
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def cmd_run(name: Optional[str], command: List[str]) -> int:
|
|
811
|
+
"""以账号启动命令的环境运行任意命令(方案 feature-everyday-commands §5.1.1)。
|
|
812
|
+
|
|
813
|
+
不给命令时运行 claude 加账号的固定参数,与 claude-NAME 逐项一致;不给 NAME 时按 claude-auto 的路由选账号。
|
|
814
|
+
成功时用 exec 把本进程换成目标命令,退出码与信号都由它决定,不会返回。
|
|
815
|
+
"""
|
|
816
|
+
config, _ = load_config()
|
|
817
|
+
if name is not None:
|
|
818
|
+
account = config.find(_checked_name(name))
|
|
819
|
+
if account is None:
|
|
820
|
+
error("account {!r} is not registered".format(name))
|
|
821
|
+
return accounts.EXIT_ERROR
|
|
822
|
+
else:
|
|
823
|
+
account, rule = routes.resolve(config, os.getcwd())
|
|
824
|
+
if account is None:
|
|
825
|
+
if rule is not None or config.route_default is not None:
|
|
826
|
+
error("the route for this directory uses account {!r}, which is not registered; run "
|
|
827
|
+
"`multi-claude doctor`".format(rule.account if rule else config.route_default))
|
|
828
|
+
return accounts.EXIT_ERROR
|
|
829
|
+
raise UsageError("no route or default account applies here; give the account name, "
|
|
830
|
+
"e.g. `multi-claude run NAME`")
|
|
831
|
+
# 提示写 stderr:stdout 留给目标命令,`run -- cmd | …` 才不会混进这一行。
|
|
832
|
+
print("[multi-claude] run: using account {} ({})".format(
|
|
833
|
+
account.name, "route " + rule.path if rule is not None else "default"), file=sys.stderr)
|
|
834
|
+
process_env, code = _usable_account_env(config, account)
|
|
835
|
+
if process_env is None:
|
|
836
|
+
return code
|
|
837
|
+
if not command:
|
|
838
|
+
# 固定参数按启动命令的规则展开 `~`(launcher.render),两种启动方式收到的参数才逐项相同。
|
|
839
|
+
command = ["claude"] + [launcher.expand_value(arg) for arg in config.effective_args(account)]
|
|
840
|
+
if shutil.which(command[0], path=process_env.get("PATH")) is None:
|
|
841
|
+
error("{} not found in PATH".format(command[0]))
|
|
842
|
+
return 127
|
|
843
|
+
sys.stdout.flush()
|
|
844
|
+
sys.stderr.flush()
|
|
845
|
+
# exec 失败(权限、格式错误)抛 OSError,由 main 统一按退出码 1 报告。
|
|
846
|
+
os.execvpe(command[0], command, process_env)
|
|
847
|
+
return accounts.EXIT_ERROR # 不会执行到这里:execvpe 成功后不返回
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def cmd_path(name: str) -> int:
|
|
851
|
+
"""打印账号目录;目录不存在时照样打印,但以 1 退出,脚本里 `cd "$(multi-claude path X)"` 不会静默进错目录。"""
|
|
852
|
+
config, _ = load_config()
|
|
853
|
+
account = config.find(_checked_name(name))
|
|
854
|
+
if account is None:
|
|
855
|
+
error("account {!r} is not registered".format(name))
|
|
856
|
+
return accounts.EXIT_ERROR
|
|
857
|
+
directory = accounts.account_dir(config, account.name)
|
|
858
|
+
print(directory)
|
|
859
|
+
if not os.path.isdir(directory):
|
|
860
|
+
error("account directory does not exist: {}".format(directory))
|
|
861
|
+
return accounts.EXIT_ERROR
|
|
862
|
+
return accounts.EXIT_OK
|
|
656
863
|
|
|
657
864
|
|
|
658
865
|
def cmd_mcp(name: str, claude_args: List[str]) -> int:
|
|
@@ -809,7 +1016,7 @@ def _looks_like_path(value: str) -> bool:
|
|
|
809
1016
|
|
|
810
1017
|
|
|
811
1018
|
# add/set 里取一个值的选项:判断位置参数时要跳过它们的值。
|
|
812
|
-
_VALUE_OPTIONS = ("--proxy", "--shared-exclude", "--shared-include")
|
|
1019
|
+
_VALUE_OPTIONS = ("--proxy", "--shared-exclude", "--shared-include", "--config-from")
|
|
813
1020
|
|
|
814
1021
|
|
|
815
1022
|
def _misplaced_account_name(argv: List[str]) -> Optional[str]:
|
|
@@ -19,11 +19,12 @@ POSITIONAL_KINDS = {
|
|
|
19
19
|
"args": ["name"], "remove": ["name"], "usage": ["name"], "completion": ["shell"],
|
|
20
20
|
"route": ["dir", "name"], "which": ["dir"], "rename": ["name"], "mcp": ["name"],
|
|
21
21
|
"statusline": ["statusline", "file"], "handoff": ["name"], "login": ["name"], "set": ["name"],
|
|
22
|
+
"run": ["name"], "path": ["name"], "restore": ["name"],
|
|
22
23
|
}
|
|
23
24
|
# 选项值是路径、应按文件补全的选项。
|
|
24
25
|
PATH_OPTIONS = ("-f", "--file", "--root", "--bin-dir", "--shared-dir")
|
|
25
26
|
# 选项值是账号名的选项。
|
|
26
|
-
NAME_OPTIONS = ("--default", "--from")
|
|
27
|
+
NAME_OPTIONS = ("--default", "--from", "--config-from")
|
|
27
28
|
FIXED_WORDS = {"shell": SHELLS, "proxy": ("off", "inherit"), "statusline": ("install", "uninstall")}
|
|
28
29
|
|
|
29
30
|
|
|
@@ -298,16 +298,18 @@ def _register(config: Config, config_exists: bool, name: str, proxy: Optional[st
|
|
|
298
298
|
return accounts.converge(config, new, config_exists=config_exists, dry_run=dry_run)
|
|
299
299
|
|
|
300
300
|
|
|
301
|
-
def _busy_check(source: str) -> int:
|
|
301
|
+
def _busy_check(source: str, command: str = "migrate-default") -> int:
|
|
302
302
|
"""占用检查第 0–5 条(方案 §5.1.7):迁移期间不能有任何 Claude 进程。
|
|
303
303
|
|
|
304
|
+
restore 也复用这里(方案 feature-everyday-commands §5.1.4),command 只改变提示里的命令名。
|
|
305
|
+
|
|
304
306
|
任何账号的 Claude 进程都会写 $HOME/.claude(依据 12):rename 窗口内写入会重建真实的 S,
|
|
305
307
|
copy 模式下复制完成到 park 之间写入的内容会随备份一起被删掉。返回 0 可以继续,
|
|
306
308
|
1 检查本身失败,4 有占用。
|
|
307
309
|
"""
|
|
308
310
|
if platform.CHILD_SESSION_ENV in os.environ:
|
|
309
|
-
error("multi-claude is running inside a Claude Code session; run
|
|
310
|
-
"terminal with no Claude session", phase="busy-check", path=source)
|
|
311
|
+
error("multi-claude is running inside a Claude Code session; run {} from a plain "
|
|
312
|
+
"terminal with no Claude session".format(command), phase="busy-check", path=source)
|
|
311
313
|
print(" pid={} command=multi-claude usage=inside-claude-session path={}".format(os.getpid(), source),
|
|
312
314
|
file=sys.stderr)
|
|
313
315
|
print(" note: {} can also be inherited by screen, tmux or programs started by Claude; if you are "
|
|
@@ -348,11 +350,12 @@ def _busy_check(source: str) -> int:
|
|
|
348
350
|
if users.stop_commands:
|
|
349
351
|
print(" every Claude account writes to {} (bridge-spawn, state/, ide/), so close all Claude sessions; "
|
|
350
352
|
"to stop background sessions run:".format(source), file=sys.stderr)
|
|
351
|
-
for
|
|
352
|
-
print(" " +
|
|
353
|
+
for stop_command in users.stop_commands:
|
|
354
|
+
print(" " + stop_command, file=sys.stderr)
|
|
353
355
|
if any(process.usage == "daemon-service" for process in busy):
|
|
354
356
|
print(" the background service is installed and launchd/systemd may restart it at any time; run "
|
|
355
|
-
"`claude daemon uninstall` first and install it again after the
|
|
357
|
+
"`claude daemon uninstall` first and install it again after the {}".format(
|
|
358
|
+
"migration" if command == "migrate-default" else command), file=sys.stderr)
|
|
356
359
|
return accounts.EXIT_BUSY
|
|
357
360
|
|
|
358
361
|
|