multi-claude-cli 0.7.0__tar.gz → 0.8.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 (60) hide show
  1. {multi_claude_cli-0.7.0/src/multi_claude_cli.egg-info → multi_claude_cli-0.8.0}/PKG-INFO +29 -1
  2. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/README.md +28 -0
  3. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/__init__.py +1 -1
  4. multi_claude_cli-0.8.0/src/multi_claude/bundle.py +508 -0
  5. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/cli.py +25 -3
  6. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/completion.py +1 -0
  7. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0/src/multi_claude_cli.egg-info}/PKG-INFO +29 -1
  8. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude_cli.egg-info/SOURCES.txt +2 -0
  9. multi_claude_cli-0.8.0/tests/test_bundle.py +305 -0
  10. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/LICENSE +0 -0
  11. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/pyproject.toml +0 -0
  12. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/setup.cfg +0 -0
  13. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/__main__.py +0 -0
  14. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/accounts.py +0 -0
  15. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/actions.py +0 -0
  16. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/config.py +0 -0
  17. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/doctor.py +0 -0
  18. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/env.py +0 -0
  19. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/fsutil.py +0 -0
  20. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/history.py +0 -0
  21. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/hook.py +0 -0
  22. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/identity.py +0 -0
  23. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/launcher.py +0 -0
  24. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/lock.py +0 -0
  25. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/migrate.py +0 -0
  26. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/platform.py +0 -0
  27. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/restore.py +0 -0
  28. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/routes.py +0 -0
  29. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/sessions.py +0 -0
  30. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/shared.py +0 -0
  31. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/shellpath.py +0 -0
  32. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/statusline.py +0 -0
  33. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude/usage.py +0 -0
  34. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude_cli.egg-info/dependency_links.txt +0 -0
  35. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude_cli.egg-info/entry_points.txt +0 -0
  36. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/src/multi_claude_cli.egg-info/top_level.txt +0 -0
  37. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_accounts.py +0 -0
  38. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_completion.py +0 -0
  39. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_config.py +0 -0
  40. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_doctor.py +0 -0
  41. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_everyday.py +0 -0
  42. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_fsutil.py +0 -0
  43. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_handoff.py +0 -0
  44. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_help.py +0 -0
  45. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_history.py +0 -0
  46. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_identity.py +0 -0
  47. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_install.py +0 -0
  48. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_mcp.py +0 -0
  49. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_migrate.py +0 -0
  50. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_onboarding.py +0 -0
  51. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_packaging.py +0 -0
  52. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_platform.py +0 -0
  53. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_rename.py +0 -0
  54. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_routes.py +0 -0
  55. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_set.py +0 -0
  56. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_shared_exclude.py +0 -0
  57. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_statusline.py +0 -0
  58. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_usability.py +0 -0
  59. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_usage.py +0 -0
  60. {multi_claude_cli-0.7.0 → multi_claude_cli-0.8.0}/tests/test_vscode.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multi-claude-cli
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -207,6 +207,32 @@ Items that hold one account's own state cannot be shared: `.credentials.json`, `
207
207
 
208
208
  **`skills/synced/`.** Claude Code stores skills synced from claude.ai in `skills/synced/`, one bucket per organization and account. If an account's `skills` is a real directory, turning sharing on is a conflict and multi-claude does not move anything. You may move the account's `skills/synced/<bucket>` into the shared directory's `skills/synced/` yourself: buckets of different accounts have different names and do not collide, but from then on that account's synced skills are written to the shared directory.
209
209
 
210
+ ### Exporting and importing settings
211
+
212
+ ```sh
213
+ multi-claude export work ~/work-settings.tar.gz # on this machine
214
+ multi-claude import ~/work-settings.tar.gz client # into another (existing) account, here or elsewhere
215
+ ```
216
+
217
+ `export` writes `settings.json`, `CLAUDE.md`, `agents`, `commands`, `skills` and `output-styles` of the account, plus the MCP servers from its `.claude.json` (only the `mcpServers` entry), to a `.tar.gz` that only you can read. Logins, the rest of `.claude.json`, sessions, history and the plugin cache are never included. Items that are links (shared items) are skipped. If `settings.json` or the MCP servers contain values under names such as `API_KEY` or `Authorization`, `export` lists those names (not the values): treat the file like a password.
218
+
219
+ `import` copies the items into an account you created with `add`, and adds the MCP servers to its `.claude.json`. An item or MCP server that already exists with different content is a conflict: nothing is changed and the conflicts are listed. With `--force`, each one is first renamed to `<name>.multi-claude-bak.<timestamp>` and then replaced. An item that is a shared link is never replaced; turn sharing off for it first. `--dry-run` shows the plan. Restart running Claude sessions of the account after importing MCP servers; they keep their own copy of `.claude.json` and may overwrite the change.
220
+
221
+ Plugin choices travel in `settings.json` (`enabledPlugins`, `extraKnownMarketplaces`); Claude Code downloads the plugins again in the new account.
222
+
223
+ ### Sharing MCP servers, plugins and settings
224
+
225
+ Shared links cover files and directories. For settings and MCP servers that every account should have, pass them on the command line of every launcher instead:
226
+
227
+ ```sh
228
+ multi-claude args --defaults -- --mcp-config ~/.claude-shared/mcp.json --settings ~/.claude-shared/settings.json
229
+ multi-claude env --defaults CLAUDE_CODE_PLUGIN_CACHE_DIR=~/.claude-shared/plugins
230
+ ```
231
+
232
+ - `--settings` is merged with each account's own `settings.json`: keys in the shared file win, other keys stay. Put `enabledPlugins`, `extraKnownMarketplaces`, `permissions` or `env` there.
233
+ - `--mcp-config` loads MCP servers from a JSON file (`{"mcpServers": {...}}`) in addition to each account's own. It takes several values, so keep another option after it (as `--settings` above); multi-claude warns otherwise.
234
+ - `CLAUDE_CODE_PLUGIN_CACHE_DIR` moves the plugins directory (marketplaces and the plugin cache), so plugins are downloaded once for all accounts.
235
+
210
236
  ### Choosing an account by directory
211
237
 
212
238
  ```sh
@@ -412,6 +438,8 @@ It never reads it: its only keychain call checks whether an item exists, and it
412
438
  | `multi-claude init [--root DIR] [--bin-dir DIR] [--shared-dir DIR] [--shared-items A,B]` | Create or change global settings. |
413
439
  | `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. |
414
440
  | `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. |
441
+ | `multi-claude export NAME FILE` | Write the account's settings, `CLAUDE.md`, agents, commands, skills, output styles and MCP servers to FILE. No logins or sessions. |
442
+ | `multi-claude import FILE NAME [--force] [--dry-run]` | Import such a file into an existing account; conflicts are refused unless `--force` (which backs up first). |
415
443
  | `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. |
416
444
  | `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. |
417
445
  | `multi-claude proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
@@ -180,6 +180,32 @@ Items that hold one account's own state cannot be shared: `.credentials.json`, `
180
180
 
181
181
  **`skills/synced/`.** Claude Code stores skills synced from claude.ai in `skills/synced/`, one bucket per organization and account. If an account's `skills` is a real directory, turning sharing on is a conflict and multi-claude does not move anything. You may move the account's `skills/synced/<bucket>` into the shared directory's `skills/synced/` yourself: buckets of different accounts have different names and do not collide, but from then on that account's synced skills are written to the shared directory.
182
182
 
