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.
Files changed (55) hide show
  1. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/PKG-INFO +29 -7
  2. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/README.md +28 -6
  3. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/__init__.py +1 -1
  4. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/cli.py +220 -13
  5. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/completion.py +2 -1
  6. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/migrate.py +9 -6
  7. multi_claude_cli-0.5.0/src/multi_claude/restore.py +149 -0
  8. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude_cli.egg-info/PKG-INFO +29 -7
  9. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude_cli.egg-info/SOURCES.txt +2 -0
  10. multi_claude_cli-0.5.0/tests/test_everyday.py +340 -0
  11. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/LICENSE +0 -0
  12. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/pyproject.toml +0 -0
  13. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/setup.cfg +0 -0
  14. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/__main__.py +0 -0
  15. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/accounts.py +0 -0
  16. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/actions.py +0 -0
  17. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/config.py +0 -0
  18. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/doctor.py +0 -0
  19. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/env.py +0 -0
  20. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/fsutil.py +0 -0
  21. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/hook.py +0 -0
  22. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/identity.py +0 -0
  23. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/launcher.py +0 -0
  24. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/lock.py +0 -0
  25. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/platform.py +0 -0
  26. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/routes.py +0 -0
  27. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/sessions.py +0 -0
  28. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/shared.py +0 -0
  29. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/shellpath.py +0 -0
  30. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/statusline.py +0 -0
  31. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude/usage.py +0 -0
  32. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude_cli.egg-info/dependency_links.txt +0 -0
  33. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude_cli.egg-info/entry_points.txt +0 -0
  34. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/src/multi_claude_cli.egg-info/top_level.txt +0 -0
  35. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_accounts.py +0 -0
  36. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_completion.py +0 -0
  37. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_config.py +0 -0
  38. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_doctor.py +0 -0
  39. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_fsutil.py +0 -0
  40. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_handoff.py +0 -0
  41. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_help.py +0 -0
  42. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_identity.py +0 -0
  43. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_install.py +0 -0
  44. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_mcp.py +0 -0
  45. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_migrate.py +0 -0
  46. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_onboarding.py +0 -0
  47. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_packaging.py +0 -0
  48. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_platform.py +0 -0
  49. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_rename.py +0 -0
  50. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_routes.py +0 -0
  51. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_set.py +0 -0
  52. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_shared_exclude.py +0 -0
  53. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_statusline.py +0 -0
  54. {multi_claude_cli-0.4.0 → multi_claude_cli-0.5.0}/tests/test_usability.py +0 -0
  55. {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.4.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
  [![Release](https://img.shields.io/github/v/release/jakoes-wu/multi-claude)](https://github.com/jakoes-wu/multi-claude/releases)
33
33
  [![CI](https://github.com/jakoes-wu/multi-claude/actions/workflows/ci.yml/badge.svg)](https://github.com/jakoes-wu/multi-claude/actions/workflows/ci.yml)
34
+ [![PyPI](https://img.shields.io/pypi/v/multi-claude-cli)](https://pypi.org/project/multi-claude-cli/)
34
35
  ![Python](https://img.shields.io/badge/python-3.8%2B-blue)
35
36
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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 git+https://github.com/jakoes-wu/multi-claude`.
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
- 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.
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 add NAME [--proxy P] [--shared [DIR] \| --no-shared] [--adopt] [--shared-exclude ITEM] [--shared-include ITEM]` | Add an account, adopt an existing directory, or change its options. `--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. |
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 migration source is in use |
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
  [![Release](https://img.shields.io/github/v/release/jakoes-wu/multi-claude)](https://github.com/jakoes-wu/multi-claude/releases)
6
6
  [![CI](https://github.com/jakoes-wu/multi-claude/actions/workflows/ci.yml/badge.svg)](https://github.com/jakoes-wu/multi-claude/actions/workflows/ci.yml)
7
+ [![PyPI](https://img.shields.io/pypi/v/multi-claude-cli)](https://pypi.org/project/multi-claude-cli/)
7
8
  ![Python](https://img.shields.io/badge/python-3.8%2B-blue)
8
9
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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 git+https://github.com/jakoes-wu/multi-claude`.
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
- 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.
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 add NAME [--proxy P] [--shared [DIR] \| --no-shared] [--adopt] [--shared-exclude ITEM] [--shared-include ITEM]` | Add an account, adopt an existing directory, or change its options. `--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. |
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 migration source is in use |
432
+ | 4 | The directory to migrate or restore is in use |
411
433
 
412
434
  ## Uninstalling
413
435
 
@@ -1,3 +1,3 @@
1
1
  """multi-claude:管理多个 Claude Code 账号的配置目录、启动命令、代理与启动参数。"""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -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, routes, sessions,
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
- p_mig.add_argument("name")
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
- process_env = account_env(config, account, os.environ)
652
- if shutil.which("claude", path=process_env.get("PATH")) is None:
653
- error("claude not found in PATH")
654
- return None, 127
655
- return process_env, accounts.EXIT_OK
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 migrate-default from a plain "
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 command in users.stop_commands:
352
- print(" " + command, file=sys.stderr)
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 migration", file=sys.stderr)
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