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/launcher.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""启动命令 `<bin_dir>/claude-<名称>` 的生成与识别(方案 §5.1.4、§5.1.5)。
|
|
2
|
+
|
|
3
|
+
启动命令是由 config.json 推导出的产物,内容完全由账号名、身份、账号目录、代理、
|
|
4
|
+
额外环境变量和固定参数决定,所以“内容相同即 unchanged”就是幂等判断。第 2 行的受管标记
|
|
5
|
+
用来区分本工具生成的文件和用户自己的同名文件:没有标记的文件一律不覆盖、不删除。
|
|
6
|
+
|
|
7
|
+
`account_dir=` 那一行是 dir 身份账号的登录绑定依据(macOS 钥匙串服务名由它的 SHA-256 决定),
|
|
8
|
+
收敛引擎据此判断“会不会改变登录绑定”,所以该行的格式(`account_dir=` + shlex 转义值)
|
|
9
|
+
不能随意改动。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shlex
|
|
14
|
+
from typing import Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from .config import IDENTITY_DEFAULT, PROXY_INHERIT, PROXY_OFF, PROXY_VARS
|
|
17
|
+
from .fsutil import KIND_FILE, entry_kind, read_text
|
|
18
|
+
|
|
19
|
+
MARKER_PREFIX = "# managed-by: multi-claude account="
|
|
20
|
+
LAUNCHER_PREFIX = "claude-"
|
|
21
|
+
LAUNCHER_MODE = 0o755
|
|
22
|
+
ACCOUNT_DIR_PREFIX = "account_dir="
|
|
23
|
+
|
|
24
|
+
_NO_PROXY_HOSTS = "localhost,127.0.0.1,::1"
|
|
25
|
+
|
|
26
|
+
# 在 shell 里导出后,会让通过任何启动命令启动的账号都改用这一份凭据(官方 env-vars 页的凭据变量),
|
|
27
|
+
# 账号各自的登录就被绕过了,所以启动命令一律清除。`env` 命令本来就拒绝这些键,不会再被设回来。
|
|
28
|
+
AUTH_OVERRIDE_ENV = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
|
|
29
|
+
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN")
|
|
30
|
+
|
|
31
|
+
# claude --help(2.1.286)中取可变个值的选项:会吞掉后面所有不以 `-` 开头的参数。
|
|
32
|
+
VARIADIC_OPTIONS = frozenset(["--add-dir", "--allowedTools", "--allowed-tools", "--betas", "--disallowedTools",
|
|
33
|
+
"--disallowed-tools", "--file", "--mcp-config", "--tools"])
|
|
34
|
+
# 同一版本中写作 `[value]` 的可选值选项:放在末尾且没有值时,会把 "$@" 的第一个参数当成自己的值。
|
|
35
|
+
OPTIONAL_VALUE_OPTIONS = frozenset(["-d", "--debug", "-r", "--resume", "-w", "--worktree", "--remote-control",
|
|
36
|
+
"--from-pr", "--teleport", "--prompt-suggestions", "--cloud"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def launcher_path(bin_dir: str, name: str) -> str:
|
|
40
|
+
return os.path.join(bin_dir, LAUNCHER_PREFIX + name)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def expand_value(value: str) -> str:
|
|
44
|
+
"""值恰好是 `~` 或以 `~/` 开头时展开成绝对路径;其它写法(如 `--settings=~/x`)原样保留。"""
|
|
45
|
+
if value == "~" or value.startswith("~/"):
|
|
46
|
+
return os.path.expanduser(value)
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render(name: str, identity: str, account_dir: str, default_dir: str, proxy: str,
|
|
51
|
+
env: Dict[str, str], args: List[str]) -> str:
|
|
52
|
+
"""生成 POSIX sh 启动命令的完整内容。所有值都经 shlex.quote 转义。
|
|
53
|
+
|
|
54
|
+
account_dir 必须是 expand() 的结果(只展开 `~` 并转绝对路径,不做 realpath),
|
|
55
|
+
因为 dir 身份账号的 macOS 登录绑定在这串字符上。default_dir 是 `$HOME/.claude` 展开后的路径,
|
|
56
|
+
只在 default 身份下使用。
|
|
57
|
+
"""
|
|
58
|
+
lines = [
|
|
59
|
+
"#!/bin/sh",
|
|
60
|
+
MARKER_PREFIX + name,
|
|
61
|
+
"# Generated by multi-claude. Do not edit; use `multi-claude` commands to change it.",
|
|
62
|
+
ACCOUNT_DIR_PREFIX + shlex.quote(account_dir),
|
|
63
|
+
# claude 遇到不存在的配置目录会当成新账号,这里提前给出明确提示。
|
|
64
|
+
'if [ ! -d "$account_dir" ]; then',
|
|
65
|
+
# 用 printf 而不是 echo:dash 的 echo 会解释路径里的反斜杠。
|
|
66
|
+
' printf \'multi-claude: account directory does not exist: %s\\n\' "$account_dir" >&2',
|
|
67
|
+
" exit 1",
|
|
68
|
+
"fi",
|
|
69
|
+
]
|
|
70
|
+
lines.extend(_identity_lines(identity, default_dir))
|
|
71
|
+
lines.append("unset " + " ".join(AUTH_OVERRIDE_ENV))
|
|
72
|
+
for key in sorted(env):
|
|
73
|
+
lines.append("{}={}; export {}".format(key, shlex.quote(expand_value(env[key])), key))
|
|
74
|
+
lines.extend(_proxy_lines(proxy))
|
|
75
|
+
command = ["exec", "claude"] + [shlex.quote(expand_value(arg)) for arg in args] + ['"$@"']
|
|
76
|
+
lines.extend([
|
|
77
|
+
"if ! command -v claude >/dev/null 2>&1; then",
|
|
78
|
+
' echo "multi-claude: claude not found in PATH" >&2',
|
|
79
|
+
" exit 127",
|
|
80
|
+
"fi",
|
|
81
|
+
" ".join(command),
|
|
82
|
+
"",
|
|
83
|
+
])
|
|
84
|
+
return "\n".join(lines)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _identity_lines(identity: str, default_dir: str) -> List[str]:
|
|
88
|
+
if identity == IDENTITY_DEFAULT:
|
|
89
|
+
return [
|
|
90
|
+
"default_dir=" + shlex.quote(default_dir),
|
|
91
|
+
# default 身份靠“不设 CLAUDE_CONFIG_DIR”保住原登录,前提是 ~/.claude 仍指向账号目录;
|
|
92
|
+
# 用户删掉或改指了这条软链时,直接运行会落到一个空目录,所以拒绝启动。
|
|
93
|
+
# 用 cd -P + pwd -P 比较真实路径:[ -ef ] 不属于 POSIX,shellcheck 会报 SC3013。
|
|
94
|
+
'if [ "$(cd -P -- "$default_dir" 2>/dev/null && pwd -P)" != "$(cd -P -- "$account_dir" && pwd -P)" ]; then',
|
|
95
|
+
" printf 'multi-claude: %s no longer points to %s; restore the link or run "
|
|
96
|
+
"multi-claude migrate-default again\\n' \"$default_dir\" \"$account_dir\" >&2",
|
|
97
|
+
" exit 1",
|
|
98
|
+
"fi",
|
|
99
|
+
# 从别的账号的 Claude 会话里运行时会继承它的 CLAUDE_CONFIG_DIR,必须清掉才能落到默认账号。
|
|
100
|
+
"unset CLAUDE_CONFIG_DIR CLAUDE_SECURESTORAGE_CONFIG_DIR",
|
|
101
|
+
]
|
|
102
|
+
return [
|
|
103
|
+
# 继承来的 CLAUDE_SECURESTORAGE_CONFIG_DIR 会改写钥匙串服务名的计算依据,必须清除。
|
|
104
|
+
"unset CLAUDE_SECURESTORAGE_CONFIG_DIR",
|
|
105
|
+
'CLAUDE_CONFIG_DIR="$account_dir"',
|
|
106
|
+
"export CLAUDE_CONFIG_DIR",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _proxy_lines(proxy: str) -> List[str]:
|
|
111
|
+
if proxy == PROXY_INHERIT:
|
|
112
|
+
# 不动任何代理变量,沿用启动时 shell 里的值。
|
|
113
|
+
return []
|
|
114
|
+
if proxy == PROXY_OFF:
|
|
115
|
+
# 清掉全部 8 个变量;只是“不导出”挡不住从父进程继承来的代理。
|
|
116
|
+
return ["unset " + " ".join(PROXY_VARS)]
|
|
117
|
+
q_proxy = shlex.quote(proxy)
|
|
118
|
+
return [
|
|
119
|
+
"HTTPS_PROXY={0}; HTTP_PROXY={0}; https_proxy={0}; http_proxy={0}".format(q_proxy),
|
|
120
|
+
"export HTTPS_PROXY HTTP_PROXY https_proxy http_proxy",
|
|
121
|
+
# Claude 自己调用 git 时会退而使用 ALL_PROXY(方案 §3 依据 5);清掉继承来的值,
|
|
122
|
+
# 免得父进程里的另一个代理把部分流量带走。
|
|
123
|
+
"unset ALL_PROXY all_proxy",
|
|
124
|
+
# 在用户原有的 NO_PROXY 后追加本地地址,不覆盖用户原来的内网例外;原值为空时不留前导逗号。
|
|
125
|
+
'NO_PROXY="${{NO_PROXY:+$NO_PROXY,}}{}"'.format(_NO_PROXY_HOSTS),
|
|
126
|
+
'no_proxy="${{no_proxy:+$no_proxy,}}{}"'.format(_NO_PROXY_HOSTS),
|
|
127
|
+
"export NO_PROXY no_proxy",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def variadic_warning(args: List[str]) -> Optional[str]:
|
|
132
|
+
"""固定参数的最后一个选项会吞掉运行时传入的参数时,返回告警文字;否则返回 None。
|
|
133
|
+
|
|
134
|
+
`--allowedTools a b` 之后接 "$@",用户传入的提示词或子命令会被当成工具名;
|
|
135
|
+
`--resume` 之类的可选值选项放在末尾且没有值时,会把 "$@" 的第一个参数当成自己的值。
|
|
136
|
+
"""
|
|
137
|
+
last_index = None
|
|
138
|
+
for index, arg in enumerate(args):
|
|
139
|
+
if arg.startswith("-"):
|
|
140
|
+
last_index = index
|
|
141
|
+
if last_index is None:
|
|
142
|
+
return None
|
|
143
|
+
option = args[last_index].split("=", 1)[0]
|
|
144
|
+
has_values = last_index < len(args) - 1 or "=" in args[last_index]
|
|
145
|
+
if option in VARIADIC_OPTIONS or (option in OPTIONAL_VALUE_OPTIONS and not has_values):
|
|
146
|
+
return ("{} takes the arguments you pass to the launcher (a prompt or a subcommand) as its own "
|
|
147
|
+
"values; put another option after it".format(option))
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def rendered_account_dir(content: str) -> Optional[str]:
|
|
152
|
+
"""从启动命令内容里取出 `account_dir=` 行的值(已去掉 shlex 转义);取不到时返回 None。"""
|
|
153
|
+
for line in content.split("\n"):
|
|
154
|
+
if line.startswith(ACCOUNT_DIR_PREFIX):
|
|
155
|
+
try:
|
|
156
|
+
parts = shlex.split(line[len(ACCOUNT_DIR_PREFIX):])
|
|
157
|
+
except ValueError:
|
|
158
|
+
return None
|
|
159
|
+
return parts[0] if len(parts) == 1 else None
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def managed_account(path: str) -> Optional[str]:
|
|
164
|
+
"""若 path 是本工具生成的启动命令,返回其中记录的账号名;否则返回 None。
|
|
165
|
+
|
|
166
|
+
只认普通文件:软链、目录或读不出文本的文件都视为“不归本工具管理”。
|
|
167
|
+
"""
|
|
168
|
+
if entry_kind(path) != KIND_FILE:
|
|
169
|
+
return None
|
|
170
|
+
content = read_text(path)
|
|
171
|
+
if content is None:
|
|
172
|
+
return None
|
|
173
|
+
lines = content.split("\n", 2)
|
|
174
|
+
if len(lines) < 2 or not lines[1].startswith(MARKER_PREFIX):
|
|
175
|
+
return None
|
|
176
|
+
return lines[1][len(MARKER_PREFIX):].strip() or None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def scan_managed(bin_dir: str) -> Dict[str, str]:
|
|
180
|
+
"""列出 bin_dir 中所有受管启动命令,返回 {账号名: 路径}。bin_dir 不存在时返回空。"""
|
|
181
|
+
found: Dict[str, str] = {}
|
|
182
|
+
try:
|
|
183
|
+
names = os.listdir(bin_dir)
|
|
184
|
+
except OSError:
|
|
185
|
+
return found
|
|
186
|
+
for entry in sorted(names):
|
|
187
|
+
if not entry.startswith(LAUNCHER_PREFIX):
|
|
188
|
+
continue
|
|
189
|
+
path = os.path.join(bin_dir, entry)
|
|
190
|
+
account = managed_account(path)
|
|
191
|
+
if account is not None:
|
|
192
|
+
found[account] = path
|
|
193
|
+
return found
|
multi_claude/lock.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""写操作互斥锁。
|
|
2
|
+
|
|
3
|
+
用 fcntl.flock 实现:进程退出(包括被 SIGKILL)时内核自动释放锁,
|
|
4
|
+
不会出现“持锁进程已死但锁还在”的情况,也不受 PID 复用影响。
|
|
5
|
+
锁文件里的 PID 只用于报错时告诉用户是谁持有锁,不参与判断。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import fcntl
|
|
9
|
+
import os
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from . import platform
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LockBusyError(Exception):
|
|
16
|
+
def __init__(self, holder_pid: Optional[str]) -> None:
|
|
17
|
+
super().__init__(holder_pid)
|
|
18
|
+
self.holder_pid = holder_pid
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WriteLock(object):
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self.path = os.path.join(platform.state_dir(), "lock")
|
|
24
|
+
self._fd: Optional[int] = None
|
|
25
|
+
|
|
26
|
+
def __enter__(self) -> "WriteLock":
|
|
27
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
28
|
+
# 不能带 O_TRUNC 打开:此时还没拿到锁,截断会清掉持锁进程写的 PID。
|
|
29
|
+
# 锁文件也不能用 atomic_write 写或被删除:换了 inode,两个进程会各自锁住不同的文件。
|
|
30
|
+
fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
31
|
+
try:
|
|
32
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
33
|
+
except (BlockingIOError, PermissionError):
|
|
34
|
+
holder = os.read(fd, 64).decode("ascii", "replace").strip() or None
|
|
35
|
+
os.close(fd)
|
|
36
|
+
raise LockBusyError(holder)
|
|
37
|
+
os.ftruncate(fd, 0)
|
|
38
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
39
|
+
os.write(fd, str(os.getpid()).encode("ascii"))
|
|
40
|
+
self._fd = fd
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def __exit__(self, *_exc) -> None:
|
|
44
|
+
if self._fd is not None:
|
|
45
|
+
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
|
46
|
+
os.close(self._fd)
|
|
47
|
+
self._fd = None
|