183
+ ### Exporting and importing settings
184
+
185
+ ```sh
186
+ multi-claude export work ~/work-settings.tar.gz # on this machine
187
+ multi-claude import ~/work-settings.tar.gz client # into another (existing) account, here or elsewhere
188
+ ```
189
+
190
+ `export` writes `settings.json`, `CLAUDE.md`, `agents`, `commands`, `skills` and `output-styles` of the account, plus the MCP servers from its `.claude.json` (only the `mcpServers` entry), to a `.tar.gz` that only you can read. Logins, the rest of `.claude.json`, sessions, history and the plugin cache are never included. Items that are links (shared items) are skipped. If `settings.json` or the MCP servers contain values under names such as `API_KEY` or `Authorization`, `export` lists those names (not the values): treat the file like a password.
191
+
192
+ `import` copies the items into an account you created with `add`, and adds the MCP servers to its `.claude.json`. An item or MCP server that already exists with different content is a conflict: nothing is changed and the conflicts are listed. With `--force`, each one is first renamed to `<name>.multi-claude-bak.<timestamp>` and then replaced. An item that is a shared link is never replaced; turn sharing off for it first. `--dry-run` shows the plan. Restart running Claude sessions of the account after importing MCP servers; they keep their own copy of `.claude.json` and may overwrite the change.
193
+
194
+ Plugin choices travel in `settings.json` (`enabledPlugins`, `extraKnownMarketplaces`); Claude Code downloads the plugins again in the new account.
195
+
196
+ ### Sharing MCP servers, plugins and settings
197
+
198
+ Shared links cover files and directories. For settings and MCP servers that every account should have, pass them on the command line of every launcher instead:
199
+
200
+ ```sh
201
+ multi-claude args --defaults -- --mcp-config ~/.claude-shared/mcp.json --settings ~/.claude-shared/settings.json
202
+ multi-claude env --defaults CLAUDE_CODE_PLUGIN_CACHE_DIR=~/.claude-shared/plugins
203
+ ```
204
+
205
+ - `--settings` is merged with each account's own `settings.json`: keys in the shared file win, other keys stay. Put `enabledPlugins`, `extraKnownMarketplaces`, `permissions` or `env` there.
206
+ - `--mcp-config` loads MCP servers from a JSON file (`{"mcpServers": {...}}`) in addition to each account's own. It takes several values, so keep another option after it (as `--settings` above); multi-claude warns otherwise.
207
+ - `CLAUDE_CODE_PLUGIN_CACHE_DIR` moves the plugins directory (marketplaces and the plugin cache), so plugins are downloaded once for all accounts.
208
+
183
209
  ### Choosing an account by directory
184
210
 
185
211
  ```sh
@@ -385,6 +411,8 @@ It never reads it: its only keychain call checks whether an item exists, and it
385
411
  | `multi-claude init [--root DIR] [--bin-dir DIR] [--shared-dir DIR] [--shared-items A,B]` | Create or change global settings. |
386
412
  | `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. |
387
413
  | `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. |
414
+ | `multi-claude export NAME FILE` | Write the account's settings, `CLAUDE.md`, agents, commands, skills, output styles and MCP servers to FILE. No logins or sessions. |
415
+ | `multi-claude import FILE NAME [--force] [--dry-run]` | Import such a file into an existing account; conflicts are refused unless `--force` (which backs up first). |
388
416
  | `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. |
389
417
  | `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. |
390
418
  | `multi-claude proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
@@ -1,3 +1,3 @@
1
1
  """multi-claude:管理多个 Claude Code 账号的配置目录、启动命令、代理与启动参数。"""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.8.0"
