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/platform.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""平台适配:默认路径、创建与识别链接、占用检查。
|
|
2
|
+
|
|
3
|
+
一期只支持 macOS 与 Linux。二期加 Windows 时,平台差异都在本模块和启动命令模板里扩展,
|
|
4
|
+
其它模块不直接判断操作系统。
|
|
5
|
+
|
|
6
|
+
占用检查分两部分(方案 §5.1.7):
|
|
7
|
+
- find_busy_processes:沿用 multi-codex,按“打开的文件 / 工作目录落在源目录下”判断;
|
|
8
|
+
- find_claude_users:Claude 进程几乎不在账号目录里常开文件,必须读进程的 argv 与环境变量,
|
|
9
|
+
再加上 IDE 锁文件、LaunchAgent 服务和 bwrap PID 命名空间三项检查。
|
|
10
|
+
本模块只收集事实,不决定退出码,也不输出日志;由 migrate 负责汇报。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ctypes
|
|
14
|
+
import ctypes.util
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shlex
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Dict, List, NamedTuple, Optional, Set, Tuple
|
|
22
|
+
|
|
23
|
+
from .fsutil import is_under
|
|
24
|
+
|
|
25
|
+
# 在全局 shell 里导出就会让所有账号共用同一份凭据或同一个后端,破坏账号隔离(方案 §4)。
|
|
26
|
+
ISOLATION_BREAKING_ENV = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
|
|
27
|
+
"ANTHROPIC_PROFILE", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX",
|
|
28
|
+
"CLAUDE_CODE_USE_FOUNDRY")
|
|
29
|
+
|
|
30
|
+
# Claude Code 只在它自己拉起的子进程(Bash 工具、hook、status line 等)里设置这个变量;
|
|
31
|
+
# 本工具环境里有它,说明正运行在某个 Claude 会话里(方案 §5.1.7 第 0 条)。
|
|
32
|
+
CHILD_SESSION_ENV = "CLAUDE_CODE_CHILD_SESSION"
|
|
33
|
+
# 安装成系统服务的后台 supervisor(方案 §3 依据 14):只带 PATH、不设 CLAUDE_CONFIG_DIR,总是服务默认账号。
|
|
34
|
+
# macOS 是 LaunchAgent;Linux 是 systemd user unit,位置随 XDG_CONFIG_HOME(2.1.286 Linux 构建实测)。
|
|
35
|
+
DAEMON_SERVICE_NAME = "com.anthropic.claude-daemon"
|
|
36
|
+
DAEMON_PLIST = os.path.join("Library", "LaunchAgents", DAEMON_SERVICE_NAME + ".plist")
|
|
37
|
+
# Claude Code 的 npm 包安装目录;当前版本在其中放原生程序 bin/claude.exe,旧版本放 JS 入口。
|
|
38
|
+
NPM_PACKAGE_MARKER = "/@anthropic-ai/claude-code/"
|
|
39
|
+
|
|
40
|
+
TEST_MODE_ENV = "MULTI_CLAUDE_TEST_MODE"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_hook_value(variable: str) -> Optional[str]:
|
|
44
|
+
"""读取测试钩子变量;只有同时设置 MULTI_CLAUDE_TEST_MODE=1 时才生效。
|
|
45
|
+
|
|
46
|
+
钩子能关掉检查或注入故障,生产环境里误设了某个钩子变量也不能让它起作用。
|
|
47
|
+
"""
|
|
48
|
+
if os.environ.get(TEST_MODE_ENV) != "1":
|
|
49
|
+
return None
|
|
50
|
+
return os.environ.get(variable)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_supported() -> bool:
|
|
54
|
+
return sys.platform == "darwin" or sys.platform.startswith("linux")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def default_source() -> str:
|
|
58
|
+
"""不设 CLAUDE_CONFIG_DIR 时 Claude 使用的配置目录,也是 migrate-default 唯一的迁移源。"""
|
|
59
|
+
return "~/.claude"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def default_root() -> str:
|
|
63
|
+
return "~/.cc"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def default_bin_dir() -> str:
|
|
67
|
+
return "~/.local/bin"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def state_dir() -> str:
|
|
71
|
+
"""工具状态目录:config.json、lock、migrate-journal.json 所在位置。"""
|
|
72
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
73
|
+
base = xdg if xdg and os.path.isabs(xdg) else os.path.expanduser("~/.config")
|
|
74
|
+
return os.path.join(base, "multi-claude")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_link(target: str, link_path: str) -> None:
|
|
78
|
+
"""在 link_path 创建指向 target 的软链;target 使用绝对路径,避免随工作目录变化。"""
|
|
79
|
+
os.symlink(target, link_path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BusyProcess(NamedTuple):
|
|
83
|
+
pid: int
|
|
84
|
+
command: str
|
|
85
|
+
# 占用方式(给人看的):cwd(工作目录)、executable(可执行文件)、mapped(内存映射)或 fd N(打开的文件)
|
|
86
|
+
usage: str
|
|
87
|
+
# 落在源目录下的那个路径,让用户一眼看出是哪个文件被占用
|
|
88
|
+
path: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def describe_usage(fd: str) -> str:
|
|
92
|
+
"""把 lsof 的 fd 字段或 /proc 的条目名翻译成好懂的占用方式;未知值原样返回。"""
|
|
93
|
+
if fd == "txt":
|
|
94
|
+
return "executable"
|
|
95
|
+
if fd == "mem":
|
|
96
|
+
return "mapped"
|
|
97
|
+
digits = fd.rstrip("rwuRWU")
|
|
98
|
+
if digits.isdigit():
|
|
99
|
+
return "fd " + digits
|
|
100
|
+
return fd
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class BusyCheckError(Exception):
|
|
104
|
+
"""占用检查本身无法完成(找不到检查工具,或 lsof 没有正常输出)。"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def find_busy_processes(path: str, exclude_pids: Optional[List[int]] = None) -> List[BusyProcess]:
|
|
108
|
+
"""列出工作目录、可执行文件或打开的文件落在 path 之下的进程。
|
|
109
|
+
|
|
110
|
+
只检查顶层数据库文件会漏掉浏览器扩展宿主这类只把工作目录设在其中的进程,
|
|
111
|
+
所以这里对全部进程逐个判断。结果按“进程名含 claude 的在前”排序。
|
|
112
|
+
"""
|
|
113
|
+
root = os.path.realpath(path)
|
|
114
|
+
excluded = set(exclude_pids or [])
|
|
115
|
+
excluded.add(os.getpid())
|
|
116
|
+
if shutil.which("lsof"):
|
|
117
|
+
found = _scan_with_lsof(root, excluded)
|
|
118
|
+
elif sys.platform.startswith("linux") and os.path.isdir("/proc"):
|
|
119
|
+
found = _scan_proc(root, excluded)
|
|
120
|
+
else:
|
|
121
|
+
raise BusyCheckError("neither lsof nor /proc is available")
|
|
122
|
+
found.sort(key=lambda item: ("claude" not in item.command.lower(), item.pid))
|
|
123
|
+
return found
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _scan_with_lsof(root: str, excluded: set) -> List[BusyProcess]:
|
|
127
|
+
# -F pcfn:按字段输出 pid、命令、fd、文件名;-n -P 关闭 DNS 与端口名解析,避免卡顿。
|
|
128
|
+
proc = subprocess.Popen(
|
|
129
|
+
["lsof", "-n", "-P", "-F", "pcfn"],
|
|
130
|
+
stdout=subprocess.PIPE,
|
|
131
|
+
stderr=subprocess.PIPE,
|
|
132
|
+
universal_newlines=True,
|
|
133
|
+
errors="replace",
|
|
134
|
+
)
|
|
135
|
+
stdout, _stderr = proc.communicate()
|
|
136
|
+
excluded = excluded | {proc.pid}
|
|
137
|
+
has_process_record = False
|
|
138
|
+
found = {}
|
|
139
|
+
pid = None
|
|
140
|
+
command = ""
|
|
141
|
+
fd = ""
|
|
142
|
+
for line in stdout.splitlines():
|
|
143
|
+
if not line:
|
|
144
|
+
continue
|
|
145
|
+
field, value = line[0], line[1:]
|
|
146
|
+
if field == "p":
|
|
147
|
+
has_process_record = True
|
|
148
|
+
pid = int(value) if value.isdigit() else None
|
|
149
|
+
command = ""
|
|
150
|
+
fd = ""
|
|
151
|
+
elif field == "c":
|
|
152
|
+
command = value
|
|
153
|
+
elif field == "f":
|
|
154
|
+
fd = value
|
|
155
|
+
elif field == "n" and pid is not None and pid not in excluded:
|
|
156
|
+
if value.startswith("/") and is_under(value, root) and pid not in found:
|
|
157
|
+
found[pid] = BusyProcess(pid, command, describe_usage(fd), value)
|
|
158
|
+
# Linux 普通用户运行 lsof 时,常因无权读取其它用户的进程而返回非 0 并输出告警;
|
|
159
|
+
# 只有一个进程记录都没有时才视为检查失败,否则会把正常情况误判为失败。
|
|
160
|
+
if proc.returncode != 0 and not has_process_record:
|
|
161
|
+
raise BusyCheckError("lsof exited with code {} and produced no output".format(proc.returncode))
|
|
162
|
+
return list(found.values())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _scan_proc(root: str, excluded: set) -> List[BusyProcess]:
|
|
166
|
+
found = []
|
|
167
|
+
for entry in os.listdir("/proc"):
|
|
168
|
+
if not entry.isdigit():
|
|
169
|
+
continue
|
|
170
|
+
pid = int(entry)
|
|
171
|
+
if pid in excluded:
|
|
172
|
+
continue
|
|
173
|
+
base = os.path.join("/proc", entry)
|
|
174
|
+
hit = _proc_usage(base, root)
|
|
175
|
+
if hit:
|
|
176
|
+
found.append(BusyProcess(pid, _proc_command(base), describe_usage(hit[0]), hit[1]))
|
|
177
|
+
return found
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _proc_usage(base: str, root: str) -> Optional[Tuple[str, str]]:
|
|
181
|
+
"""返回 (占用方式, 路径);与 lsof 的 fd 字段取值保持一致,交给 describe_usage 统一翻译。"""
|
|
182
|
+
for name, usage in (("cwd", "cwd"), ("exe", "txt")):
|
|
183
|
+
target = _readlink_quiet(os.path.join(base, name))
|
|
184
|
+
if target and is_under(target, root):
|
|
185
|
+
return usage, target
|
|
186
|
+
fd_dir = os.path.join(base, "fd")
|
|
187
|
+
try:
|
|
188
|
+
fds = os.listdir(fd_dir)
|
|
189
|
+
except OSError:
|
|
190
|
+
return None
|
|
191
|
+
for fd in fds:
|
|
192
|
+
target = _readlink_quiet(os.path.join(fd_dir, fd))
|
|
193
|
+
if target and target.startswith("/") and is_under(target, root):
|
|
194
|
+
return fd, target
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _proc_command(base: str) -> str:
|
|
199
|
+
try:
|
|
200
|
+
with open(os.path.join(base, "comm"), "r", encoding="utf-8", errors="replace") as handle:
|
|
201
|
+
return handle.read().strip()
|
|
202
|
+
except OSError:
|
|
203
|
+
return "?"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _readlink_quiet(path: str) -> str:
|
|
207
|
+
try:
|
|
208
|
+
return os.readlink(path)
|
|
209
|
+
except OSError:
|
|
210
|
+
return ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---- Claude 进程与相关占用(方案 §5.1.7 第 2–5 条) ----
|
|
214
|
+
|
|
215
|
+
class ProcessInfo(NamedTuple):
|
|
216
|
+
pid: int
|
|
217
|
+
argv: List[str]
|
|
218
|
+
# 读不出环境时为 None:进程已退出、权限不足,或 macOS 上 Apple 自带程序(/bin/zsh 等)——
|
|
219
|
+
# 系统对它们只返回 argv、隐去环境(macOS 27 实测,`ps -E` 同样读不到)。
|
|
220
|
+
env: Optional[Dict[str, str]]
|
|
221
|
+
# 可执行文件路径:macOS 取 KERN_PROCARGS2 开头的 exec_path,Linux 取 /proc/<pid>/exe;取不到为空串
|
|
222
|
+
exe: str
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class ClaudeUsers(NamedTuple):
|
|
226
|
+
busy: List[BusyProcess]
|
|
227
|
+
# 每个命中的 Claude 进程对应的 `claude daemon stop --any` 命令(去重、保持出现顺序)
|
|
228
|
+
stop_commands: List[str]
|
|
229
|
+
# 同 uid 进程中读不出环境的个数,汇总提示用
|
|
230
|
+
unreadable: int
|
|
231
|
+
# 只提示、不判占用的问题,例如 IDE 锁文件不是合法 JSON
|
|
232
|
+
warnings: List[str]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def is_claude_process(argv: List[str], exe: str) -> bool:
|
|
236
|
+
"""按 argv 与可执行文件路径判断是否 Claude Code 进程(方案 §5.1.7 第 2 条,依据 11)。
|
|
237
|
+
|
|
238
|
+
终端启动的 claude 的 argv[0] 是 `claude`,但 Claude 自己拉起的子进程不一定:原生程序拉起子进程时
|
|
239
|
+
argv[0] 就是 process.execPath——官方安装是 `.../claude/versions/<版本>`,npm 安装是
|
|
240
|
+
`.../@anthropic-ai/claude-code/bin/claude.exe`;后台会话宿主的 argv[0] 是 `claude bg-pty-host`;
|
|
241
|
+
旧版 npm 包是 `node .../bin/claude`。不能只看 argv[1],否则 `ssh claude` 之类会被误判进来。
|
|
242
|
+
"""
|
|
243
|
+
argv0 = argv[0] if argv else ""
|
|
244
|
+
base = os.path.basename(argv0)
|
|
245
|
+
if base == "claude" or base.startswith("claude "):
|
|
246
|
+
return True
|
|
247
|
+
if "/claude/versions/" in argv0 or "/claude/versions/" in exe:
|
|
248
|
+
return True
|
|
249
|
+
if NPM_PACKAGE_MARKER in argv0 or NPM_PACKAGE_MARKER in exe:
|
|
250
|
+
return True
|
|
251
|
+
if "--bg-pty-host" in argv:
|
|
252
|
+
return True
|
|
253
|
+
if base in ("node", "bun") and len(argv) > 1:
|
|
254
|
+
script = argv[1]
|
|
255
|
+
if os.path.basename(script) == "claude" or "@anthropic-ai/claude-code" in script:
|
|
256
|
+
return True
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def config_dir_of(env: Dict[str, str]) -> str:
|
|
261
|
+
"""该进程的 Claude 配置目录:与 Claude 的 we() 相同,CLAUDE_CONFIG_DIR 为空时用 <HOME>/.claude。"""
|
|
262
|
+
configured = env.get("CLAUDE_CONFIG_DIR")
|
|
263
|
+
if configured:
|
|
264
|
+
return configured
|
|
265
|
+
return os.path.join(env.get("HOME", "~"), ".claude")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def daemon_stop_command(env: Dict[str, str]) -> str:
|
|
269
|
+
"""停掉该进程所属配置目录的后台 supervisor 的命令。
|
|
270
|
+
|
|
271
|
+
supervisor 按配置目录各起一个实例(依据 14);不指定变量就会停掉当前 shell 所指账号的实例,
|
|
272
|
+
所以未设变量的进程要显式 `env -u CLAUDE_CONFIG_DIR`。按需启动的实例需要 `--any`。
|
|
273
|
+
"""
|
|
274
|
+
configured = env.get("CLAUDE_CONFIG_DIR")
|
|
275
|
+
if configured:
|
|
276
|
+
return "CLAUDE_CONFIG_DIR={} claude daemon stop --any".format(shlex.quote(configured))
|
|
277
|
+
return "env -u CLAUDE_CONFIG_DIR claude daemon stop --any"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def find_claude_users(source: str, exclude_pids: Optional[List[int]] = None) -> ClaudeUsers:
|
|
281
|
+
"""检查第 2–5 条:Claude 进程、继承了 CLAUDE_CONFIG_DIR 的进程、IDE 锁、后台服务。
|
|
282
|
+
|
|
283
|
+
source 是迁移源 S(`~/.claude` 展开后)。任何账号的 Claude 进程都会写 $HOME/.claude 下的
|
|
284
|
+
bridge-spawn、state/、ide/(依据 12),所以第 2 条按 HOME 比较,而不是按配置目录比较。
|
|
285
|
+
检查本身无法完成时抛出 BusyCheckError。
|
|
286
|
+
"""
|
|
287
|
+
_check_pid_namespace()
|
|
288
|
+
source_real = os.path.realpath(source)
|
|
289
|
+
home_real = os.path.realpath(os.path.expanduser("~"))
|
|
290
|
+
excluded = set(exclude_pids or [])
|
|
291
|
+
excluded.add(os.getpid())
|
|
292
|
+
|
|
293
|
+
processes, helper_pids = _list_processes(excluded)
|
|
294
|
+
excluded |= helper_pids
|
|
295
|
+
busy: List[BusyProcess] = []
|
|
296
|
+
stop_commands: List[str] = []
|
|
297
|
+
readable = 0
|
|
298
|
+
unreadable = 0
|
|
299
|
+
for process in processes:
|
|
300
|
+
if process.pid in excluded:
|
|
301
|
+
continue
|
|
302
|
+
env = process.env
|
|
303
|
+
command = process.argv[0] if process.argv else "?"
|
|
304
|
+
if env is None:
|
|
305
|
+
unreadable += 1
|
|
306
|
+
# 环境读不出、但 argv 能认出是 Claude:无法排除它写的是本机的 ~/.claude,按占用处理。
|
|
307
|
+
if is_claude_process(process.argv, process.exe):
|
|
308
|
+
busy.append(BusyProcess(process.pid, command, "claude-process", "(environment not readable)"))
|
|
309
|
+
continue
|
|
310
|
+
readable += 1
|
|
311
|
+
if is_claude_process(process.argv, process.exe):
|
|
312
|
+
home = env.get("HOME")
|
|
313
|
+
# 读不到 HOME 的 Claude 进程同样无法排除,按占用处理。
|
|
314
|
+
if home is None or os.path.realpath(home) == home_real:
|
|
315
|
+
busy.append(BusyProcess(process.pid, command, "claude-process", config_dir_of(env)))
|
|
316
|
+
stop = daemon_stop_command(env)
|
|
317
|
+
if stop not in stop_commands:
|
|
318
|
+
stop_commands.append(stop)
|
|
319
|
+
continue
|
|
320
|
+
configured = env.get("CLAUDE_CONFIG_DIR")
|
|
321
|
+
# 相对路径要按那个进程的工作目录解析,这里无从得知,只比较绝对路径。
|
|
322
|
+
if configured and os.path.isabs(configured) and os.path.realpath(configured) == source_real:
|
|
323
|
+
busy.append(BusyProcess(process.pid, command, "config-dir", configured))
|
|
324
|
+
if processes and readable == 0:
|
|
325
|
+
raise BusyCheckError("could not read the environment of any process")
|
|
326
|
+
|
|
327
|
+
warnings: List[str] = []
|
|
328
|
+
busy.extend(_ide_lock_users(source, warnings))
|
|
329
|
+
busy.extend(_daemon_service())
|
|
330
|
+
return ClaudeUsers(busy, stop_commands, unreadable, warnings)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _check_pid_namespace() -> None:
|
|
334
|
+
"""Linux:PID 1 是 bwrap 说明本工具在 bwrap 建的 PID 命名空间里,看不到宿主上的进程。
|
|
335
|
+
|
|
336
|
+
开启 CLAUDE_CODE_SUBPROCESS_ENV_SCRUB 的 Claude 会话用 `bwrap --unshare-pid --proc /proc`
|
|
337
|
+
隔离子进程;这种情况通常已被第 0 条拦下,这里是第二道防线。NSpid 与 /proc/1 属主在 bwrap 中
|
|
338
|
+
都看不出嵌套(方案 §5.1.7 实测),所以只看 /proc/1/comm。
|
|
339
|
+
"""
|
|
340
|
+
if not sys.platform.startswith("linux"):
|
|
341
|
+
return
|
|
342
|
+
comm = test_hook_value("MULTI_CLAUDE_TEST_PROC1_COMM")
|
|
343
|
+
if comm is None:
|
|
344
|
+
try:
|
|
345
|
+
with open("/proc/1/comm", "r", encoding="utf-8", errors="replace") as handle:
|
|
346
|
+
comm = handle.read()
|
|
347
|
+
except OSError:
|
|
348
|
+
return
|
|
349
|
+
if comm.strip() == "bwrap":
|
|
350
|
+
raise BusyCheckError("running inside a bwrap PID namespace (PID 1 is bwrap), "
|
|
351
|
+
"so processes on the host are not visible")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _list_processes(excluded: Set[int]) -> Tuple[List[ProcessInfo], Set[int]]:
|
|
355
|
+
"""枚举与本工具同 uid 的进程,返回 (进程列表, 本工具为枚举而启动的子进程 pid)。"""
|
|
356
|
+
if sys.platform == "darwin":
|
|
357
|
+
return _list_processes_darwin(excluded)
|
|
358
|
+
if os.path.isdir("/proc"):
|
|
359
|
+
return _list_processes_proc(excluded), set()
|
|
360
|
+
raise BusyCheckError("cannot list processes on this platform")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _list_processes_darwin(excluded: Set[int]) -> Tuple[List[ProcessInfo], Set[int]]:
|
|
364
|
+
try:
|
|
365
|
+
proc = subprocess.Popen(["ps", "-axo", "pid=,uid="], stdout=subprocess.PIPE,
|
|
366
|
+
stderr=subprocess.DEVNULL, universal_newlines=True)
|
|
367
|
+
except OSError as exc:
|
|
368
|
+
raise BusyCheckError("cannot run ps: {}".format(exc))
|
|
369
|
+
stdout, _ = proc.communicate()
|
|
370
|
+
if proc.returncode != 0:
|
|
371
|
+
raise BusyCheckError("ps exited with code {}".format(proc.returncode))
|
|
372
|
+
uid = os.getuid()
|
|
373
|
+
processes = []
|
|
374
|
+
for line in stdout.splitlines():
|
|
375
|
+
fields = line.split()
|
|
376
|
+
if len(fields) != 2 or not fields[0].isdigit() or not fields[1].isdigit():
|
|
377
|
+
continue
|
|
378
|
+
pid = int(fields[0])
|
|
379
|
+
if int(fields[1]) != uid or pid in excluded or pid == proc.pid:
|
|
380
|
+
continue
|
|
381
|
+
raw = _procargs2(pid)
|
|
382
|
+
if raw is None:
|
|
383
|
+
processes.append(ProcessInfo(pid, [], None, ""))
|
|
384
|
+
else:
|
|
385
|
+
processes.append(_parse_procargs2(pid, raw))
|
|
386
|
+
return processes, {proc.pid}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
_CTL_KERN = 1
|
|
390
|
+
_KERN_ARGMAX = 8
|
|
391
|
+
_KERN_PROCARGS2 = 49
|
|
392
|
+
_libc = None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _sysctl(mib: List[int], size: int) -> Optional[bytes]:
|
|
396
|
+
global _libc
|
|
397
|
+
if _libc is None:
|
|
398
|
+
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
|
|
399
|
+
mib_array = (ctypes.c_int * len(mib))(*mib)
|
|
400
|
+
buffer = ctypes.create_string_buffer(size)
|
|
401
|
+
length = ctypes.c_size_t(size)
|
|
402
|
+
if _libc.sysctl(mib_array, ctypes.c_uint(len(mib)), buffer, ctypes.byref(length), None,
|
|
403
|
+
ctypes.c_size_t(0)) != 0:
|
|
404
|
+
return None
|
|
405
|
+
return buffer.raw[:length.value]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _procargs2(pid: int) -> Optional[bytes]:
|
|
409
|
+
"""macOS:用 sysctl(KERN_PROCARGS2) 读进程的 exec_path、argv 与环境,原始数据按 NUL 分隔。
|
|
410
|
+
|
|
411
|
+
不用 `ps -E`:它把环境变量用空格拼在命令行后面,路径含空格时无法可靠切分(方案 §6)。
|
|
412
|
+
"""
|
|
413
|
+
argmax_raw = _sysctl([_CTL_KERN, _KERN_ARGMAX], ctypes.sizeof(ctypes.c_int))
|
|
414
|
+
if argmax_raw is None or len(argmax_raw) < ctypes.sizeof(ctypes.c_int):
|
|
415
|
+
return None
|
|
416
|
+
argmax = int.from_bytes(argmax_raw[:ctypes.sizeof(ctypes.c_int)], sys.byteorder)
|
|
417
|
+
return _sysctl([_CTL_KERN, _KERN_PROCARGS2, pid], argmax)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _parse_procargs2(pid: int, raw: bytes) -> ProcessInfo:
|
|
421
|
+
"""KERN_PROCARGS2 的格式:int argc,exec_path\\0,若干填充 \\0,argv 各项\\0,环境各项\\0,空串结束。"""
|
|
422
|
+
int_size = ctypes.sizeof(ctypes.c_int)
|
|
423
|
+
if len(raw) < int_size:
|
|
424
|
+
return ProcessInfo(pid, [], None, "")
|
|
425
|
+
argc = int.from_bytes(raw[:int_size], sys.byteorder)
|
|
426
|
+
rest = raw[int_size:]
|
|
427
|
+
end = rest.find(b"\0")
|
|
428
|
+
if end < 0:
|
|
429
|
+
return ProcessInfo(pid, [], None, "")
|
|
430
|
+
exe = os.fsdecode(rest[:end])
|
|
431
|
+
position = end
|
|
432
|
+
while position < len(rest) and rest[position:position + 1] == b"\0":
|
|
433
|
+
position += 1
|
|
434
|
+
tokens = rest[position:].split(b"\0")
|
|
435
|
+
argv = [os.fsdecode(token) for token in tokens[:argc]]
|
|
436
|
+
env: Dict[str, str] = {}
|
|
437
|
+
for token in tokens[argc:]:
|
|
438
|
+
if not token:
|
|
439
|
+
break
|
|
440
|
+
key, sep, value = os.fsdecode(token).partition("=")
|
|
441
|
+
if sep:
|
|
442
|
+
env[key] = value
|
|
443
|
+
# 一个环境变量都没有:多半是系统隐去了环境(见 ProcessInfo.env),按读不出处理,不能当成“检查过、没问题”。
|
|
444
|
+
return ProcessInfo(pid, argv, env or None, exe)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _list_processes_proc(excluded: Set[int]) -> List[ProcessInfo]:
|
|
448
|
+
uid = os.getuid()
|
|
449
|
+
processes = []
|
|
450
|
+
for entry in os.listdir("/proc"):
|
|
451
|
+
if not entry.isdigit():
|
|
452
|
+
continue
|
|
453
|
+
pid = int(entry)
|
|
454
|
+
base = os.path.join("/proc", entry)
|
|
455
|
+
try:
|
|
456
|
+
if pid in excluded or os.stat(base).st_uid != uid:
|
|
457
|
+
continue
|
|
458
|
+
except OSError:
|
|
459
|
+
continue
|
|
460
|
+
argv = [os.fsdecode(item) for item in _read_nul_list(os.path.join(base, "cmdline")) or []]
|
|
461
|
+
environ = _read_nul_list(os.path.join(base, "environ"))
|
|
462
|
+
env = None
|
|
463
|
+
if environ is not None:
|
|
464
|
+
env = {}
|
|
465
|
+
for token in environ:
|
|
466
|
+
key, sep, value = os.fsdecode(token).partition("=")
|
|
467
|
+
if sep:
|
|
468
|
+
env[key] = value
|
|
469
|
+
processes.append(ProcessInfo(pid, argv, env, _readlink_quiet(os.path.join(base, "exe"))))
|
|
470
|
+
return processes
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _read_nul_list(path: str) -> Optional[List[bytes]]:
|
|
474
|
+
try:
|
|
475
|
+
with open(path, "rb") as handle:
|
|
476
|
+
data = handle.read()
|
|
477
|
+
except OSError:
|
|
478
|
+
return None
|
|
479
|
+
return [token for token in data.split(b"\0") if token]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _ide_lock_users(source: str, warnings: List[str]) -> List[BusyProcess]:
|
|
483
|
+
"""第 4 条:IDE 扩展在 ~/.claude/ide/<端口>.lock 里登记自己的 pid,pid 存活即判占用。
|
|
484
|
+
|
|
485
|
+
锁文件里还有 authToken,这里只取 pid 与 ideName 两个字段,绝不输出其它内容。
|
|
486
|
+
"""
|
|
487
|
+
ide_dir = os.path.join(source, "ide")
|
|
488
|
+
try:
|
|
489
|
+
names = sorted(os.listdir(ide_dir))
|
|
490
|
+
except OSError:
|
|
491
|
+
return []
|
|
492
|
+
found = []
|
|
493
|
+
for name in names:
|
|
494
|
+
if not name.endswith(".lock"):
|
|
495
|
+
continue
|
|
496
|
+
path = os.path.join(ide_dir, name)
|
|
497
|
+
try:
|
|
498
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
499
|
+
data = json.load(handle)
|
|
500
|
+
except (OSError, ValueError):
|
|
501
|
+
warnings.append("cannot parse IDE lock file {}; skipped".format(path))
|
|
502
|
+
continue
|
|
503
|
+
pid = data.get("pid") if isinstance(data, dict) else None
|
|
504
|
+
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
|
|
505
|
+
warnings.append("IDE lock file {} has no valid pid; skipped".format(path))
|
|
506
|
+
continue
|
|
507
|
+
if _pid_alive(pid):
|
|
508
|
+
ide_name = data.get("ideName")
|
|
509
|
+
found.append(BusyProcess(pid, ide_name if isinstance(ide_name, str) else "IDE", "ide-lock", path))
|
|
510
|
+
return found
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _pid_alive(pid: int) -> bool:
|
|
514
|
+
try:
|
|
515
|
+
os.kill(pid, 0)
|
|
516
|
+
except ProcessLookupError:
|
|
517
|
+
return False
|
|
518
|
+
except PermissionError:
|
|
519
|
+
return True
|
|
520
|
+
except OSError:
|
|
521
|
+
return False
|
|
522
|
+
return True
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def daemon_service_path() -> Optional[str]:
|
|
526
|
+
"""`claude daemon install` 写下的服务文件路径;不支持的平台返回 None。
|
|
527
|
+
|
|
528
|
+
Linux 上 Claude 按它自己进程的 XDG_CONFIG_HOME 取位置,这里用本工具的环境推算,二者通常一致。
|
|
529
|
+
"""
|
|
530
|
+
if sys.platform == "darwin":
|
|
531
|
+
return os.path.join(os.path.expanduser("~"), DAEMON_PLIST)
|
|
532
|
+
if sys.platform.startswith("linux"):
|
|
533
|
+
config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
|
|
534
|
+
return os.path.join(config_home, "systemd", "user", DAEMON_SERVICE_NAME + ".service")
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _daemon_service() -> List[BusyProcess]:
|
|
539
|
+
"""第 5 条:装了系统服务形态的 supervisor 时,它随时可能被 launchd / systemd 拉起并写 S。
|
|
540
|
+
|
|
541
|
+
该服务设了自动重启(Linux 为 Restart=always),所以不论进程此刻是否在运行都判占用;
|
|
542
|
+
pid 记为 0 表示这不是某个具体进程。
|
|
543
|
+
"""
|
|
544
|
+
path = daemon_service_path()
|
|
545
|
+
if path is not None and os.path.lexists(path):
|
|
546
|
+
manager = "launchd" if sys.platform == "darwin" else "systemd"
|
|
547
|
+
return [BusyProcess(0, manager, "daemon-service", path)]
|
|
548
|
+
return []
|
multi_claude/routes.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""按目录自动选账号(方案 feature-directory-routing §5.1.2)。
|
|
2
|
+
|
|
3
|
+
`claude-auto` 是由配置生成的 POSIX sh 入口:启动时取当前目录的物理路径,在规则目录里找最长的
|
|
4
|
+
“祖先或本身”,转交给对应账号的 `claude-<名称>`;没有命中时用默认账号,未设默认账号就直接运行 claude。
|
|
5
|
+
`resolve` 是同一算法的 Python 实现,供 `which` 使用;两者必须保持一致(测试会对拍)。
|
|
6
|
+
规则目录写进脚本时只展开 `~`,物理路径在启动时用 `cd -P` 求得,所以规则目录事后变成软链也能正确匹配。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shlex
|
|
11
|
+
from typing import Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from . import launcher
|
|
14
|
+
from .config import Account, Config, RouteRule
|
|
15
|
+
from .fsutil import KIND_FILE, KIND_MISSING, entry_kind, expand, read_text
|
|
16
|
+
|
|
17
|
+
ROUTER_NAME = "claude-auto"
|
|
18
|
+
# 与账号启动命令的标记(`# managed-by: multi-claude account=`)不同前缀:launcher.managed_account
|
|
19
|
+
# 不认它,apply 的孤儿清理就不会把它当成账号启动命令删掉;反之路由计划也只认这一行。
|
|
20
|
+
ROUTER_MARKER = "# managed-by: multi-claude router"
|
|
21
|
+
# 与路由入口同名的账号:两者的文件都是 <bin_dir>/claude-auto,不能共存。
|
|
22
|
+
RESERVED_ACCOUNT = "auto"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def router_path(bin_dir: str) -> str:
|
|
26
|
+
return os.path.join(expand(bin_dir), ROUTER_NAME)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_router(path: str) -> bool:
|
|
30
|
+
"""path 是本工具生成的路由入口(普通文件,第 2 行为路由标记)。"""
|
|
31
|
+
if entry_kind(path) != KIND_FILE:
|
|
32
|
+
return False
|
|
33
|
+
content = read_text(path)
|
|
34
|
+
if content is None:
|
|
35
|
+
return False
|
|
36
|
+
lines = content.split("\n", 2)
|
|
37
|
+
return len(lines) >= 2 and lines[1] == ROUTER_MARKER
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_path(raw: str, cwd: str) -> str:
|
|
41
|
+
"""命令行输入的规则目录:`~` 开头的保持写法,其余转成绝对路径;都去掉尾部 `/`。"""
|
|
42
|
+
if raw == "~" or raw.startswith("~/"):
|
|
43
|
+
return raw.rstrip("/") or "~"
|
|
44
|
+
return os.path.normpath(os.path.join(cwd, raw))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve(config: Config, directory: str) -> Tuple[Optional[Account], Optional[RouteRule]]:
|
|
48
|
+
"""directory 会用哪个账号:返回 (账号或 None, 命中的规则或 None)。
|
|
49
|
+
|
|
50
|
+
账号为 None 表示直接运行 claude;规则为 None 且账号非 None 表示用的是默认账号。
|
|
51
|
+
与 claude-auto 一致:按物理路径比较,规则目录不存在时跳过,取最长,长度相同时靠前的规则优先。
|
|
52
|
+
"""
|
|
53
|
+
here = os.path.realpath(directory)
|
|
54
|
+
best_rule = None
|
|
55
|
+
best_length = 0
|
|
56
|
+
for rule in config.route_rules:
|
|
57
|
+
target = expand(rule.path)
|
|
58
|
+
# 对应脚本里的 `cd -P`:目录不存在或不可进入(无执行权限)时跳过。
|
|
59
|
+
if not (os.path.isdir(target) and os.access(target, os.X_OK)):
|
|
60
|
+
continue
|
|
61
|
+
physical = os.path.realpath(target)
|
|
62
|
+
# 逐字对应脚本的 case "$here/" in "$r"/*:规则目录解析成 / 时只命中 / 本身,与 sh 一致。
|
|
63
|
+
if (here + "/").startswith(physical + "/") and len(physical) > best_length:
|
|
64
|
+
best_rule, best_length = rule, len(physical)
|
|
65
|
+
if best_rule is not None:
|
|
66
|
+
return config.find(best_rule.account), best_rule
|
|
67
|
+
if config.route_default is not None:
|
|
68
|
+
return config.find(config.route_default), None
|
|
69
|
+
return None, None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _target_launcher(config: Config, name: str) -> str:
|
|
73
|
+
# 用实际登记的大小写;找不到(用户手工改了 config.json)时原样使用,渲染不抛异常,由 apply 判冲突。
|
|
74
|
+
account = config.find(name)
|
|
75
|
+
return launcher.launcher_path(expand(config.bin_dir), account.name if account else name)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def render_router(config: Config) -> str:
|
|
79
|
+
lines = [
|
|
80
|
+
"#!/bin/sh",
|
|
81
|
+
ROUTER_MARKER,
|
|
82
|
+
"# Generated by multi-claude. Do not edit; use `multi-claude route` to change it.",
|
|
83
|
+
"target=''",
|
|
84
|
+
]
|
|
85
|
+
if config.route_rules:
|
|
86
|
+
lines.extend([
|
|
87
|
+
# 物理路径:经软链进入的目录与规则目录按同一标准比较。
|
|
88
|
+
"here=$(pwd -P) || exit 1",
|
|
89
|
+
"best=0",
|
|
90
|
+
])
|
|
91
|
+
for rule in config.route_rules:
|
|
92
|
+
lines.extend([
|
|
93
|
+
# 规则目录不存在时 r 为空,该规则不参与匹配。
|
|
94
|
+
"r=$(cd -P -- {} 2>/dev/null && pwd -P) || r=''".format(shlex.quote(expand(rule.path))),
|
|
95
|
+
'if [ -n "$r" ]; then',
|
|
96
|
+
' case "$here/" in',
|
|
97
|
+
# "$r"/* 带引号:路径里的 * ? [ 按字面匹配;末尾的 / 保证 /work 不会命中 /workshop。
|
|
98
|
+
' "$r"/*) if [ "${{#r}}" -gt "$best" ]; then best=${{#r}}; target={}; fi ;;'.format(
|
|
99
|
+
shlex.quote(_target_launcher(config, rule.account))),
|
|
100
|
+
" esac",
|
|
101
|
+
"fi",
|
|
102
|
+
])
|
|
103
|
+
if config.route_default is not None:
|
|
104
|
+
lines.extend([
|
|
105
|
+
'if [ -z "$target" ]; then',
|
|
106
|
+
" target={}".format(shlex.quote(_target_launcher(config, config.route_default))),
|
|
107
|
+
"fi",
|
|
108
|
+
])
|
|
109
|
+
lines.extend([
|
|
110
|
+
'if [ -n "$target" ]; then',
|
|
111
|
+
# 绝对路径调用账号启动命令,不经 PATH 查找,避免被同名文件遮住。
|
|
112
|
+
' exec "$target" "$@"',
|
|
113
|
+
"fi",
|
|
114
|
+
"if ! command -v claude >/dev/null 2>&1; then",
|
|
115
|
+
' echo "multi-claude: claude not found in PATH" >&2',
|
|
116
|
+
" exit 127",
|
|
117
|
+
"fi",
|
|
118
|
+
'exec claude "$@"',
|
|
119
|
+
"",
|
|
120
|
+
])
|
|
121
|
+
return "\n".join(lines)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def router_status(config: Config) -> Optional[str]:
|
|
125
|
+
"""list / doctor 用:路由未启用时 None;否则 ok、missing、stale 或 conflict。"""
|
|
126
|
+
if not config.routes_enabled:
|
|
127
|
+
return None
|
|
128
|
+
path = router_path(config.bin_dir)
|
|
129
|
+
if entry_kind(path) == KIND_MISSING:
|
|
130
|
+
return "missing"
|
|
131
|
+
if not is_router(path):
|
|
132
|
+
return "conflict"
|
|
133
|
+
return "ok" if read_text(path) == render_router(config) and os.access(path, os.X_OK) else "stale"
|