multi-claude-cli 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multi_claude/__init__.py +3 -0
- multi_claude/__main__.py +12 -0
- multi_claude/accounts.py +434 -0
- multi_claude/actions.py +66 -0
- multi_claude/cli.py +1302 -0
- multi_claude/completion.py +281 -0
- multi_claude/config.py +488 -0
- multi_claude/doctor.py +221 -0
- multi_claude/env.py +46 -0
- multi_claude/fsutil.py +234 -0
- multi_claude/hook.py +90 -0
- multi_claude/identity.py +94 -0
- multi_claude/launcher.py +193 -0
- multi_claude/lock.py +47 -0
- multi_claude/migrate.py +709 -0
- multi_claude/platform.py +548 -0
- multi_claude/routes.py +133 -0
- multi_claude/sessions.py +195 -0
- multi_claude/shared.py +130 -0
- multi_claude/shellpath.py +33 -0
- multi_claude/statusline.py +196 -0
- multi_claude/usage.py +243 -0
- multi_claude_cli-0.4.0.dist-info/METADATA +457 -0
- multi_claude_cli-0.4.0.dist-info/RECORD +28 -0
- multi_claude_cli-0.4.0.dist-info/WHEEL +5 -0
- multi_claude_cli-0.4.0.dist-info/entry_points.txt +2 -0
- multi_claude_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
- multi_claude_cli-0.4.0.dist-info/top_level.txt +1 -0
multi_claude/doctor.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""`multi-claude doctor`:只读检查多账号环境是否健康(方案 v0.2 §5.1.2)。
|
|
2
|
+
|
|
3
|
+
所有检查都不修改任何东西、不读凭据:登录检查沿用 identity.probe 的存在性探测。
|
|
4
|
+
每条非 ok 结果的 message 里给出修复命令,由用户自己执行。
|
|
5
|
+
配置文件或迁移记录损坏时不抛异常,而是记成对应检查项的 error,其余不依赖配置的检查照常执行,
|
|
6
|
+
这样 `doctor --json` 在任何状态下都输出合法 JSON。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import platform as py_platform
|
|
11
|
+
import shutil
|
|
12
|
+
import stat
|
|
13
|
+
from typing import List, NamedTuple, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from . import accounts, identity, launcher, migrate, routes, shellpath
|
|
16
|
+
from .config import IDENTITY_DEFAULT, Account, Config, ConfigError, load_config
|
|
17
|
+
from .fsutil import expand
|
|
18
|
+
|
|
19
|
+
LEVEL_OK = "ok"
|
|
20
|
+
LEVEL_WARN = "warn"
|
|
21
|
+
LEVEL_ERROR = "error"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Check(NamedTuple):
|
|
25
|
+
id: str
|
|
26
|
+
level: str
|
|
27
|
+
# 检查对象:账号名、变量名、路径等;全局检查用 "-"
|
|
28
|
+
subject: str
|
|
29
|
+
message: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_checks() -> List[Check]:
|
|
33
|
+
checks: List[Check] = []
|
|
34
|
+
checks.append(_check_journal())
|
|
35
|
+
config, config_check = _load()
|
|
36
|
+
checks.append(config_check)
|
|
37
|
+
checks.append(_check_claude_on_path())
|
|
38
|
+
checks.extend(_check_auth_env())
|
|
39
|
+
if config is None:
|
|
40
|
+
return checks
|
|
41
|
+
checks.append(_check_bin_on_path(config))
|
|
42
|
+
for account in config.accounts.values():
|
|
43
|
+
checks.extend(_check_account(config, account))
|
|
44
|
+
checks.extend(_check_routes(config))
|
|
45
|
+
return checks
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_routes(config: Config) -> List[Check]:
|
|
49
|
+
"""路由启用时检查 claude-auto 与每条规则(方案 feature-directory-routing §5.1.6)。"""
|
|
50
|
+
if not config.routes_enabled:
|
|
51
|
+
return []
|
|
52
|
+
checks: List[Check] = []
|
|
53
|
+
status = routes.router_status(config)
|
|
54
|
+
path = routes.router_path(config.bin_dir)
|
|
55
|
+
if status == "ok":
|
|
56
|
+
checks.append(Check("router", LEVEL_OK, routes.ROUTER_NAME, path))
|
|
57
|
+
else:
|
|
58
|
+
hint = {"missing": "is missing", "stale": "is out of date",
|
|
59
|
+
"conflict": "is taken by a file multi-claude does not manage"}[status]
|
|
60
|
+
checks.append(Check("router", LEVEL_ERROR, routes.ROUTER_NAME,
|
|
61
|
+
"{} {}; run `multi-claude apply`".format(path, hint)))
|
|
62
|
+
for rule in config.route_rules:
|
|
63
|
+
if config.find(rule.account) is None:
|
|
64
|
+
checks.append(Check("route-account", LEVEL_ERROR, rule.path,
|
|
65
|
+
"uses account {!r}, which is not registered; run `multi-claude route {} --remove` "
|
|
66
|
+
"or register the account".format(rule.account, rule.path)))
|
|
67
|
+
if not os.path.isdir(expand(rule.path)):
|
|
68
|
+
checks.append(Check("route-path", LEVEL_WARN, rule.path,
|
|
69
|
+
"the directory does not exist, so this route is ignored"))
|
|
70
|
+
if config.route_default is not None and config.find(config.route_default) is None:
|
|
71
|
+
checks.append(Check("route-account", LEVEL_ERROR, "(default)",
|
|
72
|
+
"uses account {!r}, which is not registered; run `multi-claude route --no-default` "
|
|
73
|
+
"or register the account".format(config.route_default)))
|
|
74
|
+
return checks
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _check_journal() -> Check:
|
|
78
|
+
try:
|
|
79
|
+
journal = migrate.load_journal()
|
|
80
|
+
except migrate.JournalError as exc:
|
|
81
|
+
return Check("journal", LEVEL_ERROR, "-", "{}; see the README on abandoning a migration".format(exc))
|
|
82
|
+
if journal is not None:
|
|
83
|
+
name = journal.get("name")
|
|
84
|
+
return Check("journal", LEVEL_ERROR, str(name),
|
|
85
|
+
"an unfinished migration exists; run `multi-claude migrate-default {}`".format(name))
|
|
86
|
+
return Check("journal", LEVEL_OK, "-", "no unfinished migration")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load() -> Tuple[Optional[Config], Check]:
|
|
90
|
+
try:
|
|
91
|
+
config, exists = load_config()
|
|
92
|
+
except ConfigError as exc:
|
|
93
|
+
return None, Check("config", LEVEL_ERROR, "-", str(exc))
|
|
94
|
+
if not exists:
|
|
95
|
+
# 没有配置也照常检查全局项;默认配置里没有账号,按账号的检查自然为空。
|
|
96
|
+
return config, Check("config", LEVEL_WARN, "-", "no configuration yet; run `multi-claude add NAME`")
|
|
97
|
+
return config, Check("config", LEVEL_OK, "-", "configuration is valid")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _check_claude_on_path() -> Check:
|
|
101
|
+
found = shutil.which("claude")
|
|
102
|
+
if found is None:
|
|
103
|
+
return Check("claude-on-path", LEVEL_ERROR, "claude", "`claude` is not on PATH; install Claude Code")
|
|
104
|
+
return Check("claude-on-path", LEVEL_OK, "claude", found)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check_auth_env() -> List[Check]:
|
|
108
|
+
checks = []
|
|
109
|
+
for variable in migrate.isolation_env_variables():
|
|
110
|
+
if not os.environ.get(variable):
|
|
111
|
+
continue
|
|
112
|
+
# 只报变量名,绝不输出变量的值(多为凭据)。
|
|
113
|
+
if variable in launcher.AUTH_OVERRIDE_ENV:
|
|
114
|
+
message = "set in this shell; launchers clear it, but running `claude` directly uses it"
|
|
115
|
+
else:
|
|
116
|
+
message = "set in this shell; every account launched from it will share it"
|
|
117
|
+
checks.append(Check("auth-env", LEVEL_WARN, variable, message))
|
|
118
|
+
if not checks:
|
|
119
|
+
checks.append(Check("auth-env", LEVEL_OK, "-", "no variable overrides the accounts' logins"))
|
|
120
|
+
return checks
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _path_entries() -> List[str]:
|
|
124
|
+
return [os.path.abspath(os.path.expanduser(entry))
|
|
125
|
+
for entry in os.environ.get("PATH", "").split(os.pathsep) if entry]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _check_bin_on_path(config: Config) -> Check:
|
|
129
|
+
bin_dir = expand(config.bin_dir)
|
|
130
|
+
if bin_dir not in _path_entries():
|
|
131
|
+
return Check("bin-on-path", LEVEL_WARN, bin_dir,
|
|
132
|
+
"the launcher directory is not on PATH; " + shellpath.path_hint(
|
|
133
|
+
bin_dir, os.environ.get("SHELL"), py_platform.system()))
|
|
134
|
+
return Check("bin-on-path", LEVEL_OK, bin_dir, "on PATH")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _check_account(config: Config, account: Account) -> List[Check]:
|
|
138
|
+
name = account.name
|
|
139
|
+
directory = accounts.account_dir(config, name)
|
|
140
|
+
checks: List[Check] = []
|
|
141
|
+
|
|
142
|
+
status = accounts.launcher_status(config, name)
|
|
143
|
+
if status == "ok":
|
|
144
|
+
checks.append(Check("launcher", LEVEL_OK, name, "up to date"))
|
|
145
|
+
else:
|
|
146
|
+
hint = {"missing": "the launcher is missing",
|
|
147
|
+
"stale": "the launcher is out of date",
|
|
148
|
+
"conflict": "the launcher path is taken by a file multi-claude does not manage"}[status]
|
|
149
|
+
checks.append(Check("launcher", LEVEL_ERROR, name, "{}; run `multi-claude apply`".format(hint)))
|
|
150
|
+
|
|
151
|
+
expected = launcher.launcher_path(expand(config.bin_dir), name)
|
|
152
|
+
found = shutil.which(launcher.LAUNCHER_PREFIX + name)
|
|
153
|
+
# 找不到时由 bin-on-path 或 launcher 检查报告,这里只管“PATH 上先找到了别的同名文件”。
|
|
154
|
+
if found is not None and os.path.realpath(found) != os.path.realpath(expected):
|
|
155
|
+
checks.append(Check("launcher-shadowed", LEVEL_WARN, name,
|
|
156
|
+
"{} comes first on PATH and hides {}".format(found, expected)))
|
|
157
|
+
|
|
158
|
+
checks.extend(_check_account_dir(name, directory))
|
|
159
|
+
if account.identity == IDENTITY_DEFAULT:
|
|
160
|
+
link = accounts.default_link_status(config, account)
|
|
161
|
+
if link != "ok":
|
|
162
|
+
checks.append(Check("default-link", LEVEL_ERROR, name,
|
|
163
|
+
"{} is {}; restore the link to {} or run `multi-claude migrate-default {}`".format(
|
|
164
|
+
accounts.default_dir(), link, directory, name)))
|
|
165
|
+
else:
|
|
166
|
+
checks.append(Check("default-link", LEVEL_OK, name, "{} -> {}".format(accounts.default_dir(), directory)))
|
|
167
|
+
|
|
168
|
+
for item in config.shared_items:
|
|
169
|
+
path = os.path.join(directory, item)
|
|
170
|
+
if os.path.islink(path) and not os.path.exists(path):
|
|
171
|
+
checks.append(Check("shared-link", LEVEL_WARN, name,
|
|
172
|
+
"{} is a broken link to {}".format(path, os.readlink(path))))
|
|
173
|
+
|
|
174
|
+
login = identity.probe(account.identity, directory)
|
|
175
|
+
if login in (identity.LOGIN_NONE, identity.LOGIN_UNKNOWN):
|
|
176
|
+
message = ("no login found; start `claude-{}` and log in".format(name) if login == identity.LOGIN_NONE
|
|
177
|
+
else "could not check the login (the keychain query failed)")
|
|
178
|
+
checks.append(Check("login", LEVEL_WARN, name, message))
|
|
179
|
+
else:
|
|
180
|
+
checks.append(Check("login", LEVEL_OK, name, login))
|
|
181
|
+
return checks
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check_account_dir(name: str, directory: str) -> List[Check]:
|
|
185
|
+
try:
|
|
186
|
+
mode = stat.S_IMODE(os.stat(directory).st_mode)
|
|
187
|
+
except OSError:
|
|
188
|
+
return [Check("account-dir", LEVEL_ERROR, name, "{} does not exist".format(directory))]
|
|
189
|
+
if not os.path.isdir(directory):
|
|
190
|
+
return [Check("account-dir", LEVEL_ERROR, name, "{} is not a directory".format(directory))]
|
|
191
|
+
if mode & 0o077:
|
|
192
|
+
# 目录里有会话记录,Linux 上还有 .credentials.json,组或其他用户不应能读写。
|
|
193
|
+
return [Check("account-dir", LEVEL_WARN, name,
|
|
194
|
+
"{} has mode {:o}; run `chmod 700 '{}'`".format(directory, mode, directory))]
|
|
195
|
+
return [Check("account-dir", LEVEL_OK, name, directory)]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def summary(checks: List[Check]) -> dict:
|
|
199
|
+
counts = {LEVEL_OK: 0, LEVEL_WARN: 0, LEVEL_ERROR: 0}
|
|
200
|
+
for check in checks:
|
|
201
|
+
counts[check.level] += 1
|
|
202
|
+
return counts
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def format_text(checks: List[Check], verbose: bool) -> List[str]:
|
|
206
|
+
lines = ["[{}] {} {}: {}".format(check.level, check.id, check.subject, check.message)
|
|
207
|
+
for check in checks if verbose or check.level != LEVEL_OK]
|
|
208
|
+
counts = summary(checks)
|
|
209
|
+
lines.append("{} ok, {} warning(s), {} error(s)".format(counts[LEVEL_OK], counts[LEVEL_WARN],
|
|
210
|
+
counts[LEVEL_ERROR]))
|
|
211
|
+
return lines
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def exit_code(checks: List[Check]) -> int:
|
|
215
|
+
return 1 if any(check.level == LEVEL_ERROR for check in checks) else 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def to_dict(checks: List[Check]) -> dict:
|
|
219
|
+
return {"schema_version": 1,
|
|
220
|
+
"checks": [check._asdict() for check in checks],
|
|
221
|
+
"summary": summary(checks)}
|
multi_claude/env.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""按账号构造进程环境,与启动命令 `claude-<名称>` 的语义一致(方案 feature-rename-mcp §5.1.4)。
|
|
2
|
+
|
|
3
|
+
`multi-claude mcp NAME …` 不能经启动命令转交:固定参数排在子命令之前,`--allowedTools` 这类取多个值的
|
|
4
|
+
选项会把 `mcp`、`add` 吞成自己的值。所以这里用 Python 复刻启动命令里与环境有关的部分(不含固定参数)。
|
|
5
|
+
两套实现必须保持一致:测试会对同一账号比较本模块的结果与启动命令实际导出的环境。
|
|
6
|
+
处理顺序与 launcher.render 相同:身份 → 清除鉴权覆盖变量 → 额外环境变量 → 代理。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Dict, Mapping
|
|
10
|
+
|
|
11
|
+
from . import accounts, launcher
|
|
12
|
+
from .config import IDENTITY_DEFAULT, PROXY_INHERIT, PROXY_OFF, PROXY_VARS, Account, Config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def account_env(config: Config, account: Account, base: Mapping[str, str]) -> Dict[str, str]:
|
|
16
|
+
env = dict(base)
|
|
17
|
+
if account.identity == IDENTITY_DEFAULT:
|
|
18
|
+
# default 身份靠“不设 CLAUDE_CONFIG_DIR”使用 ~/.claude 与不带后缀的钥匙串条目。
|
|
19
|
+
env.pop("CLAUDE_CONFIG_DIR", None)
|
|
20
|
+
env.pop("CLAUDE_SECURESTORAGE_CONFIG_DIR", None)
|
|
21
|
+
else:
|
|
22
|
+
env.pop("CLAUDE_SECURESTORAGE_CONFIG_DIR", None)
|
|
23
|
+
env["CLAUDE_CONFIG_DIR"] = accounts.account_dir(config, account.name)
|
|
24
|
+
for name in launcher.AUTH_OVERRIDE_ENV:
|
|
25
|
+
env.pop(name, None)
|
|
26
|
+
for key, value in config.effective_env(account).items():
|
|
27
|
+
env[key] = launcher.expand_value(value)
|
|
28
|
+
_apply_proxy(env, account.proxy)
|
|
29
|
+
return env
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _apply_proxy(env: Dict[str, str], proxy: str) -> None:
|
|
33
|
+
if proxy == PROXY_INHERIT:
|
|
34
|
+
return
|
|
35
|
+
if proxy == PROXY_OFF:
|
|
36
|
+
for name in PROXY_VARS:
|
|
37
|
+
env.pop(name, None)
|
|
38
|
+
return
|
|
39
|
+
for name in ("HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"):
|
|
40
|
+
env[name] = proxy
|
|
41
|
+
env.pop("ALL_PROXY", None)
|
|
42
|
+
env.pop("all_proxy", None)
|
|
43
|
+
# 与启动命令的 ${NO_PROXY:+$NO_PROXY,} 相同:原值为空时不留前导逗号。
|
|
44
|
+
for name in ("NO_PROXY", "no_proxy"):
|
|
45
|
+
current = env.get(name, "")
|
|
46
|
+
env[name] = "{},{}".format(current, launcher._NO_PROXY_HOSTS) if current else launcher._NO_PROXY_HOSTS
|
multi_claude/fsutil.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""文件系统工具:原子写入、路径判定、文件清单与校验、跳过特殊文件的复制。
|
|
2
|
+
|
|
3
|
+
本模块只做与业务无关的文件操作,不读取配置,也不输出日志;
|
|
4
|
+
调用方负责决定何时调用以及如何报告结果。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import tempfile
|
|
12
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
# entry_kind() 的返回值
|
|
15
|
+
KIND_MISSING = "missing"
|
|
16
|
+
KIND_LINK = "link"
|
|
17
|
+
KIND_DIR = "dir"
|
|
18
|
+
KIND_FILE = "file"
|
|
19
|
+
# socket、FIFO、设备文件等运行时产物
|
|
20
|
+
KIND_SPECIAL = "special"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def expand(path: str) -> str:
|
|
24
|
+
"""展开 `~` 并转成绝对路径;不解析软链,调用方需要真实路径时自行 realpath。"""
|
|
25
|
+
return os.path.abspath(os.path.expanduser(path))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_under(path: str, root: str) -> bool:
|
|
29
|
+
"""path 等于 root 或位于 root 之下。
|
|
30
|
+
|
|
31
|
+
必须按路径段比较:直接用 startswith(root) 会把 `~/.claude-shared`
|
|
32
|
+
误判为位于 `~/.claude` 之下。
|
|
33
|
+
"""
|
|
34
|
+
root = root.rstrip(os.sep) or os.sep
|
|
35
|
+
if path == root:
|
|
36
|
+
return True
|
|
37
|
+
prefix = root if root.endswith(os.sep) else root + os.sep
|
|
38
|
+
return path.startswith(prefix)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def entry_kind(path: str) -> str:
|
|
42
|
+
"""按 lstat 判断目录项类型,不跟随软链;断开的软链返回 KIND_LINK。"""
|
|
43
|
+
try:
|
|
44
|
+
st = os.lstat(path)
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
return KIND_MISSING
|
|
47
|
+
if stat.S_ISLNK(st.st_mode):
|
|
48
|
+
return KIND_LINK
|
|
49
|
+
if stat.S_ISDIR(st.st_mode):
|
|
50
|
+
return KIND_DIR
|
|
51
|
+
if stat.S_ISREG(st.st_mode):
|
|
52
|
+
return KIND_FILE
|
|
53
|
+
return KIND_SPECIAL
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def same_target(link_path: str, target: str) -> bool:
|
|
57
|
+
"""link_path 解析后的真实路径是否与 target 的真实路径相同。
|
|
58
|
+
|
|
59
|
+
两边都用 realpath,这样 `~` 写法、多级软链或尾部斜杠都不影响判断。
|
|
60
|
+
"""
|
|
61
|
+
return os.path.realpath(link_path) == os.path.realpath(target)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def atomic_write(path: str, content: str, mode: int = 0o644) -> None:
|
|
65
|
+
"""原子替换 path 的内容:写临时文件、fsync,再用 os.replace 覆盖。
|
|
66
|
+
|
|
67
|
+
进程在任何时刻被杀,path 要么是旧内容,要么是完整的新内容,
|
|
68
|
+
不会出现写了一半的文件。临时文件与目标在同一目录,保证 replace 不跨设备。
|
|
69
|
+
"""
|
|
70
|
+
directory = os.path.dirname(path) or "."
|
|
71
|
+
os.makedirs(directory, exist_ok=True)
|
|
72
|
+
fd, tmp_path = tempfile.mkstemp(prefix=".multi-claude-", dir=directory)
|
|
73
|
+
try:
|
|
74
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
75
|
+
handle.write(content)
|
|
76
|
+
handle.flush()
|
|
77
|
+
os.fsync(handle.fileno())
|
|
78
|
+
os.chmod(tmp_path, mode)
|
|
79
|
+
os.replace(tmp_path, path)
|
|
80
|
+
except BaseException:
|
|
81
|
+
try:
|
|
82
|
+
os.unlink(tmp_path)
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
pass
|
|
85
|
+
raise
|
|
86
|
+
_fsync_dir(directory)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _fsync_dir(directory: str) -> None:
|
|
90
|
+
# 让 rename 本身落盘;部分文件系统不支持对目录 fsync,失败可以忽略。
|
|
91
|
+
try:
|
|
92
|
+
dir_fd = os.open(directory, os.O_RDONLY)
|
|
93
|
+
except OSError:
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
os.fsync(dir_fd)
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
finally:
|
|
100
|
+
os.close(dir_fd)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_text(path: str) -> Optional[str]:
|
|
104
|
+
"""读取 UTF-8 文本;文件不存在、不可读或不是文本时返回 None。"""
|
|
105
|
+
try:
|
|
106
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
107
|
+
return handle.read()
|
|
108
|
+
except (OSError, UnicodeDecodeError):
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def remove_path(path: str) -> None:
|
|
113
|
+
"""删除一个目录项:软链只删链接本身,目录整体删除,普通文件直接删除。
|
|
114
|
+
|
|
115
|
+
路径不存在时什么也不做,便于中断后重跑时重复调用。
|
|
116
|
+
"""
|
|
117
|
+
kind = entry_kind(path)
|
|
118
|
+
if kind == KIND_MISSING:
|
|
119
|
+
return
|
|
120
|
+
if kind == KIND_DIR:
|
|
121
|
+
_grant_owner_access(path)
|
|
122
|
+
shutil.rmtree(path)
|
|
123
|
+
else:
|
|
124
|
+
os.unlink(path)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _grant_owner_access(root: str) -> None:
|
|
128
|
+
"""删除前给目录树里的每个目录补上本人的读、写、执行权限。
|
|
129
|
+
|
|
130
|
+
没有读或执行权限的目录,rmtree 既列不出内容也删不掉里面的文件;
|
|
131
|
+
而用 rmtree 的错误回调补权限再重试并不可靠(新版 rmtree 按文件描述符遍历,
|
|
132
|
+
回调拿到的函数可能是 os.open,直接重调会抛 TypeError)。
|
|
133
|
+
所以先自上而下补齐权限:父目录先改,os.walk 才能继续列出子目录。软链不跟随、不修改。
|
|
134
|
+
权限已齐全的目录不动:中途遇到无权修改的目录会抛错,此时已处理过的目录应尽量保持原样。
|
|
135
|
+
"""
|
|
136
|
+
_add_owner_rwx(root)
|
|
137
|
+
for dirpath, dirnames, _filenames in os.walk(root, followlinks=False):
|
|
138
|
+
for name in dirnames:
|
|
139
|
+
full = os.path.join(dirpath, name)
|
|
140
|
+
if entry_kind(full) == KIND_DIR:
|
|
141
|
+
_add_owner_rwx(full)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _add_owner_rwx(path: str) -> None:
|
|
145
|
+
mode = stat.S_IMODE(os.lstat(path).st_mode)
|
|
146
|
+
if mode & stat.S_IRWXU != stat.S_IRWXU:
|
|
147
|
+
os.chmod(path, mode | stat.S_IRWXU)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# 清单条目:(类型, 大小, SHA-256 或软链目标)。目录的大小与哈希固定为 0 和空串。
|
|
151
|
+
ManifestEntry = Tuple[str, int, str]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_manifest(root: str) -> Tuple[Dict[str, ManifestEntry], List[str]]:
|
|
155
|
+
"""递归生成 root 的文件清单,返回 (清单, 跳过的特殊文件相对路径)。
|
|
156
|
+
|
|
157
|
+
软链不跟随,只记录链接目标;socket、FIFO、设备文件属于运行时产物,
|
|
158
|
+
不进清单,单独返回给调用方写日志。
|
|
159
|
+
"""
|
|
160
|
+
manifest: Dict[str, ManifestEntry] = {}
|
|
161
|
+
skipped: List[str] = []
|
|
162
|
+
# onerror 必须抛出:os.walk 默认静默跳过读不了的目录,两侧清单会同样缺一整棵子树,
|
|
163
|
+
# 校验因此误判“一致”,随后 park 并删除备份,数据就丢了。
|
|
164
|
+
for dirpath, dirnames, filenames in os.walk(root, onerror=_raise, followlinks=False):
|
|
165
|
+
rel_dir = os.path.relpath(dirpath, root)
|
|
166
|
+
entries = [(name, True) for name in dirnames] + [(name, False) for name in filenames]
|
|
167
|
+
for name, _is_dir_hint in entries:
|
|
168
|
+
full = os.path.join(dirpath, name)
|
|
169
|
+
rel = name if rel_dir == "." else os.path.join(rel_dir, name)
|
|
170
|
+
kind = entry_kind(full)
|
|
171
|
+
if kind == KIND_LINK:
|
|
172
|
+
manifest[rel] = (KIND_LINK, 0, os.readlink(full))
|
|
173
|
+
elif kind == KIND_DIR:
|
|
174
|
+
manifest[rel] = (KIND_DIR, 0, "")
|
|
175
|
+
elif kind == KIND_FILE:
|
|
176
|
+
manifest[rel] = (KIND_FILE, os.lstat(full).st_size, _sha256(full))
|
|
177
|
+
elif kind == KIND_SPECIAL:
|
|
178
|
+
skipped.append(rel)
|
|
179
|
+
return manifest, skipped
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _raise(exc: OSError) -> None:
|
|
183
|
+
raise exc
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _sha256(path: str) -> str:
|
|
187
|
+
digest = hashlib.sha256()
|
|
188
|
+
with open(path, "rb") as handle:
|
|
189
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
190
|
+
digest.update(chunk)
|
|
191
|
+
return digest.hexdigest()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def copy_tree(src: str, dst: str, on_progress: Optional[Callable[[str], None]] = None) -> List[str]:
|
|
195
|
+
"""把 src 复制到尚不存在的 dst,返回跳过的特殊文件相对路径。
|
|
196
|
+
|
|
197
|
+
与 shutil.copytree 的区别:遇到 socket、FIFO、设备文件时跳过而不是报错
|
|
198
|
+
(copytree 会抛 `Operation not supported on socket` 并留下半份目标)。
|
|
199
|
+
软链按软链本身复制;普通文件用 copy2 保留权限位和时间戳。
|
|
200
|
+
目录权限在内容复制完后再设置,避免只读目录挡住后续写入。
|
|
201
|
+
on_progress 在每个普通文件复制后调用一次,供测试在复制中途注入中断。
|
|
202
|
+
"""
|
|
203
|
+
skipped: List[str] = []
|
|
204
|
+
dir_modes: List[Tuple[str, str]] = []
|
|
205
|
+
os.makedirs(dst)
|
|
206
|
+
dir_modes.append((src, dst))
|
|
207
|
+
for dirpath, dirnames, filenames in os.walk(src, onerror=_raise, followlinks=False):
|
|
208
|
+
rel_dir = os.path.relpath(dirpath, src)
|
|
209
|
+
target_dir = dst if rel_dir == "." else os.path.join(dst, rel_dir)
|
|
210
|
+
for name in list(dirnames) + list(filenames):
|
|
211
|
+
full = os.path.join(dirpath, name)
|
|
212
|
+
target = os.path.join(target_dir, name)
|
|
213
|
+
kind = entry_kind(full)
|
|
214
|
+
if kind == KIND_LINK:
|
|
215
|
+
os.symlink(os.readlink(full), target)
|
|
216
|
+
elif kind == KIND_DIR:
|
|
217
|
+
os.mkdir(target)
|
|
218
|
+
dir_modes.append((full, target))
|
|
219
|
+
elif kind == KIND_FILE:
|
|
220
|
+
shutil.copy2(full, target, follow_symlinks=False)
|
|
221
|
+
if on_progress is not None:
|
|
222
|
+
on_progress(target)
|
|
223
|
+
elif kind == KIND_SPECIAL:
|
|
224
|
+
skipped.append(os.path.relpath(full, src))
|
|
225
|
+
for source_dir, target_dir in reversed(dir_modes):
|
|
226
|
+
shutil.copystat(source_dir, target_dir, follow_symlinks=False)
|
|
227
|
+
return skipped
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def diff_manifests(expected: Dict[str, ManifestEntry], actual: Dict[str, ManifestEntry]) -> List[str]:
|
|
231
|
+
"""返回两份清单不一致的相对路径(排序后),空列表表示完全一致。"""
|
|
232
|
+
mismatched = [rel for rel in expected if actual.get(rel) != expected[rel]]
|
|
233
|
+
mismatched.extend(rel for rel in actual if rel not in expected)
|
|
234
|
+
return sorted(set(mismatched))
|
multi_claude/hook.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""statusLine 钩子入口(方案 feature-hook-latency)。
|
|
2
|
+
|
|
3
|
+
状态栏每次刷新都会启动一次钩子,它的耗时直接加在显示上,所以这里只导入 os、sys、tempfile:
|
|
4
|
+
读完 stdin 后,把用量采集交给一个与 Claude 断开的后台进程,自己立刻 exec 原命令。
|
|
5
|
+
采集逻辑本身在 statusline.capture,只在后台进程里导入。
|
|
6
|
+
|
|
7
|
+
不要在本模块顶层导入 multi_claude 的其它模块:那会把 config、platform(含 ctypes)等
|
|
8
|
+
重新带回显示路径。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
HOOK_COMMAND = "statusline-hook"
|
|
16
|
+
SHELL = "/bin/sh"
|
|
17
|
+
# 设为 1 时在本进程同步采集(v0.2.0 的行为)。只供测试判定“不写快照”的场景,不写进 README。
|
|
18
|
+
SYNC_ENV = "MULTI_CLAUDE_HOOK_SYNC"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(args: list) -> int:
|
|
22
|
+
"""钩子:后台采集用量,再把进程交给 `/bin/sh -c <原命令>`。
|
|
23
|
+
|
|
24
|
+
成功时不返回(exec)。只有准备 stdin 或 exec 本身失败时返回 127,这时状态栏为空,
|
|
25
|
+
原因写到 stderr(Claude 只把它记进调试日志)。采集无论成败都不影响显示。
|
|
26
|
+
"""
|
|
27
|
+
if len(args) != 1:
|
|
28
|
+
sys.stderr.write("usage: multi-claude {} COMMAND\n".format(HOOK_COMMAND))
|
|
29
|
+
return 2
|
|
30
|
+
original = args[0]
|
|
31
|
+
data = sys.stdin.buffer.read()
|
|
32
|
+
if os.environ.get(SYNC_ENV) == "1":
|
|
33
|
+
_capture(data)
|
|
34
|
+
else:
|
|
35
|
+
_capture_in_background(data)
|
|
36
|
+
try:
|
|
37
|
+
# stdin 已被读完,原命令需要同样的输入:放进一个已 unlink 的临时文件再接到 fd 0。
|
|
38
|
+
# 用 exec 而不是子进程,原命令的输出、退出码与收到的信号才与 Claude 直接执行时一致。
|
|
39
|
+
replay = tempfile.TemporaryFile()
|
|
40
|
+
replay.write(data)
|
|
41
|
+
replay.flush()
|
|
42
|
+
replay.seek(0)
|
|
43
|
+
os.dup2(replay.fileno(), 0)
|
|
44
|
+
sys.stdout.flush()
|
|
45
|
+
sys.stderr.flush()
|
|
46
|
+
os.execv(SHELL, [SHELL, "-c", original])
|
|
47
|
+
except OSError as exc:
|
|
48
|
+
sys.stderr.write("multi-claude {}: cannot run {}: {}\n".format(HOOK_COMMAND, SHELL, exc))
|
|
49
|
+
return 127
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _capture_in_background(data: bytes) -> None:
|
|
53
|
+
"""两次 fork + setsid,让采集在一个与 Claude 断开的孙进程里运行。
|
|
54
|
+
|
|
55
|
+
中间进程立即退出并在这里被回收,不留僵尸;孙进程归 init 所有,不在 Claude 的进程组里。
|
|
56
|
+
孙进程先把 stdio 指向 /dev/null:继承来的 stdout 只要还开着,Claude 就可能一直等它关闭,
|
|
57
|
+
状态栏又会被采集拖慢。fork 失败只是少采一次,不影响显示。
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
pid = os.fork()
|
|
61
|
+
except OSError:
|
|
62
|
+
return
|
|
63
|
+
if pid > 0:
|
|
64
|
+
try:
|
|
65
|
+
os.waitpid(pid, 0)
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
return
|
|
69
|
+
# 中间进程与孙进程:无论发生什么都必须 os._exit,绝不能回到上面的 exec 路径。
|
|
70
|
+
try:
|
|
71
|
+
os.setsid()
|
|
72
|
+
if os.fork() > 0:
|
|
73
|
+
os._exit(0)
|
|
74
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
75
|
+
for fd in (0, 1, 2):
|
|
76
|
+
os.dup2(devnull, fd)
|
|
77
|
+
_capture(data)
|
|
78
|
+
except BaseException: # noqa: BLE001 — 后台进程的任何异常都只能吞掉
|
|
79
|
+
pass
|
|
80
|
+
os._exit(0)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _capture(data: bytes) -> None:
|
|
84
|
+
try:
|
|
85
|
+
from datetime import datetime, timezone
|
|
86
|
+
|
|
87
|
+
from . import statusline
|
|
88
|
+
statusline.capture(data, os.environ, datetime.now(timezone.utc))
|
|
89
|
+
except Exception: # noqa: BLE001 — 采集是旁路,任何异常都不能影响状态栏
|
|
90
|
+
pass
|
multi_claude/identity.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""凭据定位与只读的登录状态探测(方案 §5.1.9)。
|
|
2
|
+
|
|
3
|
+
Claude Code 在 macOS 上把登录凭据存进钥匙串,服务名由配置目录的路径字符串决定
|
|
4
|
+
(方案 §3 依据 2);Linux 上存进配置目录里的 `.credentials.json`(依据 3)。
|
|
5
|
+
本模块只计算“凭据应该在哪里”,并查询它是否存在:
|
|
6
|
+
|
|
7
|
+
- 绝不读取、复制或删除凭据内容;
|
|
8
|
+
- 钥匙串只调用 `security find-generic-password -a <账户> -s <服务名>`,不带 -w / -g,
|
|
9
|
+
只看退出码。整个工具只有这一处调用 `security`,测试会断言它收到的参数。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import pwd
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import unicodedata
|
|
19
|
+
|
|
20
|
+
from .config import IDENTITY_DEFAULT
|
|
21
|
+
|
|
22
|
+
SERVICE_BASE = "Claude Code-credentials"
|
|
23
|
+
CREDENTIALS_FILE = ".credentials.json"
|
|
24
|
+
|
|
25
|
+
# probe() 的返回值
|
|
26
|
+
LOGIN_KEYCHAIN = "keychain"
|
|
27
|
+
LOGIN_FILE = "file"
|
|
28
|
+
LOGIN_NONE = "none"
|
|
29
|
+
LOGIN_UNKNOWN = "unknown"
|
|
30
|
+
|
|
31
|
+
# security 的退出码 44 = errSecItemNotFound(条目不存在)
|
|
32
|
+
_SECURITY_NOT_FOUND = 44
|
|
33
|
+
_KEYCHAIN_ACCOUNT_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def service_name(identity: str, account_dir: str) -> str:
|
|
37
|
+
"""该账号在 macOS 钥匙串里的服务名。
|
|
38
|
+
|
|
39
|
+
default 身份不设 CLAUDE_CONFIG_DIR,Claude 用不带后缀的服务名;dir 身份的后缀是
|
|
40
|
+
启动命令里那串路径(先做 NFC 规范化,与 Claude 的 we() 一致)的 SHA-256 前 8 位。
|
|
41
|
+
这里假定正式环境(OAUTH_FILE_SUFFIX 为空):设置了 CLAUDE_CODE_CUSTOM_OAUTH_URL 时
|
|
42
|
+
服务名中段会多出 `-custom-oauth`,调用方负责提示探测结果可能不准。
|
|
43
|
+
"""
|
|
44
|
+
if identity == IDENTITY_DEFAULT:
|
|
45
|
+
return SERVICE_BASE
|
|
46
|
+
normalized = unicodedata.normalize("NFC", account_dir)
|
|
47
|
+
suffix = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8]
|
|
48
|
+
return "{}-{}".format(SERVICE_BASE, suffix)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def keychain_account() -> str:
|
|
52
|
+
"""与 Claude 的 ok() 相同:取 USER,缺失时取当前 uid 的用户名;不合规则时用 claude-code-user。"""
|
|
53
|
+
user = os.environ.get("USER")
|
|
54
|
+
if not user:
|
|
55
|
+
try:
|
|
56
|
+
user = pwd.getpwuid(os.getuid()).pw_name
|
|
57
|
+
except KeyError:
|
|
58
|
+
user = ""
|
|
59
|
+
if not _KEYCHAIN_ACCOUNT_PATTERN.match(user):
|
|
60
|
+
return "claude-code-user"
|
|
61
|
+
return user
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def credentials_file(account_dir: str) -> str:
|
|
65
|
+
return os.path.join(account_dir, CREDENTIALS_FILE)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def probe(identity: str, account_dir: str) -> str:
|
|
69
|
+
"""返回登录凭据所在的位置:keychain、file、none 或 unknown(查询本身失败)。
|
|
70
|
+
|
|
71
|
+
macOS 先查钥匙串,再查凭据文件(钥匙串写入失败时 Claude 会退回到文件);
|
|
72
|
+
其它平台只查凭据文件。只用于提示,不参与任何写操作的判定。
|
|
73
|
+
"""
|
|
74
|
+
keychain_state = LOGIN_NONE
|
|
75
|
+
if sys.platform == "darwin":
|
|
76
|
+
keychain_state = _probe_keychain(service_name(identity, account_dir))
|
|
77
|
+
if keychain_state == LOGIN_KEYCHAIN:
|
|
78
|
+
return LOGIN_KEYCHAIN
|
|
79
|
+
if os.path.isfile(credentials_file(account_dir)):
|
|
80
|
+
return LOGIN_FILE
|
|
81
|
+
return keychain_state
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _probe_keychain(service: str) -> str:
|
|
85
|
+
try:
|
|
86
|
+
proc = subprocess.run(["security", "find-generic-password", "-a", keychain_account(), "-s", service],
|
|
87
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
|
88
|
+
except (OSError, subprocess.SubprocessError):
|
|
89
|
+
return LOGIN_UNKNOWN
|
|
90
|
+
if proc.returncode == 0:
|
|
91
|
+
return LOGIN_KEYCHAIN
|
|
92
|
+
if proc.returncode == _SECURITY_NOT_FOUND:
|
|
93
|
+
return LOGIN_NONE
|
|
94
|
+
return LOGIN_UNKNOWN
|