@@ -0,0 +1,508 @@
1
+ """不含凭据的账号配置包:`export NAME FILE` / `import FILE NAME`(方案 feature-config-bundle)。
2
+
3
+ 只按白名单导出配置条目(settings.json、CLAUDE.md、agents、commands、skills、output-styles),
4
+ 再从 `.claude.json` 里只抽出 `mcpServers` 一项;凭据、`.claude.json` 的其它内容、会话与历史、
5
+ plugins/ 下的缓存一律不进包。白名单而不是排除名单:Claude Code 新增的状态文件不会被误带走。
6
+
7
+ 导入时先把整个包解到账号目录下的临时目录并完成全部比较,有冲突(且没有 --force)就什么都不改;
8
+ 检查通过后才逐条 os.replace(同一文件系统内原子)。中途被打断时,已替换的条目重跑后显示 unchanged,
9
+ 其余条目继续处理。
10
+ """
11
+
12
+ import hashlib
13
+ import io
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import stat
19
+ import tarfile
20
+ import tempfile
21
+ import time
22
+ from datetime import datetime, timezone
23
+ from typing import Dict, List, Optional, Tuple
24
+
25
+ from . import __version__, accounts, usage
26
+ from .actions import error, info, warn
27
+ from .config import Account, Config
28
+ from .fsutil import KIND_DIR, KIND_FILE, KIND_LINK, KIND_MISSING, atomic_write, entry_kind
29
+
30
+ ITEMS = ("settings.json", "CLAUDE.md", "agents", "commands", "skills", "output-styles")
31
+ # 这两项必须是文件,其余必须是目录:包里把 settings.json 做成目录时,--force 会把文件换成目录
32
+ FILE_ITEMS = ("settings.json", "CLAUDE.md")
33
+ # 解包上限:防止压缩炸弹写满磁盘
34
+ MAX_MEMBERS = 20000
35
+ MAX_TOTAL_BYTES = 100 * 1024 * 1024
36
+ FORMAT = "multi-claude-config"
37
+ VERSION = 1
38
+ MANIFEST = "manifest.json"
39
+ MCP_FILE = "mcp-servers.json"
40
+ ITEMS_DIR = "items"
41
+ # 键名像密钥时只提示键名、不显示值(用户 2026-10-03 决定:原样导出并警告)
42
+ _SECRET_KEY = re.compile(r"KEY|TOKEN|SECRET|PASSWORD|AUTH", re.IGNORECASE)
43
+
44
+
45
+ class BundleError(Exception):
46
+ """配置包读不了或不合法:退出 1,不改任何东西。"""
47
+
48
+
49
+ # ---- 导出 ----
50
+
51
+ def export_account(config: Config, account: Account, out_path: str) -> int:
52
+ """把账号配置写成 out_path(.tar.gz,0600)。out_path 已存在(含断开的软链)时退出 3,不覆盖。"""
53
+ # 按原始写法检查目录项是否存在:先 resolve 会让断开的软链“消失”,进而写穿到链接目标。
54
+ if os.path.lexists(out_path):
55
+ error("{} already exists; nothing was written".format(out_path), phase="export", path=out_path)
56
+ return accounts.EXIT_CONFLICT
57
+ parent = os.path.dirname(out_path) or "."
58
+ if not os.path.isdir(parent):
59
+ error("directory {} does not exist".format(parent), phase="export", path=out_path)
60
+ return accounts.EXIT_ERROR
61
+ directory = accounts.account_dir(config, account.name)
62
+ if not os.path.isdir(directory):
63
+ error("account directory does not exist: {}".format(directory), phase="export")
64
+ return accounts.EXIT_ERROR
65
+
66
+ exported: List[str] = []
67
+ skipped_links = {}
68
+ servers = _read_mcp_servers(usage.global_state_path(account.identity, directory))
69
+ # 临时文件必须用原始父路径拼出:tempfile 会对 dir 做 abspath,把 link/.. 按字面折叠,
70
+ # 临时文件就落到与 out_path(由内核逐段解析)不同的目录,最后的 link/replace 会失败或跨目录。
71
+ tmp_path, fd = _create_temp(parent)
72
+ try:
73
+ with os.fdopen(fd, "wb") as raw, tarfile.open(fileobj=raw, mode="w:gz") as tar:
74
+ for item in ITEMS:
75
+ source = os.path.join(directory, item)
76
+ kind = entry_kind(source)
77
+ if kind == KIND_MISSING:
78
+ continue
79
+ if kind == KIND_LINK:
80
+ # 共享条目是指向共享目录的软链:两个账号本来就读同一份,不属于这个账号自己的配置。
81
+ info("skip {} (link)".format(item))
82
+ continue
83
+ if kind not in (KIND_FILE, KIND_DIR):
84
+ continue
85
+ skipped_links[item] = _add_tree(tar, source, "{}/{}".format(ITEMS_DIR, item))
86
+ exported.append(item)
87
+ info("export {}".format(item))
88
+ if servers is not None:
89
+ _add_bytes(tar, MCP_FILE, _json_bytes(servers))
90
+ info("export mcpServers ({})".format(", ".join(sorted(servers)) or "none"))
91
+ manifest = {"format": FORMAT, "version": VERSION, "multi_claude": __version__,
92
+ "created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
93
+ "source_account": account.name, "items": exported,
94
+ "mcp_servers": sorted(servers) if servers is not None else []}
95
+ _add_bytes(tar, MANIFEST, _json_bytes(manifest))
96
+ _publish(tmp_path, out_path)
97
+ tmp_path = None
98
+ finally:
99
+ if tmp_path is not None and os.path.lexists(tmp_path):
100
+ os.unlink(tmp_path)
101
+ for item, count in skipped_links.items():
102
+ if count:
103
+ info("skipped {} link(s) or special file(s) inside {}".format(count, item))
104
+ info("wrote {}".format(out_path))
105
+ secrets = _secret_keys(os.path.join(directory, "settings.json") if "settings.json" in exported else None,
106
+ servers)
107
+ if secrets:
108
+ warn("{} contains values under keys that look like secrets: {}; keep it private".format(
109
+ out_path, ", ".join(secrets)))
110
+ return accounts.EXIT_OK
111
+
112
+
113
+ def _create_temp(parent: str):
114
+ for _attempt in range(100):
115
+ path = os.path.join(parent, ".multi-claude-export.{}.tar.gz".format(os.urandom(6).hex()))
116
+ try:
117
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
118
+ except FileExistsError:
119
+ continue
120
+ os.fchmod(fd, 0o600) # 不受 umask 影响:包里可能有密钥,只给本人读
121
+ return path, fd
122
+ raise OSError("cannot create a temporary file in {}".format(parent))
123
+
124
+
125
+ def _publish(tmp_path: str, out_path: str) -> None:
126
+ """把写好的临时文件放到 out_path,且不覆盖检查之后才出现的同名文件:硬链接遇到已存在的目标会失败。"""
127
+ try:
128
+ os.link(tmp_path, out_path)
129
+ except FileExistsError:
130
+ raise OSError("{} appeared while exporting; nothing was written".format(out_path))
131
+ except OSError:
132
+ # 不支持硬链接的文件系统(如 exFAT):再检查一次后改名
133
+ if os.path.lexists(out_path):
134
+ raise OSError("{} appeared while exporting; nothing was written".format(out_path))
135
+ os.replace(tmp_path, out_path)
136
+ return
137
+ os.unlink(tmp_path)
138
+
139
+
140
+ def _add_tree(tar: tarfile.TarFile, source: str, arcname: str) -> int:
141
+ """加入文件或目录(不跟随软链);返回跳过的软链与特殊文件数。"""
142
+ if entry_kind(source) == KIND_FILE:
143
+ _add_entry(tar, source, arcname)
144
+ return 0
145
+ skipped = 0
146
+ _add_entry(tar, source, arcname)
147
+ for root, dirs, files in os.walk(source):
148
+ rel_root = os.path.relpath(root, source)
149
+ for name in sorted(dirs):
150
+ path = os.path.join(root, name)
151
+ if entry_kind(path) != KIND_DIR:
152
+ skipped += 1 # 指向目录的软链:os.walk 不会进入,但出现在 dirs 里
153
+ continue
154
+ _add_entry(tar, path, _join(arcname, rel_root, name))
155
+ dirs[:] = [name for name in dirs if entry_kind(os.path.join(root, name)) == KIND_DIR]
156
+ for name in sorted(files):
157
+ path = os.path.join(root, name)
158
+ if entry_kind(path) != KIND_FILE:
159
+ skipped += 1
160
+ continue
161
+ _add_entry(tar, path, _join(arcname, rel_root, name))
162
+ return skipped
163
+
164
+
165
+ def _join(arcname: str, rel_root: str, name: str) -> str:
166
+ return "/".join(part for part in (arcname, "" if rel_root == "." else rel_root.replace(os.sep, "/"), name) if part)
167
+
168
+
169
+ def _add_entry(tar: tarfile.TarFile, path: str, arcname: str) -> None:
170
+ info_ = tar.gettarinfo(path, arcname)
171
+ # 不带本机的用户名与 uid:包可能被搬到另一台机器
172
+ info_.uid = info_.gid = 0
173
+ info_.uname = info_.gname = ""
174
+ if info_.isfile():
175
+ with open(path, "rb") as handle:
176
+ tar.addfile(info_, handle)
177
+ else:
178
+ tar.addfile(info_)
179
+
180
+
181
+ def _add_bytes(tar: tarfile.TarFile, name: str, data: bytes) -> None:
182
+ entry = tarfile.TarInfo(name)
183
+ entry.size = len(data)
184
+ entry.mode = 0o600
185
+ entry.mtime = int(time.time())
186
+ tar.addfile(entry, io.BytesIO(data))
187
+
188
+
189
+ def _json_bytes(value) -> bytes:
190
+ return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
191
+
192
+
193
+ def _read_mcp_servers(path: str) -> Optional[dict]:
194
+ """只取 .claude.json 顶层的 mcpServers;文件不存在或没有这一项时为 None。登录与项目状态一律不读出。"""
195
+ try:
196
+ with open(path, "r", encoding="utf-8") as handle:
197
+ data = json.load(handle)
198
+ except FileNotFoundError:
199
+ return None
200
+ except (OSError, ValueError) as exc:
201
+ warn("cannot read {} ({}); MCP servers are not exported".format(path, exc))
202
+ return None
203
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
204
+ return servers if isinstance(servers, dict) else None
205
+
206
+
207
+ def _secret_keys(settings_path: Optional[str], servers: Optional[dict]) -> List[str]:
208
+ found = []
209
+ if settings_path is not None:
210
+ try:
211
+ with open(settings_path, "r", encoding="utf-8") as handle:
212
+ settings = json.load(handle)
213
+ except (OSError, ValueError):
214
+ settings = None
215
+ env = settings.get("env") if isinstance(settings, dict) else None
216
+ if isinstance(env, dict):
217
+ found.extend("env.{}".format(key) for key in env if _SECRET_KEY.search(key))
218
+ for name, server in sorted((servers or {}).items()):
219
+ if not isinstance(server, dict):
220
+ continue
221
+ for field in ("env", "headers"):
222
+ values = server.get(field)
223
+ if isinstance(values, dict):
224
+ found.extend("mcpServers.{}.{}.{}".format(name, field, key) for key in values
225
+ if _SECRET_KEY.search(key))
226
+ return found
227
+
228
+
229
+ # ---- 导入 ----
230
+
231
+ class _Plan:
232
+ def __init__(self) -> None:
233
+ self.items: List[Tuple[str, str]] = [] # (条目, create|replace|unchanged)
234
+ self.servers: List[Tuple[str, str]] = [] # (服务器名, add|replace|unchanged)
235
+ self.conflicts: List[str] = [] # --force 可以处理的冲突
236
+ self.blocked: List[str] = [] # --force 也不能处理的冲突
237
+
238
+
239
+ def import_account(config: Config, account: Account, archive: str, force: bool, dry_run: bool) -> int:
240
+ directory = accounts.account_dir(config, account.name)
241
+ if not os.path.isdir(directory):
242
+ error("account directory does not exist: {}".format(directory), phase="import")
243
+ return accounts.EXIT_ERROR
244
+ _remove_leftover_staging(directory)
245
+ staging = tempfile.mkdtemp(prefix=".multi-claude-import.", dir=directory)
246
+ try:
247
+ try:
248
+ manifest = _extract(archive, staging)
249
+ except BundleError as exc:
250
+ error(str(exc), phase="import", path=archive)
251
+ return accounts.EXIT_ERROR
252
+ state_path = usage.global_state_path(account.identity, directory)
253
+ plan = _plan_import(directory, staging, state_path)
254
+ if plan is None:
255
+ return accounts.EXIT_ERROR
256
+ _print_plan(plan, dry_run)
257
+ if plan.blocked or (plan.conflicts and not force):
258
+ for line in plan.blocked:
259
+ error(line, phase="import")
260
+ if not force:
261
+ for line in plan.conflicts:
262
+ error(line, phase="import")
263
+ info("nothing was changed{}".format("" if plan.blocked else " (use --force to back up and replace)"))
264
+ return accounts.EXIT_CONFLICT
265
+ if dry_run:
266
+ return accounts.EXIT_OK
267
+ _apply(plan, directory, staging, state_path)
268
+ info("imported {} into {}".format(manifest.get("source_account", archive), account.name))
269
+ return accounts.EXIT_OK
270
+ finally:
271
+ shutil.rmtree(staging, ignore_errors=True)
272
+
273
+
274
+ def _remove_leftover_staging(directory: str) -> None:
275
+ """清掉上次被杀时留下的临时目录。调用方持有写锁,同一时刻不会有另一个导入在用它们。"""
276
+ for name in os.listdir(directory):
277
+ path = os.path.join(directory, name)
278
+ if name.startswith(".multi-claude-import.") and entry_kind(path) == KIND_DIR:
279
+ shutil.rmtree(path, ignore_errors=True)
280
+
281
+
282
+ def _extract(archive: str, staging: str) -> dict:
283
+ """校验并解包到 staging;任何不合规的成员都让整个导入失败。返回 manifest。
284
+
285
+ Python 3.8 没有 extractall(filter="data"),所以逐个成员手工写出:权限统一为 0600 / 0700(带执行位的文件),
286
+ 不保留包里的属主、setuid 等位;限制成员数与解压总量;拒绝重复路径。
287
+ """
288
+ try:
289
+ tar = tarfile.open(archive, "r:gz")
290
+ except (OSError, tarfile.TarError) as exc:
291
+ raise BundleError("cannot read {}: {}".format(archive, exc))
292
+ with tar:
293
+ try:
294
+ members = tar.getmembers()
295
+ except (OSError, tarfile.TarError) as exc:
296
+ raise BundleError("cannot read {}: {}".format(archive, exc))
297
+ if len(members) > MAX_MEMBERS:
298
+ raise BundleError("{} has too many entries ({})".format(archive, len(members)))
299
+ seen = set()
300
+ total = 0
301
+ for member in members:
302
+ _check_member(member)
303
+ if member.name in seen:
304
+ raise BundleError("duplicate entry in bundle: {!r}".format(member.name))
305
+ seen.add(member.name)
306
+ total += member.size if member.isfile() else 0
307
+ if total > MAX_TOTAL_BYTES:
308
+ raise BundleError("{} is larger than {} MB when unpacked".format(archive, MAX_TOTAL_BYTES >> 20))
309
+ _check_item_types(members)
310
+ if MANIFEST not in seen:
311
+ raise BundleError("{} is not a multi-claude configuration bundle (no {})".format(archive, MANIFEST))
312
+ for member in members:
313
+ target = os.path.join(staging, *member.name.split("/"))
314
+ if member.isdir():
315
+ os.makedirs(target, mode=0o700, exist_ok=True)
316
+ os.chmod(target, 0o700)
317
+ continue
318
+ os.makedirs(os.path.dirname(target), mode=0o700, exist_ok=True)
319
+ source = tar.extractfile(member)
320
+ if source is None:
321
+ raise BundleError("cannot read {} from {}".format(member.name, archive))
322
+ with source, open(target, "wb") as handle:
323
+ shutil.copyfileobj(source, handle)
324
+ os.chmod(target, 0o700 if member.mode & 0o111 else 0o600)
325
+ try:
326
+ with open(os.path.join(staging, MANIFEST), "r", encoding="utf-8") as handle:
327
+ manifest = json.load(handle)
328
+ except (OSError, ValueError) as exc:
329
+ raise BundleError("invalid {}: {}".format(MANIFEST, exc))
330
+ if not isinstance(manifest, dict) or manifest.get("format") != FORMAT:
331
+ raise BundleError("{} is not a multi-claude configuration bundle".format(archive))
332
+ if manifest.get("version") != VERSION:
333
+ raise BundleError("unsupported bundle version {!r}; upgrade multi-claude".format(manifest.get("version")))
334
+ return manifest
335
+
336
+
337
+ def _check_item_types(members: List[tarfile.TarInfo]) -> None:
338
+ """settings.json、CLAUDE.md 只能是一个文件;目录条目下的成员必须挂在目录下。"""
339
+ for member in members:
340
+ parts = member.name.split("/")
341
+ if parts[0] != ITEMS_DIR:
342
+ continue
343
+ item = parts[1]
344
+ if item in FILE_ITEMS and (len(parts) != 2 or not member.isfile()):
345
+ raise BundleError("{} must be a single file in the bundle".format(item))
346
+ if item not in FILE_ITEMS and len(parts) == 2 and not member.isdir():
347
+ raise BundleError("{} must be a directory in the bundle".format(item))
348
+
349
+
350
+ def _check_member(member: tarfile.TarInfo) -> None:
351
+ name = member.name
352
+ parts = name.split("/")
353
+ if name.startswith("/") or "\\" in name or "\x00" in name or any(part in ("", ".", "..") for part in parts):
354
+ raise BundleError("unsafe path in bundle: {!r}".format(name))
355
+ if not (member.isfile() or member.isdir()):
356
+ raise BundleError("unsupported entry in bundle (only files and directories): {!r}".format(name))
357
+ if parts[0] in (MANIFEST, MCP_FILE):
358
+ if len(parts) != 1 or not member.isfile():
359
+ raise BundleError("unexpected entry in bundle: {!r}".format(name))
360
+ return
361
+ if parts[0] != ITEMS_DIR or len(parts) < 2 or parts[1] not in ITEMS:
362
+ raise BundleError("unexpected entry in bundle: {!r}".format(name))
363
+
364
+
365
+ def _plan_import(directory: str, staging: str, state_path: str) -> Optional[_Plan]:
366
+ plan = _Plan()
367
+ staged_items = os.path.join(staging, ITEMS_DIR)
368
+ for item in ITEMS:
369
+ staged = os.path.join(staged_items, item)
370
+ if not os.path.lexists(staged):
371
+ continue
372
+ target = os.path.join(directory, item)
373
+ kind = entry_kind(target)
374
+ if kind == KIND_MISSING:
375
+ plan.items.append((item, "create"))
376
+ elif kind == KIND_LINK:
377
+ plan.blocked.append("{} is a shared link in this account; turn sharing off for it first "
378
+ "(multi-claude set NAME --shared-exclude {})".format(item, item))
379
+ elif _tree_digest(staged) == _tree_digest(target):
380
+ plan.items.append((item, "unchanged"))
381
+ else:
382
+ plan.items.append((item, "replace"))
383
+ plan.conflicts.append("{} already exists with different content".format(item))
384
+ staged_mcp = os.path.join(staging, MCP_FILE)
385
+ if os.path.exists(staged_mcp):
386
+ try:
387
+ with open(staged_mcp, "r", encoding="utf-8") as handle:
388
+ servers = json.load(handle)
389
+ except (OSError, ValueError) as exc:
390
+ error("invalid {} in bundle: {}".format(MCP_FILE, exc), phase="import")
391
+ return None
392
+ if not isinstance(servers, dict):
393
+ error("invalid {} in bundle".format(MCP_FILE), phase="import")
394
+ return None
395
+ current = _load_state(state_path)
396
+ if current is None:
397
+ return None
398
+ existing = current.get("mcpServers", {})
399
+ if not isinstance(existing, dict):
400
+ plan.blocked.append("mcpServers in {} is not a JSON object; fix it by hand first".format(state_path))
401
+ return plan
402
+ for name in sorted(servers):
403
+ if name not in existing:
404
+ plan.servers.append((name, "add"))
405
+ elif existing[name] == servers[name]:
406
+ plan.servers.append((name, "unchanged"))
407
+ else:
408
+ plan.servers.append((name, "replace"))
409
+ plan.conflicts.append("MCP server {} already exists with a different configuration".format(name))
410
+ return plan
411
+
412
+
413
+ def _load_state(path: str) -> Optional[dict]:
414
+ try:
415
+ with open(path, "r", encoding="utf-8") as handle:
416
+ data = json.load(handle)
417
+ except FileNotFoundError:
418
+ return {}
419
+ except (OSError, ValueError) as exc:
420
+ error("cannot read {} ({}); close Claude sessions of this account and retry".format(path, exc),
421
+ phase="import", path=path)
422
+ return None
423
+ if not isinstance(data, dict):
424
+ error("{} is not a JSON object".format(path), phase="import", path=path)
425
+ return None
426
+ return data
427
+
428
+
429
+ def _print_plan(plan: _Plan, dry_run: bool) -> None:
430
+ prefix = "(dry-run) " if dry_run else ""
431
+ for item, action in plan.items:
432
+ info("{}import {}: {}".format(prefix, item, action))
433
+ for name, action in plan.servers:
434
+ info("{}mcp server {}: {}".format(prefix, name, action))
435
+
436
+
437
+ def _apply(plan: _Plan, directory: str, staging: str, state_path: str) -> None:
438
+ """检查都通过后才执行。.claude.json 先读好再动条目:读失败时什么都没改,不会留下“条目已换、MCP 未写”的半截状态。"""
439
+ stamp = time.strftime("%Y%m%d_%H%M%S")
440
+ changed = [(name, action) for name, action in plan.servers if action != "unchanged"]
441
+ data = None
442
+ if changed:
443
+ with open(os.path.join(staging, MCP_FILE), "r", encoding="utf-8") as handle:
444
+ servers = json.load(handle)
445
+ # .claude.json 是软链时(例如用 dotfiles 仓库管理)写到链接目标,不把软链换成普通文件
446
+ state_path = os.path.realpath(state_path) if entry_kind(state_path) == KIND_LINK else state_path
447
+ data = _load_state(state_path)
448
+ if data is None:
449
+ raise OSError("cannot read {}".format(state_path))
450
+ for item, action in plan.items:
451
+ if action == "unchanged":
452
+ continue
453
+ target = os.path.join(directory, item)
454
+ if action == "replace":
455
+ backup = _backup_name(target, stamp)
456
+ os.rename(target, backup)
457
+ info("backup: {}".format(backup))
458
+ os.replace(os.path.join(staging, ITEMS_DIR, item), target)
459
+ if data is None:
460
+ return
461
+ warn("Claude sessions of this account that are running keep their own copy of {} and may overwrite "
462
+ "the imported MCP servers when they save it; restart them now".format(os.path.basename(state_path)))
463
+ if any(action == "replace" for _name, action in changed) and os.path.exists(state_path):
464
+ backup = _backup_name(state_path, stamp)
465
+ shutil.copy2(state_path, backup)
466
+ info("backup: {}".format(backup))
467
+ merged = dict(data.get("mcpServers") or {}) if isinstance(data.get("mcpServers"), dict) else {}
468
+ for name, _action in changed:
469
+ merged[name] = servers[name]
470
+ data["mcpServers"] = merged
471
+ mode = stat.S_IMODE(os.stat(state_path).st_mode) if os.path.exists(state_path) else 0o600
472
+ atomic_write(state_path, json.dumps(data, indent=2, ensure_ascii=False) + "\n", mode=mode)
473
+
474
+
475
+ def _backup_name(path: str, stamp: str) -> str:
476
+ candidate = "{}.multi-claude-bak.{}".format(path, stamp)
477
+ counter = 1
478
+ while os.path.lexists(candidate):
479
+ counter += 1
480
+ candidate = "{}.multi-claude-bak.{}-{}".format(path, stamp, counter)
481
+ return candidate
482
+
483
+
484
+ def _tree_digest(path: str) -> Dict[str, str]:
485
+ """文件按字节、目录按“相对路径 + 类型 + 内容”比较;不跟随软链。"""
486
+ if entry_kind(path) == KIND_FILE:
487
+ return {".": "file:" + _sha256(path)}
488
+ result = {}
489
+ for root, dirs, files in os.walk(path):
490
+ rel_root = os.path.relpath(root, path)
491
+ for name in dirs:
492
+ entry = os.path.join(root, name)
493
+ result[os.path.normpath(os.path.join(rel_root, name))] = (
494
+ "dir" if entry_kind(entry) == KIND_DIR else "other")
495
+ dirs[:] = [name for name in dirs if entry_kind(os.path.join(root, name)) == KIND_DIR]
496
+ for name in files:
497
+ entry = os.path.join(root, name)
498
+ key = os.path.normpath(os.path.join(rel_root, name))
499
+ result[key] = "file:" + _sha256(entry) if entry_kind(entry) == KIND_FILE else "other"
500
+ return result
501
+
502
+
503
+ def _sha256(path: str) -> str:
504
+ digest = hashlib.sha256()
505
+ with open(path, "rb") as handle:
506
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
507
+ digest.update(chunk)
508
+ return digest.hexdigest()
@@ -18,8 +18,8 @@ import sys
18
18
  from datetime import datetime, timedelta, timezone
