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/cli.py
ADDED
|
@@ -0,0 +1,1302 @@
|
|
|
1
|
+
"""命令行入口:解析参数、加锁、组装新配置,再交给收敛引擎或迁移模块。
|
|
2
|
+
|
|
3
|
+
退出码契约(方案 §5.2,沿用 multi-codex):0 成功或已是目标状态;1 运行错误;2 参数不合法;
|
|
4
|
+
3 存在冲突且未做任何修改;4 迁移源目录正被占用。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import difflib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform as py_platform
|
|
12
|
+
import re
|
|
13
|
+
import shlex
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from typing import List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from . import (__version__, accounts, completion, doctor, hook, identity, migrate, platform, routes, sessions,
|
|
21
|
+
shellpath, statusline, usage)
|
|
22
|
+
from .actions import error, info, warn
|
|
23
|
+
from .config import (DEFAULT_SHARED_DIR, DEFAULT_SHARED_ITEMS, IDENTITY_DEFAULT, IDENTITY_DIR, Account, Config, ConfigError, RouteRule,
|
|
24
|
+
is_unshareable, load_config, normalize_proxy, parse_config, validate_env_key, validate_name,
|
|
25
|
+
validate_route_path, validate_value, config_path)
|
|
26
|
+
from .env import account_env
|
|
27
|
+
from .fsutil import expand
|
|
28
|
+
from .lock import LockBusyError, WriteLock
|
|
29
|
+
|
|
30
|
+
# 源码中出现、官方文档未列出的代理变量(方案 §10):`off` 一期不清除它们,list 时只提示。
|
|
31
|
+
_UNMANAGED_PROXY_VARS = ("CLAUDE_CODE_HTTP_PROXY", "CLAUDE_CODE_HTTPS_PROXY")
|
|
32
|
+
_ONE_DAY = timedelta(days=1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UsageError(Exception):
|
|
36
|
+
"""命令行参数不合法,对应退出码 2。"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# (分组标题, [(命令显示名, 说明)])。命令显示名里用逗号并列的每个名字都必须是子命令。
|
|
40
|
+
COMMAND_GROUPS = [
|
|
41
|
+
("Getting started", [
|
|
42
|
+
("add NAME", "create an account and its launcher claude-NAME"),
|
|
43
|
+
("login NAME", "sign in to an account"),
|
|
44
|
+
("list", "show accounts, logins and usage"),
|
|
45
|
+
]),
|
|
46
|
+
("Account settings", [
|
|
47
|
+
("set NAME", "change an account: proxy and sharing"),
|
|
48
|
+
("proxy, env, args", "set the proxy, extra variables or fixed arguments of a launcher"),
|
|
49
|
+
("rename, remove", "rename or unregister an account"),
|
|
50
|
+
("migrate-default", "turn the existing ~/.claude into an account"),
|
|
51
|
+
]),
|
|
52
|
+
("Everyday", [
|
|
53
|
+
("usage", "5-hour and 7-day usage of each account"),
|
|
54
|
+
("route, which", "choose an account by directory (launcher claude-auto)"),
|
|
55
|
+
("handoff", "copy a session to another account"),
|
|
56
|
+
("mcp", "run `claude mcp` as an account"),
|
|
57
|
+
]),
|
|
58
|
+
("Setup and checks", [
|
|
59
|
+
("init, apply", "global settings; converge everything to config.json"),
|
|
60
|
+
("doctor", "check the setup and suggest fixes"),
|
|
61
|
+
("statusline", "record usage from the status line"),
|
|
62
|
+
("completion", "print a shell completion script"),
|
|
63
|
+
]),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
_EXAMPLES = [
|
|
67
|
+
("multi-claude add work --proxy 7901", ""),
|
|
68
|
+
("multi-claude login work", ""),
|
|
69
|
+
("multi-claude route ~/work work", ""),
|
|
70
|
+
("multi-claude add --help", "details of one command"),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def grouped_command_names() -> List[str]:
|
|
75
|
+
"""COMMAND_GROUPS 里出现的全部子命令名(去掉 NAME 之类的参数占位)。"""
|
|
76
|
+
names = []
|
|
77
|
+
for _title, items in COMMAND_GROUPS:
|
|
78
|
+
for shown, _text in items:
|
|
79
|
+
for part in shown.split(","):
|
|
80
|
+
names.append(part.split()[0])
|
|
81
|
+
return names
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _help_epilog() -> str:
|
|
85
|
+
lines = []
|
|
86
|
+
for title, items in COMMAND_GROUPS:
|
|
87
|
+
lines.append("{}:".format(title))
|
|
88
|
+
for shown, text in items:
|
|
89
|
+
lines.append(" {:<18}{}".format(shown, text))
|
|
90
|
+
lines.append("")
|
|
91
|
+
lines.append("Examples:")
|
|
92
|
+
for command, text in _EXAMPLES:
|
|
93
|
+
lines.append(" {:<36}{}".format(command, text).rstrip())
|
|
94
|
+
return "\n".join(lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
98
|
+
# 子命令不传 help=,argparse 就不会把它们平铺成一列;改由 epilog 按用途分组列出(方案 feature-clearer-help §5.1.1)。
|
|
99
|
+
# 新增子命令时必须同时写进 COMMAND_GROUPS,测试会核对两者一致。
|
|
100
|
+
parser = argparse.ArgumentParser(
|
|
101
|
+
prog="multi-claude",
|
|
102
|
+
description="Manage multiple Claude Code accounts: separate CLAUDE_CONFIG_DIR directories,\n"
|
|
103
|
+
"per-account launchers, proxies, environment variables and arguments.",
|
|
104
|
+
epilog=_help_epilog(), formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
105
|
+
parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
|
|
106
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND", help="one of the commands listed below")
|
|
107
|
+
sub.required = True
|
|
108
|
+
|
|
109
|
+
p_init = sub.add_parser("init", description="create or update the global settings")
|
|
110
|
+
p_init.add_argument("--root", help="directory that holds account directories (default ~/.cc)")
|
|
111
|
+
p_init.add_argument("--bin-dir", help="directory for claude-<name> launchers (default ~/.local/bin)")
|
|
112
|
+
p_init.add_argument("--shared-dir", help="directory whose items can be linked into accounts")
|
|
113
|
+
p_init.add_argument("--shared-items", help="comma-separated items to share "
|
|
114
|
+
"(default {})".format(",".join(DEFAULT_SHARED_ITEMS)))
|
|
115
|
+
_add_write_options(p_init)
|
|
116
|
+
|
|
117
|
+
p_mig = sub.add_parser("migrate-default", description="turn the default ~/.claude into a named account "
|
|
118
|
+
"without losing its login")
|
|
119
|
+
p_mig.add_argument("name")
|
|
120
|
+
p_mig.add_argument("--copy", action="store_true",
|
|
121
|
+
help="copy and verify instead of renaming (used automatically across file systems)")
|
|
122
|
+
p_mig.add_argument("--keep-backup", action="store_true",
|
|
123
|
+
help="in copy mode, keep the original directory as a backup")
|
|
124
|
+
p_mig.add_argument("--proxy", help="proxy for the new account: port, URL, off or inherit")
|
|
125
|
+
p_mig.add_argument("--skip-process-check", action="store_true",
|
|
126
|
+
help="do not check whether Claude Code is running (at your own risk)")
|
|
127
|
+
_add_dry_run(p_mig)
|
|
128
|
+
|
|
129
|
+
p_add = sub.add_parser("add", description="add an account, adopt an existing directory, or change its options")
|
|
130
|
+
_add_account_options(p_add, adopt=True)
|
|
131
|
+
|
|
132
|
+
p_set = sub.add_parser("set", description="change an existing account: proxy and sharing "
|
|
133
|
+
"(use env, args, rename for the rest)")
|
|
134
|
+
_add_account_options(p_set, adopt=False)
|
|
135
|
+
|
|
136
|
+
p_proxy = sub.add_parser("proxy", description="set the proxy of an account")
|
|
137
|
+
p_proxy.add_argument("name")
|
|
138
|
+
p_proxy.add_argument("value", help="port (e.g. 7901), http(s) URL, off or inherit")
|
|
139
|
+
_add_write_options(p_proxy)
|
|
140
|
+
|
|
141
|
+
p_env = sub.add_parser("env", description="set or unset extra environment variables of a launcher")
|
|
142
|
+
p_env.add_argument("items", nargs="*", metavar="NAME|K=V",
|
|
143
|
+
help="account name (omit with --defaults) followed by K=V assignments")
|
|
144
|
+
p_env.add_argument("--defaults", action="store_true", help="change the defaults used by every account")
|
|
145
|
+
p_env.add_argument("--unset", action="append", default=[], metavar="K", help="remove a variable")
|
|
146
|
+
_add_write_options(p_env)
|
|
147
|
+
|
|
148
|
+
# `args` 的参数列表在 argparse 之前从 argv 里切走(见 _split_args_command),这里只解析 `--` 之前的部分。
|
|
149
|
+
p_args = sub.add_parser("args", description="replace the fixed arguments of a launcher: args NAME -- [ARG ...]")
|
|
150
|
+
p_args.add_argument("name", nargs="?")
|
|
151
|
+
p_args.add_argument("--defaults", action="store_true", help="change the defaults used by every account")
|
|
152
|
+
_add_write_options(p_args)
|
|
153
|
+
|
|
154
|
+
p_remove = sub.add_parser("remove", description="unregister an account (its directory and login are kept)")
|
|
155
|
+
p_remove.add_argument("name")
|
|
156
|
+
_add_write_options(p_remove)
|
|
157
|
+
|
|
158
|
+
p_apply = sub.add_parser("apply", description="converge all accounts to the configuration")
|
|
159
|
+
p_apply.add_argument("-f", "--file", help="use this file as the new configuration")
|
|
160
|
+
_add_write_options(p_apply)
|
|
161
|
+
|
|
162
|
+
p_list = sub.add_parser("list", description="show accounts and their status")
|
|
163
|
+
list_mode = p_list.add_mutually_exclusive_group()
|
|
164
|
+
list_mode.add_argument("--verbose", action="store_true",
|
|
165
|
+
help="show every field, the global settings and the keychain details")
|
|
166
|
+
list_mode.add_argument("--json", action="store_true", help="print machine-readable JSON (includes usage)")
|
|
167
|
+
list_mode.add_argument("--names", action="store_true", help="print only the account names, one per line")
|
|
168
|
+
|
|
169
|
+
p_usage = sub.add_parser("usage", description="show the last known 5-hour and 7-day usage of each account "
|
|
170
|
+
"(from Claude Code's own cache; never reads credentials)")
|
|
171
|
+
p_usage.add_argument("name", nargs="?")
|
|
172
|
+
p_usage.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
173
|
+
|
|
174
|
+
p_doctor = sub.add_parser("doctor", description="check the accounts, launchers and environment (read-only)")
|
|
175
|
+
p_doctor.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
176
|
+
p_doctor.add_argument("--verbose", action="store_true", help="also list the checks that passed")
|
|
177
|
+
|
|
178
|
+
p_route = sub.add_parser("route", description="choose an account by directory for claude-auto: route DIR NAME, "
|
|
179
|
+
"route DIR --remove, route --default NAME, route --no-default")
|
|
180
|
+
p_route.add_argument("path", nargs="?", metavar="DIR")
|
|
181
|
+
p_route.add_argument("name", nargs="?", metavar="NAME")
|
|
182
|
+
route_mode = p_route.add_mutually_exclusive_group()
|
|
183
|
+
route_mode.add_argument("--remove", action="store_true", help="remove the route of DIR")
|
|
184
|
+
route_mode.add_argument("--default", metavar="NAME", help="account used when no route matches")
|
|
185
|
+
route_mode.add_argument("--no-default", action="store_true",
|
|
186
|
+
help="run plain claude when no route matches (the initial behaviour)")
|
|
187
|
+
_add_write_options(p_route)
|
|
188
|
+
|
|
189
|
+
p_rename = sub.add_parser("rename", description="rename an account and its launcher; the directory and login stay")
|
|
190
|
+
p_rename.add_argument("old")
|
|
191
|
+
p_rename.add_argument("new")
|
|
192
|
+
_add_write_options(p_rename)
|
|
193
|
+
|
|
194
|
+
# mcp 的 claude 参数在 argparse 之前从 argv 里切走(见 _split_args_command)。
|
|
195
|
+
p_mcp = sub.add_parser("mcp", description="run `claude mcp ...` as an account: mcp NAME [ARG ...]")
|
|
196
|
+
p_mcp.add_argument("name")
|
|
197
|
+
|
|
198
|
+
# login 的 claude 参数与 mcp 一样,在 argparse 之前从 argv 里切走(见 _split_args_command)。
|
|
199
|
+
p_login = sub.add_parser("login", description="sign in to an account: runs `claude auth login` as that account: "
|
|
200
|
+
"login NAME [ARG ...]")
|
|
201
|
+
p_login.add_argument("name")
|
|
202
|
+
|
|
203
|
+
p_which = sub.add_parser("which", description="show which account claude-auto would use in DIR (default: here)")
|
|
204
|
+
p_which.add_argument("path", nargs="?", metavar="DIR")
|
|
205
|
+
|
|
206
|
+
p_completion = sub.add_parser("completion", description="print a shell completion script")
|
|
207
|
+
p_completion.add_argument("shell", choices=completion.SHELLS)
|
|
208
|
+
|
|
209
|
+
# 钩子 statusline-hook 不是子命令:它在 main 里先于 argparse 被拦下,不出现在帮助与补全中。
|
|
210
|
+
p_statusline = sub.add_parser("statusline", description="record usage from Claude's status line: wrap (install) or "
|
|
211
|
+
"restore (uninstall) the statusLine command in a settings FILE")
|
|
212
|
+
p_statusline.add_argument("action", choices=("install", "uninstall"))
|
|
213
|
+
p_statusline.add_argument("file", metavar="FILE")
|
|
214
|
+
_add_dry_run(p_statusline)
|
|
215
|
+
|
|
216
|
+
p_handoff = sub.add_parser("handoff", description="copy a Claude session to another account so that it can be resumed "
|
|
217
|
+
"there: handoff TARGET [--from NAME] [--session ID]")
|
|
218
|
+
p_handoff.add_argument("target", metavar="TARGET")
|
|
219
|
+
p_handoff.add_argument("--from", dest="source", metavar="NAME",
|
|
220
|
+
help="account that has the session (default: the account of the Claude session "
|
|
221
|
+
"this runs in)")
|
|
222
|
+
p_handoff.add_argument("--session", metavar="ID",
|
|
223
|
+
help="session to copy (default: the current session, else the latest one here)")
|
|
224
|
+
p_handoff.add_argument("--force", action="store_true",
|
|
225
|
+
help="back up and replace a different copy of the session in TARGET")
|
|
226
|
+
_add_dry_run(p_handoff)
|
|
227
|
+
return parser
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _add_dry_run(parser: argparse.ArgumentParser) -> None:
|
|
231
|
+
parser.add_argument("--dry-run", action="store_true", help="show the planned actions without changing anything")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _add_account_options(parser: argparse.ArgumentParser, adopt: bool) -> None:
|
|
235
|
+
"""add 与 set 共用的选项;只有 add 有 --adopt(接管只在登记已有目录时有意义)。"""
|
|
236
|
+
parser.add_argument("name")
|
|
237
|
+
parser.add_argument("--proxy", help="port, URL, off or inherit (new accounts default to inherit)")
|
|
238
|
+
shared_group = parser.add_mutually_exclusive_group()
|
|
239
|
+
# --shared 可带一个目录:不带时用已设置的共享目录(未设置则为 ~/.claude-shared),
|
|
240
|
+
# 带 DIR 时把全局共享目录改为 DIR,所有开启共享的账号的链接都改指向它(方案 feature-set-command §5.1)。
|
|
241
|
+
shared_group.add_argument("--shared", dest="shared_to", nargs="?", const=True, default=None, metavar="DIR",
|
|
242
|
+
help="link the shared items into this account; with DIR, also make DIR the shared "
|
|
243
|
+
"directory for every account (default {})".format(DEFAULT_SHARED_DIR))
|
|
244
|
+
shared_group.add_argument("--no-shared", dest="no_shared", action="store_true",
|
|
245
|
+
help="do not link shared items (default for new accounts)")
|
|
246
|
+
if adopt:
|
|
247
|
+
parser.add_argument("--adopt", action="store_true",
|
|
248
|
+
help="take over existing links that already point to the shared items, "
|
|
249
|
+
"so that turning sharing off later removes them too")
|
|
250
|
+
parser.add_argument("--shared-exclude", action="append", default=[], metavar="ITEM",
|
|
251
|
+
help="do not link this shared item into the account (repeatable); "
|
|
252
|
+
"the link multi-claude made is removed")
|
|
253
|
+
parser.add_argument("--shared-include", action="append", default=[], metavar="ITEM",
|
|
254
|
+
help="undo --shared-exclude for this item (repeatable)")
|
|
255
|
+
_add_write_options(parser)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _add_write_options(parser: argparse.ArgumentParser) -> None:
|
|
259
|
+
"""收敛类写命令的公共选项:默认只打印有变化的动作,--verbose 列出全部。"""
|
|
260
|
+
_add_dry_run(parser)
|
|
261
|
+
parser.add_argument("--verbose", action="store_true", help="also show items that are already up to date")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _split_args_command(argv: List[str]) -> Tuple[List[str], Optional[List[str]]]:
|
|
265
|
+
"""子命令是 `args` 时,把第一个 `--` 之后的全部内容取出来作为参数列表;
|
|
266
|
+
子命令是 `mcp` 或 `login` 时,账号名之后的全部内容原样作为 `claude mcp` / `claude auth login` 的参数(不经 argparse)。
|
|
267
|
+
|
|
268
|
+
不能用 argparse.REMAINDER:可选位置参数加 --defaults 加 REMAINDER 时,
|
|
269
|
+
`args --defaults -- --settings s` 会被解析成账号名 `--settings`(方案 §5.1.2,实测 3.10/3.11/3.13)。
|
|
270
|
+
返回 (交给 argparse 的部分, 参数列表);不是这两个子命令时参数列表为 None。
|
|
271
|
+
"""
|
|
272
|
+
command_index = None
|
|
273
|
+
for index, token in enumerate(argv):
|
|
274
|
+
if token == "--":
|
|
275
|
+
break
|
|
276
|
+
if not token.startswith("-"):
|
|
277
|
+
command_index = index
|
|
278
|
+
break
|
|
279
|
+
if command_index is not None and argv[command_index] in ("mcp", "login"):
|
|
280
|
+
command = argv[command_index]
|
|
281
|
+
rest = argv[command_index + 1:]
|
|
282
|
+
if rest and rest[0] in ("-h", "--help"):
|
|
283
|
+
return argv, []
|
|
284
|
+
if not rest or rest[0].startswith("-"):
|
|
285
|
+
example = "multi-claude mcp NAME list" if command == "mcp" else "multi-claude login NAME"
|
|
286
|
+
raise UsageError("{} needs an account name first, e.g. `{}`".format(command, example))
|
|
287
|
+
return argv[:command_index + 2], rest[1:]
|
|
288
|
+
if command_index is None or argv[command_index] != "args":
|
|
289
|
+
return argv, None
|
|
290
|
+
try:
|
|
291
|
+
separator = argv.index("--", command_index + 1)
|
|
292
|
+
except ValueError:
|
|
293
|
+
# 要求显式写 `--`:否则 `args work --resume` 这种写法里的参数会被误当成账号名或选项。
|
|
294
|
+
if any(token in ("-h", "--help") for token in argv[command_index + 1:]):
|
|
295
|
+
return argv, []
|
|
296
|
+
raise UsageError("args needs `--` before the argument list, e.g. `multi-claude args NAME -- --ide` "
|
|
297
|
+
"(use `args NAME --` to clear)")
|
|
298
|
+
return argv[:separator], argv[separator + 1:]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
302
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
303
|
+
if argv[:1] == [hook.HOOK_COMMAND]:
|
|
304
|
+
# 状态栏每次刷新都会执行钩子:跳过参数解析、平台检查与迁移记录读取,尽快把进程交给原命令。
|
|
305
|
+
if len(argv) != 2:
|
|
306
|
+
error("usage: multi-claude {} COMMAND".format(hook.HOOK_COMMAND))
|
|
307
|
+
return accounts.EXIT_USAGE
|
|
308
|
+
return hook.main(argv[1:])
|
|
309
|
+
if not argv:
|
|
310
|
+
# 不带参数:新用户先看到怎么上手,已有配置的用户看到账号表(方案 feature-easier-onboarding §5.1.1)。
|
|
311
|
+
return cmd_overview()
|
|
312
|
+
parser = build_parser()
|
|
313
|
+
unknown = _unknown_command(parser, argv) or _misplaced_account_name(argv)
|
|
314
|
+
if unknown is not None:
|
|
315
|
+
error(unknown)
|
|
316
|
+
return accounts.EXIT_USAGE
|
|
317
|
+
try:
|
|
318
|
+
argv, launch_args = _split_args_command(argv)
|
|
319
|
+
except UsageError as exc:
|
|
320
|
+
error(str(exc))
|
|
321
|
+
return accounts.EXIT_USAGE
|
|
322
|
+
try:
|
|
323
|
+
args = parser.parse_args(argv)
|
|
324
|
+
except SystemExit as exc:
|
|
325
|
+
# argparse 在参数错误时以 2 退出、--help/--version 以 0 退出,与本工具的退出码契约一致。
|
|
326
|
+
return int(exc.code or 0)
|
|
327
|
+
args.launch_args = launch_args
|
|
328
|
+
|
|
329
|
+
if not platform.is_supported():
|
|
330
|
+
error("this platform is not supported yet (macOS and Linux only)")
|
|
331
|
+
return accounts.EXIT_ERROR
|
|
332
|
+
|
|
333
|
+
# completion 与 doctor 在读取迁移记录之前分派:前者不依赖任何状态,后者要把损坏的配置或
|
|
334
|
+
# 迁移记录报告成检查项,而不是被下面的统一异常处理打断(方案 v0.2 §5.1.6)。
|
|
335
|
+
if args.command == "completion":
|
|
336
|
+
print(completion.render(args.shell, build_parser()), end="")
|
|
337
|
+
return accounts.EXIT_OK
|
|
338
|
+
if args.command == "doctor":
|
|
339
|
+
return cmd_doctor(args.json, args.verbose)
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
notice = migrate.pending_journal_notice()
|
|
343
|
+
if notice:
|
|
344
|
+
warn(notice)
|
|
345
|
+
# list 与 usage 只读:不加写锁,迁移未完成时也放行。
|
|
346
|
+
if args.command == "list":
|
|
347
|
+
return cmd_list(verbose=args.verbose, as_json=args.json, names_only=args.names)
|
|
348
|
+
if args.command == "usage":
|
|
349
|
+
return cmd_usage(args.name, args.json)
|
|
350
|
+
if args.command == "which":
|
|
351
|
+
return cmd_which(args.path)
|
|
352
|
+
if args.command == "statusline":
|
|
353
|
+
# 只改用户指定的设置文件,不碰 config.json 与账号目录,所以不加写锁、迁移未完成时也放行。
|
|
354
|
+
return cmd_statusline(args.action, args.file, args.dry_run)
|
|
355
|
+
if args.command == "handoff":
|
|
356
|
+
if notice:
|
|
357
|
+
# 迁移进行到一半时账号目录可能正在搬动,复制进去的会话可能落在半迁移的目录里。
|
|
358
|
+
error("finish the unfinished migration before running `handoff`", phase="handoff")
|
|
359
|
+
return accounts.EXIT_ERROR
|
|
360
|
+
# 不加写锁:只读来源账号、只写目标账号的 projects/,不碰 config.json。
|
|
361
|
+
return cmd_handoff(args.target, args.source, args.session, args.force, args.dry_run)
|
|
362
|
+
if args.command == "login":
|
|
363
|
+
if notice:
|
|
364
|
+
# 与 mcp 相同:迁移进行到一半时账号目录可能正在搬动。
|
|
365
|
+
error("finish the unfinished migration before running `login`")
|
|
366
|
+
return accounts.EXIT_ERROR
|
|
367
|
+
# 不加写锁:凭据由 Claude 自己写入,登录过程需要浏览器交互,可能持续很久。
|
|
368
|
+
return cmd_login(args.name, args.launch_args or [])
|
|
369
|
+
if args.command == "mcp":
|
|
370
|
+
if notice:
|
|
371
|
+
# 迁移进行到一半时账号目录可能正在搬动,claude 写进去的内容可能落在半迁移的目录里。
|
|
372
|
+
error("finish the unfinished migration before running `mcp`")
|
|
373
|
+
return accounts.EXIT_ERROR
|
|
374
|
+
# 不加写锁:claude mcp 只改 Claude 自己的文件,可能需要交互(如远程服务器登录)。
|
|
375
|
+
return cmd_mcp(args.name, args.launch_args or [])
|
|
376
|
+
if _blocked_by_migration(args, notice):
|
|
377
|
+
return accounts.EXIT_ERROR
|
|
378
|
+
with WriteLock():
|
|
379
|
+
# 加锁前的检查与加锁之间,另一条命令可能刚开始一次迁移,拿到锁后再确认一次。
|
|
380
|
+
if _blocked_by_migration(args, migrate.pending_journal_notice()):
|
|
381
|
+
return accounts.EXIT_ERROR
|
|
382
|
+
return dispatch(args)
|
|
383
|
+
except migrate.JournalError as exc:
|
|
384
|
+
error("{}. Check where the complete data of the unfinished migration is (the source, "
|
|
385
|
+
"the target account directory, or a *.multi-claude-bak.* backup next to the source), "
|
|
386
|
+
"make sure it is back at the source path, then delete the journal file".format(exc),
|
|
387
|
+
phase="resume")
|
|
388
|
+
return accounts.EXIT_ERROR
|
|
389
|
+
except LockBusyError as exc:
|
|
390
|
+
error("another multi-claude command is running (pid {})".format(exc.holder_pid or "unknown"))
|
|
391
|
+
return accounts.EXIT_ERROR
|
|
392
|
+
except UsageError as exc:
|
|
393
|
+
error(str(exc))
|
|
394
|
+
return accounts.EXIT_USAGE
|
|
395
|
+
except _RenameConflict as exc:
|
|
396
|
+
error("account {!r} already exists".format(exc.args[0]))
|
|
397
|
+
return accounts.EXIT_CONFLICT
|
|
398
|
+
except _NotRegistered as exc:
|
|
399
|
+
# 与 proxy 一致:对未登记的账号名返回 1。
|
|
400
|
+
error("account {!r} is not registered{}".format(
|
|
401
|
+
exc.args[0], "; " + exc.args[1] if len(exc.args) > 1 else ""))
|
|
402
|
+
return accounts.EXIT_ERROR
|
|
403
|
+
except ConfigError as exc:
|
|
404
|
+
error(str(exc), phase="config")
|
|
405
|
+
return accounts.EXIT_ERROR
|
|
406
|
+
except OSError as exc:
|
|
407
|
+
error("{}: {}".format(type(exc).__name__, exc))
|
|
408
|
+
return accounts.EXIT_ERROR
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _blocked_by_migration(args: argparse.Namespace, notice: Optional[str]) -> bool:
|
|
412
|
+
# 迁移中途禁止其它写命令,防止根目录或账号在迁移过程中被改动。
|
|
413
|
+
if notice and args.command != "migrate-default" and not args.dry_run:
|
|
414
|
+
error("finish the unfinished migration before running `{}`".format(args.command))
|
|
415
|
+
return True
|
|
416
|
+
return False
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
420
|
+
if args.command == "migrate-default":
|
|
421
|
+
name = _checked_name(args.name)
|
|
422
|
+
proxy = _checked_proxy(args.proxy) if args.proxy is not None else None
|
|
423
|
+
return migrate.migrate_default(name, None, args.copy, args.keep_backup, proxy,
|
|
424
|
+
args.skip_process_check, args.dry_run)
|
|
425
|
+
|
|
426
|
+
old, exists = load_config()
|
|
427
|
+
new = old.copy()
|
|
428
|
+
orphan_scope = accounts.NO_ORPHANS
|
|
429
|
+
adopt_accounts = frozenset()
|
|
430
|
+
# 写完后要检查“可变参数吞参”与“settings 覆盖代理”的账号;None 表示全部账号。
|
|
431
|
+
warn_names: Optional[List[str]] = []
|
|
432
|
+
adopted_dir: Optional[str] = None
|
|
433
|
+
renamed: Optional[Tuple[str, str]] = None
|
|
434
|
+
# --shared DIR 修改全局共享目录时记下 (旧值, 新值),收敛成功后告诉用户所有共享账号都跟着改了。
|
|
435
|
+
changed_shared_dir: Optional[Tuple[Optional[str], str]] = None
|
|
436
|
+
|
|
437
|
+
if args.command == "init":
|
|
438
|
+
if args.root:
|
|
439
|
+
new.root = args.root
|
|
440
|
+
if args.bin_dir:
|
|
441
|
+
new.bin_dir = args.bin_dir
|
|
442
|
+
if args.shared_dir:
|
|
443
|
+
new.shared_dir = args.shared_dir
|
|
444
|
+
if args.shared_items is not None:
|
|
445
|
+
items = [item.strip() for item in args.shared_items.split(",") if item.strip()]
|
|
446
|
+
if any(item in (".", "..") or "/" in item for item in items):
|
|
447
|
+
raise UsageError("--shared-items must be plain names without '/'")
|
|
448
|
+
unshareable = [item for item in items if is_unshareable(item)]
|
|
449
|
+
if unshareable:
|
|
450
|
+
raise UsageError("--shared-items must not include {}: it holds account-specific state".format(
|
|
451
|
+
", ".join(unshareable)))
|
|
452
|
+
new.shared_items = items
|
|
453
|
+
elif args.command in ("add", "set"):
|
|
454
|
+
name = _checked_name(args.name)
|
|
455
|
+
account = new.find(name)
|
|
456
|
+
if account is None and args.command == "set":
|
|
457
|
+
raise _NotRegistered(name, "use multi-claude add {} to create it".format(name))
|
|
458
|
+
if args.command == "set" and not _has_account_option(args):
|
|
459
|
+
raise UsageError("nothing to set; give at least one option, e.g. `multi-claude set {} --proxy 7901`"
|
|
460
|
+
.format(name))
|
|
461
|
+
if account is None:
|
|
462
|
+
account = Account(name)
|
|
463
|
+
new.accounts[name] = account
|
|
464
|
+
directory = accounts.account_dir(new, name)
|
|
465
|
+
if os.path.isdir(directory):
|
|
466
|
+
adopted_dir = directory
|
|
467
|
+
if args.proxy is not None:
|
|
468
|
+
account.proxy = _checked_proxy(args.proxy)
|
|
469
|
+
if args.shared_to is not None:
|
|
470
|
+
if args.shared_to is not True:
|
|
471
|
+
changed_shared_dir = (new.shared_dir, _checked_shared_dir(args.shared_to))
|
|
472
|
+
new.shared_dir = changed_shared_dir[1]
|
|
473
|
+
elif not new.shared_dir:
|
|
474
|
+
new.shared_dir = DEFAULT_SHARED_DIR
|
|
475
|
+
account.shared = True
|
|
476
|
+
elif args.no_shared:
|
|
477
|
+
account.shared = False
|
|
478
|
+
_apply_shared_exclude(new, account, args.shared_exclude, args.shared_include)
|
|
479
|
+
if getattr(args, "adopt", False):
|
|
480
|
+
# 接管只对开启了共享的账号有意义;关闭状态下工具本来就不管这些软链。
|
|
481
|
+
if not account.shared:
|
|
482
|
+
raise UsageError("--adopt requires sharing to be on for {!r}; add --shared".format(account.name))
|
|
483
|
+
adopt_accounts = frozenset([account.name.casefold()])
|
|
484
|
+
warn_names = [account.name]
|
|
485
|
+
elif args.command == "proxy":
|
|
486
|
+
account = new.find(_checked_name(args.name))
|
|
487
|
+
if account is None:
|
|
488
|
+
error("account {!r} is not registered".format(args.name))
|
|
489
|
+
return accounts.EXIT_ERROR
|
|
490
|
+
account.proxy = _checked_proxy(args.value)
|
|
491
|
+
warn_names = [account.name]
|
|
492
|
+
elif args.command == "env":
|
|
493
|
+
target, assignments = _env_target(args)
|
|
494
|
+
env = new.defaults_env if target is None else _registered(new, target).env
|
|
495
|
+
for key in args.unset:
|
|
496
|
+
# 删除不存在的键不算错误,重复执行结果相同(幂等)。
|
|
497
|
+
env.pop(key, None)
|
|
498
|
+
for key, value in assignments:
|
|
499
|
+
env[key] = value
|
|
500
|
+
warn_names = None if target is None else [target]
|
|
501
|
+
elif args.command == "args":
|
|
502
|
+
launch_args = _checked_args(args.launch_args or [])
|
|
503
|
+
if args.defaults:
|
|
504
|
+
if args.name is not None:
|
|
505
|
+
raise UsageError("give either an account name or --defaults, not both")
|
|
506
|
+
new.defaults_args = launch_args
|
|
507
|
+
warn_names = None
|
|
508
|
+
else:
|
|
509
|
+
if args.name is None:
|
|
510
|
+
raise UsageError("args needs an account name or --defaults")
|
|
511
|
+
account = _registered(new, _checked_name(args.name))
|
|
512
|
+
account.args = launch_args
|
|
513
|
+
warn_names = [account.name]
|
|
514
|
+
elif args.command == "route":
|
|
515
|
+
_apply_route_args(args, new)
|
|
516
|
+
elif args.command == "rename":
|
|
517
|
+
old_name = _rename(new, args.old, args.new)
|
|
518
|
+
orphan_scope = frozenset([old_name.casefold()])
|
|
519
|
+
renamed = (old_name, args.new)
|
|
520
|
+
elif args.command == "remove":
|
|
521
|
+
name = _checked_name(args.name)
|
|
522
|
+
account = new.find(name)
|
|
523
|
+
if account is None:
|
|
524
|
+
info("{} is not registered".format(name))
|
|
525
|
+
else:
|
|
526
|
+
del new.accounts[account.name]
|
|
527
|
+
orphan_scope = frozenset([name.casefold()])
|
|
528
|
+
elif args.command == "apply":
|
|
529
|
+
orphan_scope = accounts.ALL_ORPHANS
|
|
530
|
+
if args.file:
|
|
531
|
+
new = _load_apply_file(args.file, old)
|
|
532
|
+
warn_names = None
|
|
533
|
+
|
|
534
|
+
code = accounts.converge(old, new, config_exists=exists, dry_run=args.dry_run, verbose=args.verbose,
|
|
535
|
+
orphan_scope=orphan_scope, adopt_accounts=adopt_accounts)
|
|
536
|
+
if code == accounts.EXIT_OK and renamed is not None and not args.dry_run:
|
|
537
|
+
info("renamed {} to {} (directory {} unchanged)".format(
|
|
538
|
+
renamed[0], renamed[1], accounts.account_dir(new, renamed[1])))
|
|
539
|
+
if code == accounts.EXIT_OK:
|
|
540
|
+
accounts.warn_launch_settings(new, warn_names)
|
|
541
|
+
if adopted_dir is not None:
|
|
542
|
+
_warn_if_not_logged_in(adopted_dir)
|
|
543
|
+
if args.command == "add" and not args.dry_run:
|
|
544
|
+
_hint_next_step(new, args.name)
|
|
545
|
+
_hint_bin_on_path(new, args.name)
|
|
546
|
+
if args.command in ("add", "set") and not args.dry_run:
|
|
547
|
+
if changed_shared_dir is not None and changed_shared_dir[0] != changed_shared_dir[1]:
|
|
548
|
+
info("shared directory is now {} (was {}); links of every shared account point there".format(
|
|
549
|
+
changed_shared_dir[1], changed_shared_dir[0] or "not set"))
|
|
550
|
+
_hint_empty_shared_dir(new, args.name)
|
|
551
|
+
return code
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _apply_shared_exclude(config: Config, account: Account, exclude: List[str], include: List[str]) -> None:
|
|
555
|
+
"""add 的 --shared-exclude / --shared-include:先撤销再排除。只改配置,链接由随后的收敛计划处理。"""
|
|
556
|
+
for item in exclude + include:
|
|
557
|
+
if item in (".", "..") or "/" in item or not item:
|
|
558
|
+
raise UsageError("shared items must be plain names without '/': {!r}".format(item))
|
|
559
|
+
both = sorted(set(exclude) & set(include))
|
|
560
|
+
if both:
|
|
561
|
+
raise UsageError("{} given to both --shared-exclude and --shared-include".format(", ".join(both)))
|
|
562
|
+
account.shared_exclude = [item for item in account.shared_exclude if item not in include]
|
|
563
|
+
for item in exclude:
|
|
564
|
+
if item not in account.shared_exclude:
|
|
565
|
+
account.shared_exclude.append(item)
|
|
566
|
+
if item not in config.shared_items:
|
|
567
|
+
info("note: {} is not in shared.items; it takes effect only if added there".format(item))
|
|
568
|
+
if exclude and not account.shared:
|
|
569
|
+
info("note: sharing is off for {}; the exclusion applies once it is turned on".format(account.name))
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _apply_route_args(args: argparse.Namespace, new: Config) -> None:
|
|
573
|
+
"""route 的四种形式(互斥):DIR NAME、DIR --remove、--default NAME、--no-default。"""
|
|
574
|
+
if args.default is not None or args.no_default:
|
|
575
|
+
if args.path is not None or args.name is not None:
|
|
576
|
+
raise UsageError("--default and --no-default do not take DIR or NAME")
|
|
577
|
+
if args.no_default:
|
|
578
|
+
new.route_default = None
|
|
579
|
+
return
|
|
580
|
+
new.route_default = _registered(new, _checked_name(args.default)).name
|
|
581
|
+
return
|
|
582
|
+
if args.path is None:
|
|
583
|
+
raise UsageError("route needs DIR NAME, DIR --remove, --default NAME or --no-default")
|
|
584
|
+
path = routes.normalize_path(args.path, os.getcwd())
|
|
585
|
+
try:
|
|
586
|
+
expanded = validate_route_path(path)
|
|
587
|
+
except ValueError as exc:
|
|
588
|
+
raise UsageError(str(exc))
|
|
589
|
+
existing = [index for index, rule in enumerate(new.route_rules) if expand(rule.path) == expanded]
|
|
590
|
+
if args.remove:
|
|
591
|
+
if args.name is not None:
|
|
592
|
+
raise UsageError("route DIR --remove does not take NAME")
|
|
593
|
+
if not existing:
|
|
594
|
+
info("{} has no route".format(path))
|
|
595
|
+
new.route_rules = [rule for rule in new.route_rules if expand(rule.path) != expanded]
|
|
596
|
+
return
|
|
597
|
+
if args.name is None:
|
|
598
|
+
raise UsageError("route DIR needs NAME (or --remove)")
|
|
599
|
+
account = _registered(new, _checked_name(args.name))
|
|
600
|
+
if not os.path.isdir(expanded):
|
|
601
|
+
warn("{} does not exist; the route is ignored until it does".format(path))
|
|
602
|
+
rule = RouteRule(path, account.name)
|
|
603
|
+
if existing:
|
|
604
|
+
# 改指已有规则时保留它在列表中的位置:位置决定“物理路径相同的两条规则”谁生效。
|
|
605
|
+
new.route_rules[existing[0]] = RouteRule(new.route_rules[existing[0]].path, account.name)
|
|
606
|
+
else:
|
|
607
|
+
new.route_rules.append(rule)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _rename(new: Config, old: str, target: str) -> str:
|
|
611
|
+
"""改账号名:账号对象换键、dir 不变,路由引用跟着改。返回原登记的账号名。"""
|
|
612
|
+
_checked_name(target)
|
|
613
|
+
account = _registered(new, _checked_name(old))
|
|
614
|
+
if account.name.casefold() == target.casefold():
|
|
615
|
+
# 大小写不敏感的文件系统上新旧启动命令是同一个文件,删旧建新会互相覆盖。
|
|
616
|
+
raise UsageError("only the letter case differs; renaming that way is not supported")
|
|
617
|
+
if new.find(target) is not None:
|
|
618
|
+
# 交给收敛计划报冲突(退出码 3):这里只需保证不覆盖已有账号。
|
|
619
|
+
raise _RenameConflict(target)
|
|
620
|
+
old_name = account.name
|
|
621
|
+
del new.accounts[old_name]
|
|
622
|
+
account.name = target
|
|
623
|
+
new.accounts[target] = account
|
|
624
|
+
new.route_rules = [RouteRule(rule.path, target) if rule.account == old_name else rule
|
|
625
|
+
for rule in new.route_rules]
|
|
626
|
+
if new.route_default == old_name:
|
|
627
|
+
new.route_default = target
|
|
628
|
+
return old_name
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
class _RenameConflict(Exception):
|
|
632
|
+
"""rename 的新名字已被另一个账号使用,对应退出码 3。"""
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _account_process_env(name: str) -> Tuple[Optional[dict], int]:
|
|
636
|
+
"""mcp 与 login 共用:检查账号可用,返回 (该账号的进程环境, 0);不可用时返回 (None, 退出码)。"""
|
|
637
|
+
config, _ = load_config()
|
|
638
|
+
account = config.find(_checked_name(name))
|
|
639
|
+
if account is None:
|
|
640
|
+
error("account {!r} is not registered".format(name))
|
|
641
|
+
return None, accounts.EXIT_ERROR
|
|
642
|
+
directory = accounts.account_dir(config, account.name)
|
|
643
|
+
if not os.path.isdir(directory):
|
|
644
|
+
error("account directory does not exist: {}".format(directory))
|
|
645
|
+
return None, accounts.EXIT_ERROR
|
|
646
|
+
if account.identity == IDENTITY_DEFAULT and accounts.default_link_status(config, account) != "ok":
|
|
647
|
+
# 与启动命令相同的保护:~/.claude 不再指向账号目录时,直接运行会落到别的目录。
|
|
648
|
+
error("{} no longer points to {}; restore the link or run multi-claude migrate-default again".format(
|
|
649
|
+
accounts.default_dir(), directory))
|
|
650
|
+
return None, accounts.EXIT_ERROR
|
|
651
|
+
process_env = account_env(config, account, os.environ)
|
|
652
|
+
if shutil.which("claude", path=process_env.get("PATH")) is None:
|
|
653
|
+
error("claude not found in PATH")
|
|
654
|
+
return None, 127
|
|
655
|
+
return process_env, accounts.EXIT_OK
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def cmd_mcp(name: str, claude_args: List[str]) -> int:
|
|
659
|
+
"""以账号的身份环境运行 `claude mcp …`;不带固定参数(见 env.py 说明)。"""
|
|
660
|
+
process_env, code = _account_process_env(name)
|
|
661
|
+
if process_env is None:
|
|
662
|
+
return code
|
|
663
|
+
return subprocess.call(["claude", "mcp"] + claude_args, env=process_env)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# 只用于判断“账号名看起来像邮箱”,以便给登录页预填;不做完整的邮箱校验。
|
|
667
|
+
_EMAIL_LIKE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def cmd_login(name: str, claude_args: List[str]) -> int:
|
|
671
|
+
"""以账号的身份环境运行 `claude auth login …`(方案 feature-easier-onboarding §5.1.4)。
|
|
672
|
+
|
|
673
|
+
凭据由 Claude 自己写入该账号的钥匙串条目或凭据文件,本工具不读写凭据。
|
|
674
|
+
不带账号的固定参数:取多个值的选项会把 `auth login` 当成自己的值吞掉(同 mcp)。
|
|
675
|
+
"""
|
|
676
|
+
process_env, code = _account_process_env(name)
|
|
677
|
+
if process_env is None:
|
|
678
|
+
return code
|
|
679
|
+
login_args = list(claude_args)
|
|
680
|
+
if _EMAIL_LIKE.match(name) and not any(arg == "--email" or arg.startswith("--email=") for arg in login_args):
|
|
681
|
+
login_args += ["--email", name]
|
|
682
|
+
return subprocess.call(["claude", "auth", "login"] + login_args, env=process_env)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def cmd_overview() -> int:
|
|
686
|
+
"""不带参数时的输出:没有配置就给上手步骤,有配置就显示账号表。"""
|
|
687
|
+
if not platform.is_supported():
|
|
688
|
+
# 与其它命令一致:不支持的平台上先报平台错误,不给出走不通的上手步骤。
|
|
689
|
+
error("this platform is not supported yet (macOS and Linux only)")
|
|
690
|
+
return accounts.EXIT_ERROR
|
|
691
|
+
if not os.path.exists(config_path()):
|
|
692
|
+
print(_GETTING_STARTED, end="")
|
|
693
|
+
return accounts.EXIT_OK
|
|
694
|
+
code = main(["list"])
|
|
695
|
+
if code == accounts.EXIT_OK:
|
|
696
|
+
print("All commands: multi-claude --help")
|
|
697
|
+
return code
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
_GETTING_STARTED = """multi-claude runs several Claude Code accounts side by side, each with its own launcher.
|
|
701
|
+
|
|
702
|
+
Get started:
|
|
703
|
+
multi-claude add work create the account "work" and the launcher claude-work
|
|
704
|
+
multi-claude login work sign in to that account
|
|
705
|
+
claude-work start Claude Code with it
|
|
706
|
+
|
|
707
|
+
Already have a login in ~/.claude? It keeps working as plain `claude`;
|
|
708
|
+
`multi-claude migrate-default --help` explains how to turn it into an account.
|
|
709
|
+
All commands: multi-claude --help
|
|
710
|
+
"""
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _unknown_command(parser: argparse.ArgumentParser, argv: List[str]) -> Optional[str]:
|
|
714
|
+
"""第一个非选项参数不是子命令时,返回带建议的报错文字;否则返回 None 交给 argparse。"""
|
|
715
|
+
word = next((token for token in argv if not token.startswith("-")), None)
|
|
716
|
+
if word is None:
|
|
717
|
+
return None
|
|
718
|
+
names = [name for action in parser._actions if isinstance(action, argparse._SubParsersAction)
|
|
719
|
+
for name in action.choices]
|
|
720
|
+
if word in names:
|
|
721
|
+
return None
|
|
722
|
+
close = difflib.get_close_matches(word, names, n=1, cutoff=0.6)
|
|
723
|
+
if close:
|
|
724
|
+
return "unknown command {!r}; did you mean {!r}?".format(word, close[0])
|
|
725
|
+
return "unknown command {!r}; run multi-claude --help for the list".format(word)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def cmd_which(path: Optional[str]) -> int:
|
|
729
|
+
"""只读:显示 DIR 下运行 claude-auto 会用哪个账号;与 claude-auto 使用同一判定规则。"""
|
|
730
|
+
directory = os.path.abspath(os.path.expanduser(path)) if path else os.getcwd()
|
|
731
|
+
if not os.path.isdir(directory):
|
|
732
|
+
error("{} is not a directory".format(directory))
|
|
733
|
+
return accounts.EXIT_ERROR
|
|
734
|
+
config, _ = load_config()
|
|
735
|
+
account, rule = routes.resolve(config, directory)
|
|
736
|
+
if account is not None and rule is not None:
|
|
737
|
+
print("{} (route {})".format(account.name, rule.path))
|
|
738
|
+
elif account is not None:
|
|
739
|
+
print("{} (default)".format(account.name))
|
|
740
|
+
elif rule is not None or config.route_default is not None:
|
|
741
|
+
# 规则或默认账号引用了未登记的账号:只会出现在手工改过的配置里。
|
|
742
|
+
print("{} (not registered; run `multi-claude doctor`)".format(rule.account if rule else config.route_default))
|
|
743
|
+
return accounts.EXIT_ERROR
|
|
744
|
+
else:
|
|
745
|
+
print("claude (no route, no default)")
|
|
746
|
+
return accounts.EXIT_OK
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class _NotRegistered(Exception):
|
|
750
|
+
"""env / args / set 指定的账号未登记,对应退出码 1。args[1](可选)是附加在报错后的提示。"""
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _registered(config: Config, name: str) -> Account:
|
|
754
|
+
account = config.find(name)
|
|
755
|
+
if account is None:
|
|
756
|
+
raise _NotRegistered(name)
|
|
757
|
+
return account
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _env_target(args: argparse.Namespace) -> Tuple[Optional[str], List[Tuple[str, str]]]:
|
|
761
|
+
"""拆出 env 命令的作用对象(账号名,或 None 表示 --defaults)与 K=V 列表,并校验键和值。"""
|
|
762
|
+
items = list(args.items)
|
|
763
|
+
target = None
|
|
764
|
+
if not args.defaults:
|
|
765
|
+
if not items or "=" in items[0]:
|
|
766
|
+
raise UsageError("env needs an account name or --defaults")
|
|
767
|
+
target = _checked_name(items.pop(0))
|
|
768
|
+
assignments = []
|
|
769
|
+
for item in items:
|
|
770
|
+
key, sep, value = item.partition("=")
|
|
771
|
+
if not sep:
|
|
772
|
+
raise UsageError("expected K=V, got {!r}".format(item))
|
|
773
|
+
try:
|
|
774
|
+
validate_env_key(key)
|
|
775
|
+
validate_value(value)
|
|
776
|
+
except ValueError as exc:
|
|
777
|
+
raise UsageError(str(exc))
|
|
778
|
+
assignments.append((key, value))
|
|
779
|
+
if not assignments and not args.unset:
|
|
780
|
+
raise UsageError("env needs at least one K=V or --unset K")
|
|
781
|
+
return target, assignments
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _checked_args(values: List[str]) -> List[str]:
|
|
785
|
+
for value in values:
|
|
786
|
+
try:
|
|
787
|
+
validate_value(value)
|
|
788
|
+
except ValueError as exc:
|
|
789
|
+
raise UsageError(str(exc))
|
|
790
|
+
return list(values)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _hint_next_step(config: Config, name: str) -> None:
|
|
794
|
+
"""add 成功后,账号还没有登录时提示下一步;已登录或探测失败时不打扰。"""
|
|
795
|
+
account = config.find(name)
|
|
796
|
+
if account is None:
|
|
797
|
+
return
|
|
798
|
+
if identity.probe(account.identity, accounts.account_dir(config, account.name)) == identity.LOGIN_NONE:
|
|
799
|
+
info("next: multi-claude login {0} (then start claude-{0})".format(account.name))
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _has_account_option(args: argparse.Namespace) -> bool:
|
|
803
|
+
return bool(args.proxy is not None or args.shared_to is not None or args.no_shared
|
|
804
|
+
or args.shared_exclude or args.shared_include)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _looks_like_path(value: str) -> bool:
|
|
808
|
+
return "/" in value or value.startswith(("~", "."))
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# add/set 里取一个值的选项:判断位置参数时要跳过它们的值。
|
|
812
|
+
_VALUE_OPTIONS = ("--proxy", "--shared-exclude", "--shared-include")
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _misplaced_account_name(argv: List[str]) -> Optional[str]:
|
|
816
|
+
"""`add --shared work` 这类把账号名写在选项后面的输入:argparse 会把 work 当成 --shared 的目录,
|
|
817
|
+
再报“缺少 name”,看不出原因。只在“除这个词外没有任何位置参数”(argparse 本来就会报错)时接管报错。
|
|
818
|
+
"""
|
|
819
|
+
# --version / --help 会让 argparse 直接输出后正常退出;--no-shared 与 --shared 同时出现时
|
|
820
|
+
# argparse 会报互斥错误,那条更准确。这几种情况都不接管。
|
|
821
|
+
if any(token in ("--version", "-h", "--help", "--no-shared") for token in argv):
|
|
822
|
+
return None
|
|
823
|
+
command_index = next((i for i, token in enumerate(argv) if not token.startswith("-")), None)
|
|
824
|
+
if command_index is None or argv[command_index] not in ("add", "set"):
|
|
825
|
+
return None
|
|
826
|
+
rest = argv[command_index + 1:]
|
|
827
|
+
candidate = None
|
|
828
|
+
positionals = []
|
|
829
|
+
index = 0
|
|
830
|
+
while index < len(rest):
|
|
831
|
+
token = rest[index]
|
|
832
|
+
if token in _VALUE_OPTIONS:
|
|
833
|
+
index += 2
|
|
834
|
+
continue
|
|
835
|
+
if token == "--shared":
|
|
836
|
+
following = rest[index + 1] if index + 1 < len(rest) else None
|
|
837
|
+
if following is not None and not following.startswith("-") and not _looks_like_path(following):
|
|
838
|
+
candidate = following
|
|
839
|
+
index += 2 if following is not None and not following.startswith("-") else 1
|
|
840
|
+
continue
|
|
841
|
+
if not token.startswith("-"):
|
|
842
|
+
positionals.append(token)
|
|
843
|
+
index += 1
|
|
844
|
+
if candidate is None or positionals:
|
|
845
|
+
return None
|
|
846
|
+
return "put the account name before the options, e.g. multi-claude {} {} --shared".format(
|
|
847
|
+
argv[command_index], candidate)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _checked_shared_dir(value: str) -> str:
|
|
851
|
+
"""--shared DIR 的值必须像路径,免得把 `add --shared work` 里的账号名之类的词当成目录。"""
|
|
852
|
+
if not _looks_like_path(value):
|
|
853
|
+
raise UsageError("--shared DIR expects a directory path, e.g. --shared ~/claude-shared; got {!r}".format(value))
|
|
854
|
+
return value
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _hint_empty_shared_dir(config: Config, name: str) -> None:
|
|
858
|
+
"""开启共享后,共享目录里一个默认条目都没有时,说明该往哪里放什么(工具不替用户创建)。"""
|
|
859
|
+
account = config.find(name)
|
|
860
|
+
if account is None or not account.shared or not config.shared_dir:
|
|
861
|
+
return
|
|
862
|
+
root = expand(config.shared_dir)
|
|
863
|
+
if any(os.path.lexists(os.path.join(root, item)) for item in config.shared_items):
|
|
864
|
+
return
|
|
865
|
+
info("note: {} has none of {} yet; put what every account should share there, then run "
|
|
866
|
+
"multi-claude apply".format(config.shared_dir, ", ".join(config.shared_items)))
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _hint_bin_on_path(config: Config, name: str) -> None:
|
|
870
|
+
"""启动命令目录不在 PATH 上时,`claude-NAME` 会找不到;给出按当前 shell 可直接粘贴的命令。"""
|
|
871
|
+
bin_dir = expand(config.bin_dir)
|
|
872
|
+
entries = [os.path.abspath(os.path.expanduser(entry)) for entry in os.environ.get("PATH", "").split(os.pathsep)
|
|
873
|
+
if entry]
|
|
874
|
+
if bin_dir in entries:
|
|
875
|
+
return
|
|
876
|
+
account = config.find(name)
|
|
877
|
+
launcher_name = "claude-" + (account.name if account is not None else name)
|
|
878
|
+
info("note: {} is not on PATH, so {} will not be found; {}".format(
|
|
879
|
+
bin_dir, launcher_name, shellpath.path_hint(bin_dir, os.environ.get("SHELL"), py_platform.system())))
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _warn_if_not_logged_in(directory: str) -> None:
|
|
883
|
+
"""add 登记已有目录时的只读登录探测(方案 §5.1.9):只提示,不改变退出码。
|
|
884
|
+
|
|
885
|
+
macOS 上登录绑定在路径字符串上:以前用 `~/x` 与 `/Users/me/x` 两种写法登录过,结果并不相同。
|
|
886
|
+
"""
|
|
887
|
+
if sys.platform != "darwin":
|
|
888
|
+
return
|
|
889
|
+
if "CLAUDE_CODE_CUSTOM_OAUTH_URL" in os.environ:
|
|
890
|
+
warn("CLAUDE_CODE_CUSTOM_OAUTH_URL is set; the login check assumes the production service "
|
|
891
|
+
"and may be wrong")
|
|
892
|
+
if identity.probe(IDENTITY_DIR, directory) == identity.LOGIN_NONE:
|
|
893
|
+
warn("no login found for the path {}; the first launch will ask you to log in. If you logged in "
|
|
894
|
+
"with a different spelling of this path before, keep using that spelling".format(directory))
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _load_apply_file(path: str, old: Config) -> Config:
|
|
898
|
+
"""读取 apply -f 的文件。
|
|
899
|
+
|
|
900
|
+
managed_links、identity 与 dir 是工具内部状态,已登记账号一律沿用当前配置里的值;
|
|
901
|
+
dir 与 default 之间的转换只能由 migrate-default 完成(方案 §5.1.6)。文件中未登记、
|
|
902
|
+
却写了 identity=default 的账号不登记:直接建目录会得到一个没有登录的空账号。
|
|
903
|
+
"""
|
|
904
|
+
try:
|
|
905
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
906
|
+
raw = handle.read()
|
|
907
|
+
except OSError as exc:
|
|
908
|
+
raise ConfigError("cannot read {}: {}".format(path, exc))
|
|
909
|
+
new = parse_config(raw, path)
|
|
910
|
+
skipped = set()
|
|
911
|
+
for name, account in list(new.accounts.items()):
|
|
912
|
+
current = old.find(account.name)
|
|
913
|
+
if current is not None:
|
|
914
|
+
account.identity = current.identity
|
|
915
|
+
account.managed_links = list(current.managed_links)
|
|
916
|
+
# 目录名同样是内部状态:改了它,macOS 上按路径绑定的登录就会丢。
|
|
917
|
+
account.dir = current.dir
|
|
918
|
+
continue
|
|
919
|
+
if account.identity == IDENTITY_DEFAULT:
|
|
920
|
+
info("skip account {0} (identity is default; run multi-claude migrate-default {0} to create it, "
|
|
921
|
+
"then set its proxy/env/args/shared again)".format(account.name))
|
|
922
|
+
del new.accounts[name]
|
|
923
|
+
skipped.add(name.casefold())
|
|
924
|
+
continue
|
|
925
|
+
account.managed_links = []
|
|
926
|
+
# 引用被跳过账号的路由一并跳过:否则整条 apply 会因“规则引用未登记账号”判冲突,
|
|
927
|
+
# 带路由的配置文件就无法在新机器上使用。迁移完成后由用户重新设置。
|
|
928
|
+
for rule in [rule for rule in new.route_rules if rule.account.casefold() in skipped]:
|
|
929
|
+
info("skip route {} (account {} is skipped; set it again with `multi-claude route {} {}`)".format(
|
|
930
|
+
rule.path, rule.account, rule.path, rule.account))
|
|
931
|
+
new.route_rules.remove(rule)
|
|
932
|
+
if new.route_default is not None and new.route_default.casefold() in skipped:
|
|
933
|
+
info("skip default route (account {0} is skipped; set it again with "
|
|
934
|
+
"`multi-claude route --default {0}`)".format(new.route_default))
|
|
935
|
+
new.route_default = None
|
|
936
|
+
return new
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _checked_name(name: str) -> str:
|
|
940
|
+
try:
|
|
941
|
+
validate_name(name)
|
|
942
|
+
except ValueError as exc:
|
|
943
|
+
raise UsageError(str(exc))
|
|
944
|
+
return name
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _checked_proxy(value: str) -> str:
|
|
948
|
+
try:
|
|
949
|
+
return normalize_proxy(value)
|
|
950
|
+
except ValueError as exc:
|
|
951
|
+
raise UsageError(str(exc))
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def cmd_list(verbose: bool = False, as_json: bool = False, names_only: bool = False) -> int:
|
|
955
|
+
config, exists = load_config()
|
|
956
|
+
if names_only:
|
|
957
|
+
# 给补全脚本用:只输出名称,不输出任何告警。
|
|
958
|
+
for name in config.accounts if exists else []:
|
|
959
|
+
print(name)
|
|
960
|
+
return accounts.EXIT_OK
|
|
961
|
+
migrate.warn_isolation_env()
|
|
962
|
+
for variable in _UNMANAGED_PROXY_VARS:
|
|
963
|
+
if variable in os.environ:
|
|
964
|
+
warn("{} is set; launchers do not change it, even with proxy off".format(variable))
|
|
965
|
+
if as_json:
|
|
966
|
+
_print_json(_list_data(config, exists))
|
|
967
|
+
if exists:
|
|
968
|
+
accounts.warn_launch_settings(config, None, note_invalid=True)
|
|
969
|
+
return accounts.EXIT_OK
|
|
970
|
+
if not exists:
|
|
971
|
+
info("no configuration yet at {}; run `multi-claude add NAME` to start".format(
|
|
972
|
+
os.path.join(platform.state_dir(), "config.json")))
|
|
973
|
+
return accounts.EXIT_OK
|
|
974
|
+
if not verbose:
|
|
975
|
+
return _print_brief_list(config)
|
|
976
|
+
print("root: {}".format(expand(config.root)))
|
|
977
|
+
print("bin_dir: {}".format(expand(config.bin_dir)))
|
|
978
|
+
print("shared.dir: {}".format(expand(config.shared_dir) if config.shared_dir else "(not set)"))
|
|
979
|
+
if not config.accounts:
|
|
980
|
+
print("no accounts registered")
|
|
981
|
+
return accounts.EXIT_OK
|
|
982
|
+
if "CLAUDE_CODE_CUSTOM_OAUTH_URL" in os.environ:
|
|
983
|
+
warn("CLAUDE_CODE_CUSTOM_OAUTH_URL is set; LOGIN assumes the production service and may be wrong")
|
|
984
|
+
rows = [("NAME", "IDENTITY", "DIR", "PROXY", "SHARED", "LAUNCHER", "LOGIN", "LINK")]
|
|
985
|
+
details = []
|
|
986
|
+
default_account = None
|
|
987
|
+
for entry in _account_entries(config):
|
|
988
|
+
if entry["identity"] == IDENTITY_DEFAULT:
|
|
989
|
+
default_account = entry["name"]
|
|
990
|
+
rows.append((entry["name"], entry["identity"], "ok" if entry["dir_exists"] else "missing-dir",
|
|
991
|
+
entry["proxy"], _shared_cell(entry), entry["launcher"], entry["login"],
|
|
992
|
+
entry["link"] or "-"))
|
|
993
|
+
if verbose:
|
|
994
|
+
details.append(" {}: keychain service {!r}, credentials file {}".format(
|
|
995
|
+
entry["name"], entry["keychain_service"], entry["credentials_file"]))
|
|
996
|
+
_print_table(rows)
|
|
997
|
+
_print_routes(config)
|
|
998
|
+
if default_account is not None:
|
|
999
|
+
print("note: {} keeps its global state in {} (not moved by migrate-default)".format(
|
|
1000
|
+
default_account, os.path.join(os.path.expanduser("~"), ".claude.json")))
|
|
1001
|
+
for line in details:
|
|
1002
|
+
print(line)
|
|
1003
|
+
accounts.warn_launch_settings(config, None, note_invalid=True)
|
|
1004
|
+
return accounts.EXIT_OK
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _print_brief_list(config: Config) -> int:
|
|
1008
|
+
"""list 的默认输出:只放新用户关心的列(方案 feature-clearer-help §5.1.2);完整信息见 --verbose。"""
|
|
1009
|
+
if not config.accounts:
|
|
1010
|
+
print("no accounts registered; run `multi-claude add NAME` to start")
|
|
1011
|
+
return accounts.EXIT_OK
|
|
1012
|
+
if "CLAUDE_CODE_CUSTOM_OAUTH_URL" in os.environ:
|
|
1013
|
+
# 与完整输出相同:LOGIN 与由它得出的 STATUS 都按正式服务探测,这时可能不准。
|
|
1014
|
+
warn("CLAUDE_CODE_CUSTOM_OAUTH_URL is set; LOGIN assumes the production service and may be wrong")
|
|
1015
|
+
now = datetime.now(timezone.utc)
|
|
1016
|
+
rows = [("NAME", "LOGIN", "PROXY", "SHARED", "5H", "7D", "STATUS")]
|
|
1017
|
+
has_problem = False
|
|
1018
|
+
for entry in _account_entries(config):
|
|
1019
|
+
report = usage.read_account_usage(config, config.find(entry["name"]), now)
|
|
1020
|
+
status = _status_cell(entry)
|
|
1021
|
+
has_problem = has_problem or status != "ok"
|
|
1022
|
+
rows.append((entry["name"], entry["login"], entry["proxy"], _shared_cell(entry),
|
|
1023
|
+
_window_cell(report.five_hour, now), _window_cell(report.seven_day, now), status))
|
|
1024
|
+
_print_table(rows)
|
|
1025
|
+
_print_routes(config)
|
|
1026
|
+
if has_problem:
|
|
1027
|
+
print("run multi-claude doctor for details")
|
|
1028
|
+
accounts.warn_launch_settings(config, None, note_invalid=True)
|
|
1029
|
+
return accounts.EXIT_OK
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _status_cell(entry: dict) -> str:
|
|
1033
|
+
"""把账号的各项问题合成一格;顺序固定,便于用户和测试对照。"""
|
|
1034
|
+
problems = []
|
|
1035
|
+
if not entry["dir_exists"]:
|
|
1036
|
+
problems.append("directory missing")
|
|
1037
|
+
if entry["launcher"] != "ok":
|
|
1038
|
+
problems.append("launcher {}".format(entry["launcher"]))
|
|
1039
|
+
if entry["link"] not in (None, "ok"):
|
|
1040
|
+
problems.append("link {}".format(entry["link"]))
|
|
1041
|
+
if entry["login"] == identity.LOGIN_NONE:
|
|
1042
|
+
problems.append("not logged in")
|
|
1043
|
+
return ", ".join(problems) or "ok"
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _print_table(rows: List[tuple]) -> None:
|
|
1047
|
+
widths = [max(len(row[index]) for row in rows) for index in range(len(rows[0]))]
|
|
1048
|
+
for row in rows:
|
|
1049
|
+
print(" ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip())
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _print_routes(config: Config) -> None:
|
|
1053
|
+
if not config.routes_enabled:
|
|
1054
|
+
return
|
|
1055
|
+
print("routes:")
|
|
1056
|
+
for rule in config.route_rules:
|
|
1057
|
+
print(" {} -> {}".format(rule.path, rule.account))
|
|
1058
|
+
if config.route_default is not None:
|
|
1059
|
+
print(" (default) -> {}".format(config.route_default))
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _shared_cell(entry: dict) -> str:
|
|
1063
|
+
if not entry["shared"]:
|
|
1064
|
+
return "no"
|
|
1065
|
+
if entry["shared_exclude"]:
|
|
1066
|
+
return "yes (not: {})".format(",".join(entry["shared_exclude"]))
|
|
1067
|
+
return "yes"
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _account_entries(config: Config, with_usage: bool = False) -> List[dict]:
|
|
1071
|
+
"""list 的表格与 JSON 共用的每账号数据;表格的列取自这里,保证两种输出口径一致。"""
|
|
1072
|
+
now = datetime.now(timezone.utc)
|
|
1073
|
+
entries = []
|
|
1074
|
+
for name, account in config.accounts.items():
|
|
1075
|
+
directory = accounts.account_dir(config, name)
|
|
1076
|
+
is_default = account.identity == IDENTITY_DEFAULT
|
|
1077
|
+
entry = {
|
|
1078
|
+
"name": name,
|
|
1079
|
+
"identity": account.identity,
|
|
1080
|
+
"dir": directory,
|
|
1081
|
+
"dir_exists": os.path.isdir(directory),
|
|
1082
|
+
"proxy": account.proxy,
|
|
1083
|
+
"shared": account.shared,
|
|
1084
|
+
"shared_exclude": list(account.shared_exclude),
|
|
1085
|
+
"launcher": accounts.launcher_status(config, name),
|
|
1086
|
+
"login": identity.probe(account.identity, directory),
|
|
1087
|
+
"link": accounts.default_link_status(config, account) if is_default else None,
|
|
1088
|
+
"keychain_service": identity.service_name(account.identity, directory),
|
|
1089
|
+
"credentials_file": identity.credentials_file(directory),
|
|
1090
|
+
}
|
|
1091
|
+
if with_usage:
|
|
1092
|
+
entry["usage"] = usage.report_to_dict(usage.read_account_usage(config, account, now), now)
|
|
1093
|
+
entries.append(entry)
|
|
1094
|
+
return entries
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _list_data(config: Config, exists: bool) -> dict:
|
|
1098
|
+
if not exists:
|
|
1099
|
+
return {"schema_version": 1, "configured": False, "accounts": [],
|
|
1100
|
+
"routes": {"default": None, "rules": []}}
|
|
1101
|
+
return {
|
|
1102
|
+
"schema_version": 1,
|
|
1103
|
+
"configured": True,
|
|
1104
|
+
"root": expand(config.root),
|
|
1105
|
+
"bin_dir": expand(config.bin_dir),
|
|
1106
|
+
"shared_dir": expand(config.shared_dir) if config.shared_dir else None,
|
|
1107
|
+
"accounts": _account_entries(config, with_usage=True),
|
|
1108
|
+
"routes": {"default": config.route_default,
|
|
1109
|
+
"rules": [{"path": rule.path, "account": rule.account, "exists": os.path.isdir(expand(rule.path))}
|
|
1110
|
+
for rule in config.route_rules]},
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _print_json(data: dict) -> None:
|
|
1115
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
# 固定英文缩写,不用 %a:%a 随系统语言变化,表格对不齐,也不利于脚本解析。
|
|
1119
|
+
_WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def cmd_usage(name: Optional[str], as_json: bool) -> int:
|
|
1123
|
+
"""显示各账号最近一次已知的用量。数据来自 Claude Code 自己的缓存,不读凭据、不联网。"""
|
|
1124
|
+
config, exists = load_config()
|
|
1125
|
+
selected = list(config.accounts.values()) if exists else []
|
|
1126
|
+
if name is not None:
|
|
1127
|
+
account = config.find(_checked_name(name)) if exists else None
|
|
1128
|
+
if account is None:
|
|
1129
|
+
error("account {!r} is not registered".format(name))
|
|
1130
|
+
return accounts.EXIT_ERROR
|
|
1131
|
+
selected = [account]
|
|
1132
|
+
now = datetime.now(timezone.utc)
|
|
1133
|
+
reports = []
|
|
1134
|
+
for account in selected:
|
|
1135
|
+
reports.append((account.name, usage.read_account_usage(config, account, now)))
|
|
1136
|
+
if as_json:
|
|
1137
|
+
data = {"schema_version": 1, "configured": exists,
|
|
1138
|
+
"accounts": [{"name": account_name, "usage": usage.report_to_dict(report, now)}
|
|
1139
|
+
for account_name, report in reports]}
|
|
1140
|
+
_print_json(data)
|
|
1141
|
+
return accounts.EXIT_OK
|
|
1142
|
+
if not exists:
|
|
1143
|
+
info("no configuration yet at {}; run `multi-claude add NAME` to start".format(
|
|
1144
|
+
os.path.join(platform.state_dir(), "config.json")))
|
|
1145
|
+
return accounts.EXIT_OK
|
|
1146
|
+
rows = [("NAME", "5H", "7D", "UPDATED")]
|
|
1147
|
+
for account_name, report in reports:
|
|
1148
|
+
rows.append((account_name, _window_cell(report.five_hour, now), _window_cell(report.seven_day, now),
|
|
1149
|
+
_updated_cell(account_name, report, now)))
|
|
1150
|
+
widths = [max(len(row[index]) for row in rows) for index in range(len(rows[0]))]
|
|
1151
|
+
for row in rows:
|
|
1152
|
+
print(" ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip())
|
|
1153
|
+
print("Values come from Claude Code's own cache (.claude.json) or the statusline hook, and may be out of date; "
|
|
1154
|
+
"multi-claude never reads credentials.")
|
|
1155
|
+
return accounts.EXIT_OK
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _window_cell(window: Optional[usage.UsageWindow], now: datetime) -> str:
|
|
1159
|
+
if window is None:
|
|
1160
|
+
return "-"
|
|
1161
|
+
if usage.window_is_reset(window, now):
|
|
1162
|
+
return "reset"
|
|
1163
|
+
text = "{}%".format(int(round(window.percent)))
|
|
1164
|
+
if window.resets_at is None:
|
|
1165
|
+
return text
|
|
1166
|
+
local = window.resets_at.astimezone()
|
|
1167
|
+
when = local.strftime("%H:%M") if window.resets_at - now <= _ONE_DAY else _WEEKDAYS[local.weekday()]
|
|
1168
|
+
return "{} {}".format(text, when)
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _updated_cell(name: str, report: usage.UsageReport, now: datetime) -> str:
|
|
1172
|
+
if report.status == usage.STATUS_NO_DATA:
|
|
1173
|
+
return "no data (start claude-{} once)".format(name)
|
|
1174
|
+
if report.status == usage.STATUS_UNREADABLE:
|
|
1175
|
+
return "unreadable"
|
|
1176
|
+
if report.status == usage.STATUS_OTHER_ACCOUNT:
|
|
1177
|
+
return "cache belongs to an earlier login"
|
|
1178
|
+
age = _format_age(now - report.fetched_at)
|
|
1179
|
+
if report.status == usage.STATUS_STALE:
|
|
1180
|
+
age += " (stale)"
|
|
1181
|
+
return age + (" (statusline)" if report.source == usage.SOURCE_STATUSLINE else "")
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _format_age(delta) -> str:
|
|
1185
|
+
minutes = max(0, int(delta.total_seconds() // 60))
|
|
1186
|
+
if minutes < 60:
|
|
1187
|
+
return "{}m ago".format(minutes)
|
|
1188
|
+
if minutes < 48 * 60:
|
|
1189
|
+
return "{}h ago".format(minutes // 60)
|
|
1190
|
+
return "{}d ago".format(minutes // (24 * 60))
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def cmd_handoff(target_name: str, source_name: Optional[str], session_id: Optional[str], force: bool,
|
|
1194
|
+
dry_run: bool) -> int:
|
|
1195
|
+
"""把一条会话复制到目标账号并打印续聊命令。目标已有不同内容的同 ID 会话时退出 3、不改任何文件。"""
|
|
1196
|
+
config, exists = load_config()
|
|
1197
|
+
if not exists:
|
|
1198
|
+
raise _NotRegistered(target_name)
|
|
1199
|
+
target = _registered(config, _checked_name(target_name))
|
|
1200
|
+
environ = os.environ
|
|
1201
|
+
in_session = bool(environ.get("CLAUDECODE"))
|
|
1202
|
+
# 当前 Claude 会话所属账号;不在会话里时为 None。
|
|
1203
|
+
session_account = statusline.account_for(config, environ.get("CLAUDE_CONFIG_DIR")) if in_session else None
|
|
1204
|
+
if source_name is not None:
|
|
1205
|
+
source = _registered(config, _checked_name(source_name))
|
|
1206
|
+
elif in_session:
|
|
1207
|
+
# 在会话里用 ! 运行:Claude 把自己的 CLAUDE_CONFIG_DIR 传给子进程,default 身份账号则没有这个变量。
|
|
1208
|
+
source = session_account
|
|
1209
|
+
if source is None:
|
|
1210
|
+
raise UsageError("cannot tell which account this Claude session belongs to; use --from NAME")
|
|
1211
|
+
else:
|
|
1212
|
+
# 普通终端里没有 CLAUDE_CONFIG_DIR 不能说明用的是 default 账号,不猜。
|
|
1213
|
+
raise UsageError("--from is required outside a Claude session")
|
|
1214
|
+
if source.name == target.name:
|
|
1215
|
+
raise UsageError("the session is already in account {!r}".format(target.name))
|
|
1216
|
+
if session_id is not None and not sessions.SESSION_ID_PATTERN.match(session_id):
|
|
1217
|
+
raise UsageError("invalid session id {!r}".format(session_id))
|
|
1218
|
+
# 只有来源就是当前会话所属账号时,CLAUDE_CODE_SESSION_ID 才指向来源里的会话。
|
|
1219
|
+
current = environ.get("CLAUDE_CODE_SESSION_ID") if session_account is not None and session_account is source \
|
|
1220
|
+
else None
|
|
1221
|
+
target_dir = accounts.account_dir(config, target.name)
|
|
1222
|
+
if not os.path.isdir(target_dir):
|
|
1223
|
+
error("account directory of {} does not exist".format(target.name), phase="handoff", path=target_dir)
|
|
1224
|
+
return accounts.EXIT_ERROR
|
|
1225
|
+
try:
|
|
1226
|
+
handoff = sessions.plan(accounts.account_dir(config, source.name), target_dir,
|
|
1227
|
+
sessions.cwd_variants(dict(environ)), session_id, current)
|
|
1228
|
+
except sessions.HandoffError as exc:
|
|
1229
|
+
error(str(exc), phase="handoff")
|
|
1230
|
+
return accounts.EXIT_ERROR
|
|
1231
|
+
target_file = os.path.join(handoff.target_dir, handoff.session_id + ".jsonl")
|
|
1232
|
+
if handoff.state == sessions.CONFLICT and not force:
|
|
1233
|
+
error("{} already has a different copy of session {}; use --force to back it up and replace it".format(
|
|
1234
|
+
target.name, handoff.session_id), phase="handoff", path=target_file)
|
|
1235
|
+
return accounts.EXIT_CONFLICT
|
|
1236
|
+
# cd 到找到会话时用的写法(物理路径或软链写法的 $PWD),目标账号才会按同一目录名找到会话。
|
|
1237
|
+
resume = "cd {} && claude-{} --resume {}".format(shlex.quote(handoff.cwd), target.name, handoff.session_id)
|
|
1238
|
+
if handoff.state == sessions.UNCHANGED:
|
|
1239
|
+
info("handoff: session {} is already in {} (unchanged)".format(handoff.session_id, target.name))
|
|
1240
|
+
elif dry_run:
|
|
1241
|
+
info("handoff: would copy session {} from {} to {} ({}){}".format(
|
|
1242
|
+
handoff.session_id, source.name, target.name, handoff.target_dir,
|
|
1243
|
+
", backing up the existing copy" if handoff.state == sessions.CONFLICT else ""))
|
|
1244
|
+
return accounts.EXIT_OK
|
|
1245
|
+
else:
|
|
1246
|
+
backups = sessions.execute(handoff, force)
|
|
1247
|
+
info("handoff: copied session {} from {} to {} ({})".format(
|
|
1248
|
+
handoff.session_id, source.name, target.name, handoff.target_dir))
|
|
1249
|
+
for backup in backups:
|
|
1250
|
+
info("backup: {}".format(backup))
|
|
1251
|
+
info("continue with: {}".format(resume))
|
|
1252
|
+
return accounts.EXIT_OK
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def cmd_statusline(action: str, path: str, dry_run: bool) -> int:
|
|
1256
|
+
"""包装或还原设置文件中的 statusLine 命令。文件不合法时退出 1 且不改动;其它写入错误由 main 统一处理。"""
|
|
1257
|
+
try:
|
|
1258
|
+
if action == "install":
|
|
1259
|
+
# 包装命令里写的是绝对路径:Claude 执行 statusLine 时的 PATH 不一定含 multi-claude 所在目录。
|
|
1260
|
+
executable = shutil.which(statusline.EXECUTABLE)
|
|
1261
|
+
if executable is None:
|
|
1262
|
+
error("{} is not on PATH; the wrapped statusLine needs its full path".format(statusline.EXECUTABLE),
|
|
1263
|
+
phase="statusline")
|
|
1264
|
+
return accounts.EXIT_ERROR
|
|
1265
|
+
result = statusline.install(path, executable, dry_run=dry_run)
|
|
1266
|
+
else:
|
|
1267
|
+
result = statusline.uninstall(path, dry_run=dry_run)
|
|
1268
|
+
except statusline.StatuslineError as exc:
|
|
1269
|
+
error(str(exc), phase="statusline")
|
|
1270
|
+
return accounts.EXIT_ERROR
|
|
1271
|
+
changed = result.state in (statusline.WRAPPED, statusline.REWRAPPED, statusline.RESTORED)
|
|
1272
|
+
if dry_run and changed:
|
|
1273
|
+
info("statusline: would be {} {}".format(result.state, result.target))
|
|
1274
|
+
else:
|
|
1275
|
+
info("statusline: {} {}".format(result.state, result.target))
|
|
1276
|
+
if result.backup:
|
|
1277
|
+
info("backup: {}".format(result.backup))
|
|
1278
|
+
if changed:
|
|
1279
|
+
info("command: {}".format(result.command))
|
|
1280
|
+
if not dry_run and result.state in (statusline.WRAPPED, statusline.REWRAPPED):
|
|
1281
|
+
# 实测(2026-10-01):包装前就在运行的会话一直不产生快照,新会话收到第一次回复后才有。
|
|
1282
|
+
info("note: Claude sessions that are already running keep the old status line. Usage is recorded from "
|
|
1283
|
+
"sessions started after `install`, once they have had their first reply")
|
|
1284
|
+
return accounts.EXIT_OK
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def cmd_doctor(as_json: bool, verbose: bool) -> int:
|
|
1288
|
+
try:
|
|
1289
|
+
checks = doctor.run_checks()
|
|
1290
|
+
except OSError as exc:
|
|
1291
|
+
# 意外的读写错误也记成一条检查结果,保证 --json 在任何情况下都输出合法 JSON。
|
|
1292
|
+
checks = [doctor.Check("doctor", doctor.LEVEL_ERROR, "-", "{}: {}".format(type(exc).__name__, exc))]
|
|
1293
|
+
if as_json:
|
|
1294
|
+
_print_json(doctor.to_dict(checks))
|
|
1295
|
+
else:
|
|
1296
|
+
for line in doctor.format_text(checks, verbose):
|
|
1297
|
+
print(line)
|
|
1298
|
+
return doctor.exit_code(checks)
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
if __name__ == "__main__":
|
|
1302
|
+
sys.exit(main())
|