19
19
  from typing import List, Optional, Tuple
20
20
 
21
- from . import (__version__, accounts, completion, doctor, history, hook, identity, launcher, migrate, platform,
22
- restore, routes, sessions, shellpath, statusline, usage)
21
+ from . import (__version__, accounts, bundle, completion, doctor, history, hook, identity, launcher, migrate,
22
+ platform, restore, routes, sessions, shellpath, statusline, usage)
23
23
  from .actions import error, info, warn
24
24
  from .config import (DEFAULT_SHARED_DIR, DEFAULT_SHARED_ITEMS, IDENTITY_DEFAULT, IDENTITY_DIR, Account, Config, ConfigError, RouteRule,
25
25
  is_unshareable, load_config, normalize_proxy, parse_config, validate_env_key, validate_name,
@@ -49,6 +49,7 @@ COMMAND_GROUPS = [
49
49
  ("proxy, env, args", "set the proxy, extra variables or fixed arguments of a launcher"),
50
50
  ("rename, remove", "rename or unregister an account"),
51
51
  ("migrate-default, restore", "turn the existing ~/.claude into an account, or back"),
52
+ ("export, import", "copy an account's settings to a file and back (no logins)"),
52
53
  ]),
53
54
  ("Everyday", [
54
55
  ("usage", "5-hour and 7-day usage of each account"),
@@ -235,6 +236,19 @@ def build_parser() -> argparse.ArgumentParser:
235
236
  help="do not check whether Claude Code is running (at your own risk)")
236
237
  _add_dry_run(p_restore)
237
238
 
239
+ p_export = sub.add_parser("export", description="write the settings of an account to FILE (.tar.gz): "
240
+ "settings.json, CLAUDE.md, agents, commands, skills, "
241
+ "output-styles and its MCP servers; never logins or sessions")
242
+ p_export.add_argument("name")
243
+ p_export.add_argument("file", metavar="FILE")
244
+
245
+ p_import = sub.add_parser("import", description="import a file written by `export` into an existing account")
246
+ p_import.add_argument("file", metavar="FILE")
247
+ p_import.add_argument("name")
248
+ p_import.add_argument("--force", action="store_true",
249
+ help="back up and replace items and MCP servers that differ")
250
+ _add_dry_run(p_import)
251
+
238
252
  p_completion = sub.add_parser("completion", description="print a shell completion script")
239
253
  p_completion.add_argument("shell", choices=completion.SHELLS)
240
254
 
@@ -464,13 +478,21 @@ def main(argv: Optional[List[str]] = None) -> int:
464
478
 
465
479
  def _blocked_by_migration(args: argparse.Namespace, notice: Optional[str]) -> bool:
466
480
  # 迁移中途禁止其它写命令,防止根目录或账号在迁移过程中被改动。
467
- if notice and args.command != "migrate-default" and not args.dry_run:
481
+ # export 没有 --dry-run;用 getattr,免得写命令里少一个选项就在这里崩溃
482
+ if notice and args.command != "migrate-default" and not getattr(args, "dry_run", False):
468
483
  error("finish the unfinished migration before running `{}`".format(args.command))
469
484
  return True
470
485
  return False
471
486
 
472
487
 
473
488
  def dispatch(args: argparse.Namespace) -> int:
489
+ if args.command in ("export", "import"):
490
+ # 两者都只读写这个账号的白名单条目与 .claude.json 的 mcpServers,不改 config.json 与启动命令。
491
+ config, _ = load_config()
492
+ account = _registered(config, _checked_name(args.name))
493
+ if args.command == "export":
494
+ return bundle.export_account(config, account, args.file)
495
+ return bundle.import_account(config, account, args.file, args.force, args.dry_run)
474
496
  if args.command == "restore":
475
497
  return restore.restore(_checked_name(args.name), args.skip_process_check, args.dry_run)
476
498
  if args.command == "migrate-default":
@@ -20,6 +20,7 @@ POSITIONAL_KINDS = {
20
20
  "route": ["dir", "name"], "which": ["dir"], "rename": ["name"], "mcp": ["name"],
21
21
  "statusline": ["statusline", "file"], "handoff": ["name"], "login": ["name"], "set": ["name"],
22
22
  "run": ["name"], "path": ["name"], "restore": ["name"], "code": ["name", "file"],
23
+ "export": ["name", "file"], "import": ["file", "name"],
23
24
  }
24
25
  # 选项值是路径、应按文件补全的选项。
25
26
  PATH_OPTIONS = ("-f", "--file", "--root", "--bin-dir", "--shared-dir")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multi-claude-cli
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -207,6 +207,32 @@ Items that hold one account's own state cannot be shared: `.credentials.json`, `
207
207
 
208
208
  **`skills/synced/`.** Claude Code stores skills synced from claude.ai in `skills/synced/`, one bucket per organization and account. If an account's `skills` is a real directory, turning sharing on is a conflict and multi-claude does not move anything. You may move the account's `skills/synced/<bucket>` into the shared directory's `skills/synced/` yourself: buckets of different accounts have different names and do not collide, but from then on that account's synced skills are written to the shared directory.
209
209
 
210
+ ### Exporting and importing settings
211
+
212
+ ```sh
213
+ multi-claude export work ~/work-settings.tar.gz # on this machine
214
+ multi-claude import ~/work-settings.tar.gz client # into another (existing) account, here or elsewhere
215
+ ```
216
+
217
+ `export` writes `settings.json`, `CLAUDE.md`, `agents`, `commands`, `skills` and `output-styles` of the account, plus the MCP servers from its `.claude.json` (only the `mcpServers` entry), to a `.tar.gz` that only you can read. Logins, the rest of `.claude.json`, sessions, history and the plugin cache are never included. Items that are links (shared items) are skipped. If `settings.json` or the MCP servers contain values under names such as `API_KEY` or `Authorization`, `export` lists those names (not the values): treat the file like a password.
218
+
219
+ `import` copies the items into an account you created with `add`, and adds the MCP servers to its `.claude.json`. An item or MCP server that already exists with different content is a conflict: nothing is changed and the conflicts are listed. With `--force`, each one is first renamed to `<name>.multi-claude-bak.<timestamp>` and then replaced. An item that is a shared link is never replaced; turn sharing off for it first. `--dry-run` shows the plan. Restart running Claude sessions of the account after importing MCP servers; they keep their own copy of `.claude.json` and may overwrite the change.
220
+
221
+ Plugin choices travel in `settings.json` (`enabledPlugins`, `extraKnownMarketplaces`); Claude Code downloads the plugins again in the new account.
222
+
223
+ ### Sharing MCP servers, plugins and settings
224
+
225
+ Shared links cover files and directories. For settings and MCP servers that every account should have, pass them on the command line of every launcher instead:
226
+
227
+ ```sh
228
+ multi-claude args --defaults -- --mcp-config ~/.claude-shared/mcp.json --settings ~/.claude-shared/settings.json
229
+ multi-claude env --defaults CLAUDE_CODE_PLUGIN_CACHE_DIR=~/.claude-shared/plugins
230
+ ```
231
+
232
+ - `--settings` is merged with each account's own `settings.json`: keys in the shared file win, other keys stay. Put `enabledPlugins`, `extraKnownMarketplaces`, `permissions` or `env` there.
233
+ - `--mcp-config` loads MCP servers from a JSON file (`{"mcpServers": {...}}`) in addition to each account's own. It takes several values, so keep another option after it (as `--settings` above); multi-claude warns otherwise.
234
+ - `CLAUDE_CODE_PLUGIN_CACHE_DIR` moves the plugins directory (marketplaces and the plugin cache), so plugins are downloaded once for all accounts.
235
+
210
236
  ### Choosing an account by directory
211
237
 
212
238
  ```sh
@@ -412,6 +438,8 @@ It never reads it: its only keychain call checks whether an item exists, and it
412
438
  | `multi-claude init [--root DIR] [--bin-dir DIR] [--shared-dir DIR] [--shared-items A,B]` | Create or change global settings. |
413
439
  | `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. |
414
440
  | `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. |
441
+ | `multi-claude export NAME FILE` | Write the account's settings, `CLAUDE.md`, agents, commands, skills, output styles and MCP servers to FILE. No logins or sessions. |
442
+ | `multi-claude import FILE NAME [--force] [--dry-run]` | Import such a file into an existing account; conflicts are refused unless `--force` (which backs up first). |
415
443
  | `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. |
416
444
  | `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. |
417
445
  | `multi-claude proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
@@ -5,6 +5,7 @@ src/multi_claude/__init__.py
5
5
  src/multi_claude/__main__.py
6
6
  src/multi_claude/accounts.py
7
7
  src/multi_claude/actions.py
8
+ src/multi_claude/bundle.py
8
9
  src/multi_claude/cli.py
9
10
  src/multi_claude/completion.py
10
11
  src/multi_claude/config.py
@@ -31,6 +32,7 @@ src/multi_claude_cli.egg-info/dependency_links.txt
31
32
  src/multi_claude_cli.egg-info/entry_points.txt
32
33
  src/multi_claude_cli.egg-info/top_level.txt
33
34
  tests/test_accounts.py
35
+ tests/test_bundle.py
34
36
  tests/test_completion.py
35
37
  tests/test_config.py
36
38
  tests/test_doctor.py
@@ -0,0 +1,305 @@
1
+ """v0.8.0 `export` / `import`:不含凭据的账号配置包(方案 docs/feature/feature-config-bundle.md §8)。"""
2
+
3
+ import io
4
+ import json
5
+ import os
6
+ import stat
7
+ import tarfile
8
+ import unittest
9
+
10
+ from helpers import CliTestCase
11
+
12
+
13
+ class BundleBase(CliTestCase):
14
+ def setUp(self):
15
+ super().setUp()
16
+ self.ok("add", "src")
17
+ self.ok("add", "dst")
18
+ self.src = os.path.join(self.root, "src")
19
+ self.dst = os.path.join(self.root, "dst")
20
+ self.write(os.path.join(self.src, "settings.json"), '{"env": {"API_KEY": "sk-secret-1"}, "model": "opus"}\n')
21
+ self.write(os.path.join(self.src, "CLAUDE.md"), "rules\n")
22
+ self.write(os.path.join(self.src, "skills", "s1", "SKILL.md"), "skill\n")
23
+ os.symlink("/etc/hosts", os.path.join(self.src, "skills", "s1", "link"))
24
+ self.write(os.path.join(self.src, ".credentials.json"), '{"token": "cred-secret-2"}', 0o600)
25
+ self.write(os.path.join(self.src, "projects", "p", "s.jsonl"), "{}\n")
26
+ self.write(os.path.join(self.src, ".claude.json"), json.dumps({
27
+ "oauthAccount": {"emailAddress": "me@example.com"},
28
+ "mcpServers": {"gh": {"command": "gh-mcp", "headers": {"Authorization": "Bearer hdr-secret-3"}}}}))
29
+ self.bundle = os.path.join(self.tmp, "src.tgz")
30
+
31
+ def names(self, path=None):
32
+ with tarfile.open(path or self.bundle) as tar:
33
+ return sorted(tar.getnames())
34
+
35
+ def member(self, name, path=None):
36
+ with tarfile.open(path or self.bundle) as tar:
37
+ return tar.extractfile(name).read().decode()
38
+
39
+ def make_bundle(self, path, entries, manifest=None):
40
+ """手工构造配置包:entries 是 (TarInfo, bytes|None)。"""
41
+ with tarfile.open(path, "w:gz") as tar:
42
+ data = json.dumps(manifest or {"format": "multi-claude-config", "version": 1}).encode()
43
+ info = tarfile.TarInfo("manifest.json")
44
+ info.size = len(data)
45
+ tar.addfile(info, io.BytesIO(data))
46
+ for info, content in entries:
47
+ tar.addfile(info, io.BytesIO(content) if content is not None else None)
48
+
49
+
50
+ class ExportTest(BundleBase):
51
+ def test_t1_whitelist_only(self):
52
+ result = self.ok("export", "src", self.bundle)
53
+ names = self.names()
54
+ self.assertIn("items/settings.json", names)
55
+ self.assertIn("items/skills/s1/SKILL.md", names)
56
+ self.assertIn("mcp-servers.json", names)
57
+ for forbidden in ("credentials", "projects", ".claude.json", "items/skills/s1/link"):
58
+ self.assertFalse([n for n in names if forbidden in n], forbidden)
59
+ self.assertNotIn("me@example.com", self.member("mcp-servers.json"))
60
+ self.assertIn("skipped 1 link(s)", result.out)
61
+ self.assertEqual(stat.S_IMODE(os.stat(self.bundle).st_mode), 0o600)
62
+ self.assertEqual(json.loads(self.member("manifest.json"))["items"], ["settings.json", "CLAUDE.md", "skills"])
63
+
64
+ def test_t2_shared_link_existing_output_unregistered(self):
65
+ os.rename(os.path.join(self.src, "CLAUDE.md"), os.path.join(self.tmp, "shared.md"))
66
+ os.symlink(os.path.join(self.tmp, "shared.md"), os.path.join(self.src, "CLAUDE.md"))
67
+ result = self.ok("export", "src", self.bundle)
68
+ self.assertIn("skip CLAUDE.md (link)", result.out)
69
+ self.assertNotIn("items/CLAUDE.md", self.names())
70
+ self.assertEqual(self.run_cli("export", "src", self.bundle).code, 3)
71
+ broken = os.path.join(self.tmp, "broken.tgz")
72
+ os.symlink(os.path.join(self.tmp, "nowhere", "x.tgz"), broken)
73
+ self.assertEqual(self.run_cli("export", "src", broken).code, 3)
74
+ self.assertFalse(os.path.exists(os.path.join(self.tmp, "nowhere")))
75
+ self.assertEqual(self.run_cli("export", "nosuch", os.path.join(self.tmp, "n.tgz")).code, 1)
76
+ self.assertEqual(self.run_cli("export", "src", os.path.join(self.tmp, "no", "dir.tgz")).code, 1)
77
+
78
+ def test_t3_secret_key_names_without_values(self):
79
+ result = self.ok("export", "src", self.bundle)
80
+ self.assertIn("env.API_KEY", result.err)
81
+ self.assertIn("mcpServers.gh.headers.Authorization", result.err)
82
+ for value in ("sk-secret-1", "hdr-secret-3", "cred-secret-2"):
83
+ self.assertNotIn(value, result.out + result.err)
84
+
85
+
86
+ class ImportTest(BundleBase):
87
+ def setUp(self):
88
+ super().setUp()
89
+ self.ok("export", "src", self.bundle)
90
+ self.write(os.path.join(self.dst, ".claude.json"), json.dumps({"numStartups": 3}))
91
+
92
+ def test_t4_t5_import_and_rerun(self):
93
+ self.ok("import", self.bundle, "dst")
94
+ with open(os.path.join(self.dst, "settings.json")) as handle:
95
+ self.assertIn("opus", handle.read())
96
+ self.assertTrue(os.path.isfile(os.path.join(self.dst, "skills", "s1", "SKILL.md")))
97
+ with open(os.path.join(self.dst, ".claude.json")) as handle:
98
+ state = json.load(handle)
99
+ self.assertEqual(state["numStartups"], 3)
100
+ self.assertEqual(state["mcpServers"]["gh"]["command"], "gh-mcp")
101
+ self.assertFalse(os.path.exists(os.path.join(self.dst, ".credentials.json")))
102
+ again = self.ok("import", self.bundle, "dst")
103
+ self.assertNotIn("create", again.out)
104
+ self.assertIn("settings.json: unchanged", again.out)
105
+ self.assertIn("mcp server gh: unchanged", again.out)
106
+ self.assertFalse([n for n in os.listdir(self.dst) if n.startswith(".multi-claude-import")])
107
+
108
+ def test_t6_conflicts_and_force(self):
109
+ self.write(os.path.join(self.dst, "settings.json"), '{"model": "haiku"}\n')
110
+ self.write(os.path.join(self.dst, ".claude.json"), json.dumps({"mcpServers": {"gh": {"command": "other"}}}))
111
+ before = self.snapshot(self.dst)
112
+ result = self.run_cli("import", self.bundle, "dst")
113
+ self.assertEqual(result.code, 3, result)
114
+ self.assertIn("settings.json already exists", result.err)
115
+ self.assertIn("MCP server gh", result.err)
116
+ self.assertEqual(before, self.snapshot(self.dst))
117
+ self.ok("import", self.bundle, "dst", "--force")
118
+ backups = [n for n in os.listdir(self.dst) if ".multi-claude-bak." in n]
119
+ self.assertTrue(any(n.startswith("settings.json.") for n in backups), backups)
120
+ self.assertTrue(any(n.startswith(".claude.json.") for n in backups), backups)
121
+ with open(os.path.join(self.dst, ".claude.json")) as handle:
122
+ self.assertEqual(json.load(handle)["mcpServers"]["gh"]["command"], "gh-mcp")
123
+
124
+ def test_t7_shared_link_blocks_even_with_force(self):
125
+ os.symlink(os.path.join(self.tmp, "shared-skills"), os.path.join(self.dst, "skills"))
126
+ for extra in ((), ("--force",)):
127
+ result = self.run_cli("import", self.bundle, "dst", *extra)
128
+ self.assertEqual(result.code, 3, result)
129
+ self.assertIn("shared link", result.err)
130
+ self.assertFalse(os.path.exists(os.path.join(self.dst, "settings.json")))
131
+
132
+ def test_t8_malicious_bundles(self):
133
+ def entry(name, kind=tarfile.REGTYPE, content=b"x", linkname=""):
134
+ info = tarfile.TarInfo(name)
135
+ info.type = kind
136
+ info.linkname = linkname
137
+ info.size = len(content) if kind == tarfile.REGTYPE else 0
138
+ return info, content if kind == tarfile.REGTYPE else None
139
+ cases = {
140
+ "dotdot": [entry("items/skills/../../escape")],
141
+ "absolute": [entry("/tmp/escape")],
142
+ "symlink": [entry("items/skills/l", tarfile.SYMTYPE, linkname="/etc")],
143
+ "hardlink": [entry("items/skills/h", tarfile.LNKTYPE, linkname="manifest.json")],
144
+ "outside": [entry("items/.credentials.json")],
145
+ "toplevel": [entry("other.txt")],
146
+ }
147
+ before = self.snapshot(self.dst)
148
+ for label, entries in cases.items():
149
+ with self.subTest(label=label):
150
+ path = os.path.join(self.tmp, label + ".tgz")
151
+ self.make_bundle(path, entries)
152
+ self.assertEqual(self.run_cli("import", path, "dst").code, 1)
153
+ bad_version = os.path.join(self.tmp, "v9.tgz")
154
+ self.make_bundle(bad_version, [], manifest={"format": "multi-claude-config", "version": 9})
155
+ self.assertEqual(self.run_cli("import", bad_version, "dst").code, 1)
156
+ no_manifest = os.path.join(self.tmp, "nom.tgz")
157
+ with tarfile.open(no_manifest, "w:gz") as tar:
158
+ info, content = entry("items/CLAUDE.md")
159
+ tar.addfile(info, io.BytesIO(content))
160
+ self.assertEqual(self.run_cli("import", no_manifest, "dst").code, 1)
161
+ self.assertEqual(self.run_cli("import", os.path.join(self.tmp, "missing.tgz"), "dst").code, 1)
162
+ self.assertEqual(before, self.snapshot(self.dst))
163
+ self.assertFalse(os.path.lexists(os.path.join(self.root, "escape")))
164
+
165
+ def test_t9_dry_run(self):
166
+ before = self.snapshot(self.dst)
167
+ result = self.ok("import", self.bundle, "dst", "--dry-run")
168
+ self.assertIn("(dry-run) import settings.json: create", result.out)
169
+ self.assertEqual(before, self.snapshot(self.dst))
170
+
171
+ def test_t10_unregistered_and_unfinished_migration(self):
172
+ self.assertEqual(self.run_cli("import", self.bundle, "nosuch").code, 1)
173
+ with open(os.path.join(self.state, "migrate-journal.json"), "w") as handle:
174
+ json.dump({"name": "main", "source": "s", "target": "t", "backup": "b", "mode": "rename",
175
+ "phase": "planned"}, handle)
176
+ result = self.run_cli("import", self.bundle, "dst")
177
+ self.assertEqual(result.code, 1, result)
178
+ self.assertIn("unfinished migration", result.err)
179
+ self.assertEqual(self.run_cli("export", "src", os.path.join(self.tmp, "x.tgz")).code, 1)
180
+
181
+
182
+ class ImportSafetyTest(BundleBase):
183
+ def setUp(self):
184
+ super().setUp()
185
+ self.ok("export", "src", self.bundle)
186
+ self.state_file = os.path.join(self.dst, ".claude.json")
187
+
188
+ def test_invalid_claude_json_changes_nothing(self):
189
+ self.write(self.state_file, "{broken")
190
+ before = self.snapshot(self.dst)
191
+ self.assertEqual(self.run_cli("import", self.bundle, "dst", "--force").code, 1)
192
+ self.assertEqual(before, self.snapshot(self.dst))
193
+
194
+ def test_mcp_servers_not_an_object_is_blocked(self):
195
+ self.write(self.state_file, json.dumps({"mcpServers": ["x"]}))
196
+ self.assertEqual(self.run_cli("import", self.bundle, "dst", "--force").code, 3)
197
+ self.assertFalse(os.path.exists(os.path.join(self.dst, "settings.json")))
198
+
199
+ def test_mode_kept_and_backup_only_when_replacing(self):
200
+ self.write(self.state_file, json.dumps({"numStartups": 1}), 0o600)
201
+ result = self.ok("import", self.bundle, "dst")
202
+ self.assertIn("may overwrite the imported MCP servers", result.err)
203
+ self.assertEqual(stat.S_IMODE(os.stat(self.state_file).st_mode), 0o600)
204
+ self.assertFalse([n for n in os.listdir(self.dst) if ".multi-claude-bak." in n])
205
+
206
+ def test_symlinked_claude_json_is_written_through(self):
207
+ real = self.write(os.path.join(self.tmp, "dotfiles", "claude.json"), json.dumps({"numStartups": 1}))
208
+ os.symlink(real, self.state_file)
209
+ self.ok("import", self.bundle, "dst")
210
+ self.assertTrue(os.path.islink(self.state_file))
211
+ with open(real) as handle:
212
+ self.assertIn("gh", json.load(handle)["mcpServers"])
213
+
214
+ def test_leftover_staging_removed_and_force_dry_run(self):
215
+ leftover = os.path.join(self.dst, ".multi-claude-import.old")
216
+ os.makedirs(os.path.join(leftover, "items"))
217
+ self.write(os.path.join(self.dst, "settings.json"), '{"model": "haiku"}\n')
218
+ result = self.ok("import", self.bundle, "dst", "--force", "--dry-run")
219
+ self.assertIn("(dry-run) import settings.json: replace", result.out)
220
+ with open(os.path.join(self.dst, "settings.json")) as handle:
221
+ self.assertIn("haiku", handle.read())
222
+ self.assertFalse([n for n in os.listdir(self.dst) if n.startswith(".multi-claude-import")])
223
+
224
+ def test_bundle_shape_checks(self):
225
+ def file_entry(name, content=b"x", mode=0o644):
226
+ info = tarfile.TarInfo(name)
227
+ info.size = len(content)
228
+ info.mode = mode
229
+ return info, content
230
+
231
+ def dir_entry(name):
232
+ info = tarfile.TarInfo(name)
233
+ info.type = tarfile.DIRTYPE
234
+ return info, None
235
+ cases = {
236
+ "duplicate": [file_entry("items/CLAUDE.md"), file_entry("items/CLAUDE.md")],
237
+ "settings-dir": [dir_entry("items/settings.json")],
238
+ "skills-file": [file_entry("items/skills")],
239
+ }
240
+ for label, entries in cases.items():
241
+ with self.subTest(label=label):
242
+ path = os.path.join(self.tmp, label + ".tgz")
243
+ self.make_bundle(path, entries)
244
+ self.assertEqual(self.run_cli("import", path, "dst").code, 1)
245
+ loose = os.path.join(self.tmp, "loose.tgz")
246
+ self.make_bundle(loose, [dir_entry("items/skills"), file_entry("items/skills/run.sh", mode=0o4777),
247
+ file_entry("items/CLAUDE.md", mode=0o666)])
248
+ self.ok("import", loose, "dst")
249
+ self.assertEqual(stat.S_IMODE(os.stat(os.path.join(self.dst, "skills", "run.sh")).st_mode), 0o700)
250
+ self.assertEqual(stat.S_IMODE(os.stat(os.path.join(self.dst, "CLAUDE.md")).st_mode), 0o600)
251
+
252
+ def test_missing_account_directory(self):
253
+ import shutil
254
+ shutil.rmtree(self.dst)
255
+ self.assertEqual(self.run_cli("import", self.bundle, "dst").code, 1)
256
+
257
+
258
+ class ExportPathTest(BundleBase):
259
+ def test_existing_file_dir_and_symlinked_parent(self):
260
+ existing = self.write(os.path.join(self.tmp, "exists.tgz"), "keep")
261
+ self.assertEqual(self.run_cli("export", "src", existing).code, 3)
262
+ with open(existing) as handle:
263
+ self.assertEqual(handle.read(), "keep")
264
+ os.makedirs(os.path.join(self.tmp, "adir"))
265
+ self.assertEqual(self.run_cli("export", "src", os.path.join(self.tmp, "adir")).code, 3)
266
+ # 父路径含软链加 ..:必须落在内核解析出的真实位置(软链目标的父目录)
267
+ real_parent = os.path.join(self.tmp, "real", "sub")
268
+ os.makedirs(real_parent)
269
+ os.symlink(real_parent, os.path.join(self.tmp, "link"))
270
+ self.ok("export", "src", "link/../out.tgz", cwd=self.tmp)
271
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, "real", "out.tgz")))
272
+ self.assertFalse(os.path.exists(os.path.join(self.tmp, "out.tgz")))
273
+ self.assertEqual(self.run_cli("export").code, 2)
274
+
275
+
276
+ class DefaultIdentityTest(CliTestCase):
277
+ def test_t11_default_identity_uses_home_claude_json(self):
278
+ source = os.path.join(self.home, ".claude")
279
+ self.write(os.path.join(source, "settings.json"), "{}\n")
280
+ self.write(os.path.join(self.home, ".claude.json"),
281
+ json.dumps({"mcpServers": {"m": {"command": "m"}}, "numStartups": 1}))
282
+ self.ok("migrate-default", "main")
283
+ bundle = os.path.join(self.tmp, "main.tgz")
284
+ self.ok("export", "main", bundle)
285
+ with tarfile.open(bundle) as tar:
286
+ self.assertIn('"m"', tar.extractfile("mcp-servers.json").read().decode())
287
+ self.ok("add", "other")
288
+ with open(os.path.join(self.home, ".claude.json"), "w") as handle:
289
+ json.dump({"numStartups": 1}, handle)
290
+ self.ok("import", bundle, "main")
291
+ with open(os.path.join(self.home, ".claude.json")) as handle:
292
+ self.assertIn("m", json.load(handle)["mcpServers"])
293
+
294
+
295
+ class HelpTest(CliTestCase):
296
+ def test_t12_help_and_completion(self):
297
+ self.assertIn("export, import", self.ok("--help").out)
298
+ for shell in ("bash", "zsh", "fish"):
299
+ script = self.ok("completion", shell).out
300
+ self.assertIn("export", script)
301
+ self.assertIn("import", script)
302
+
303
+
304
+ if __name__ == "__main__":
305
+ unittest.main()