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/config.py ADDED
@@ -0,0 +1,488 @@
1
+ """配置模型:读写 config.json,校验账号名、代理值、额外环境变量与启动参数。
2
+
3
+ config.json 是唯一的权威源,启动命令、共享链接都是从它推导出来的产物(方案 §5.1.1)。
4
+ 本模块只负责数据本身,不创建或删除任何账号文件。
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import urllib.parse
11
+ from typing import Dict, List, NamedTuple, Optional, Tuple
12
+
13
+ from . import platform
14
+ from .fsutil import atomic_write, expand
15
+
16
+ CONFIG_VERSION = 1
17
+ DEFAULT_SHARED_ITEMS = ["agents", "commands", "skills", "CLAUDE.md"]
18
+ # add/set --shared 在未设置共享目录时使用的默认值;原样写进配置,使用时再展开 ~。
19
+ DEFAULT_SHARED_DIR = "~/.claude-shared"
20
+ # 账号私有的状态:凭据、全局状态、会话与历史、运行时状态。共享出去会让一个账号的登录或会话
21
+ # 被所有账号读写,所以不允许出现在 shared.items 里(方案 feature-shared-exclude §5.1.4)。
22
+ UNSHAREABLE_ITEMS = (".credentials.json", ".claude.json", "settings.local.json", "projects", "history.jsonl",
23
+ "file-history", "sessions", "session-env", "shell-snapshots", "todos")
24
+
25
+
26
+ def is_unshareable(item: str) -> bool:
27
+ # macOS 默认文件系统不区分大小写,`Projects` 指向的就是 `projects`。
28
+ return item.casefold() in {name.casefold() for name in UNSHAREABLE_ITEMS}
29
+
30
+ # 以字母或数字开头:不会被当成命令行选项,也不会以 `.` 开头与 `.migration` 等目录混淆。
31
+ NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@+-]{0,63}$")
32
+
33
+ PROXY_INHERIT = "inherit"
34
+ PROXY_OFF = "off"
35
+ # Claude Code 官方文档明确写着不支持 SOCKS 代理(方案 §3 依据 5),所以只接受 http/https。
36
+ PROXY_SCHEMES = ("http", "https")
37
+ PROXY_VARS = ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY",
38
+ "https_proxy", "http_proxy", "all_proxy", "no_proxy")
39
+
40
+ # 账号身份(方案 §5.1.4、§5.1.6):
41
+ # - dir:启动命令设置 CLAUDE_CONFIG_DIR=<账号目录>,macOS 登录绑定在这串路径上;
42
+ # - default:由 migrate-default 产生,启动命令不设 CLAUDE_CONFIG_DIR,沿用 ~/.claude、
43
+ # ~/.claude.json 和不带后缀的钥匙串条目,与直接运行 `claude` 完全一致。
44
+ IDENTITY_DIR = "dir"
45
+ IDENTITY_DEFAULT = "default"
46
+ IDENTITIES = (IDENTITY_DIR, IDENTITY_DEFAULT)
47
+
48
+ ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
49
+
50
+ # 改变账号身份的变量:写进启动命令会静默改变登录绑定(方案 §5.1.5)。
51
+ _IDENTITY_KEYS = ("CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CODE_CUSTOM_OAUTH_URL",
52
+ "HOME", "USER")
53
+ # 官方 env-vars 页列出的凭据变量;启动命令是 0755 明文文件,写进去即泄露。
54
+ _CREDENTIAL_KEYS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
55
+ "CLAUDE_CODE_OAUTH_REFRESH_TOKEN", "ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_AWS_API_KEY",
56
+ "ANTHROPIC_FOUNDRY_AUTH_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "MCP_CLIENT_SECRET")
57
+ # 按下划线分段后整段相等才算凭据:`MAX_THINKING_TOKENS` 的 `TOKENS` 不能误伤。
58
+ _CREDENTIAL_SEGMENTS = ("TOKEN", "SECRET", "PASSWORD", "PASSPHRASE")
59
+
60
+
61
+ class ConfigError(Exception):
62
+ """配置文件内容不合法,对应退出码 1。"""
63
+
64
+
65
+ class RouteRule(NamedTuple):
66
+ """按目录选账号的一条规则:当前目录位于 path 之下时用 account(方案 feature-directory-routing §5.1.1)。"""
67
+ # 用户写法(如 "~/work");比较时经 expand 展开,claude-auto 启动时再求物理路径
68
+ path: str
69
+ account: str
70
+
71
+
72
+ class Account(object):
73
+ """一个已登记的账号。
74
+
75
+ managed_links 与 identity 是工具内部状态:前者记录本工具在该账号目录里建立过的共享链接,
76
+ 关闭共享时只删除这些链接;后者只能由 migrate-default 改为 default,apply -f 不接受外部改写。
77
+ env / args 是启动命令额外设置的环境变量和固定参数,与全局 defaults 合并后生效。
78
+ """
79
+
80
+ def __init__(self, name: str, proxy: str = PROXY_INHERIT, shared: bool = False,
81
+ managed_links: Optional[List[str]] = None, identity: str = IDENTITY_DIR,
82
+ env: Optional[Dict[str, str]] = None, args: Optional[List[str]] = None,
83
+ dir: Optional[str] = None, shared_exclude: Optional[List[str]] = None) -> None:
84
+ self.name = name
85
+ # 账号目录在根目录下的名字。缺省等于账号名;rename 只改 name、不改 dir,
86
+ # 目录路径不变,macOS 上按路径绑定的登录就不会丢(方案 feature-rename-mcp §3)。
87
+ self.dir = dir or name
88
+ self.proxy = proxy
89
+ self.shared = shared
90
+ self.managed_links = list(managed_links or [])
91
+ # 共享开启时仍不链接的项(用户配置,apply -f 按文件生效);本工具建过的这些项的链接会被删除。
92
+ self.shared_exclude = list(shared_exclude or [])
93
+ self.identity = identity
94
+ self.env = dict(env or {})
95
+ self.args = list(args or [])
96
+
97
+ def copy(self) -> "Account":
98
+ return Account(self.name, self.proxy, self.shared, list(self.managed_links), self.identity,
99
+ dict(self.env), list(self.args), self.dir, list(self.shared_exclude))
100
+
101
+ def to_dict(self) -> dict:
102
+ data = {"identity": self.identity, "proxy": self.proxy, "shared": self.shared,
103
+ "managed_links": list(self.managed_links), "env": dict(self.env), "args": list(self.args)}
104
+ # 只在与账号名不同时写出:没改过名的账号,配置内容与 v0.2 逐字相同。
105
+ if self.dir != self.name:
106
+ data["dir"] = self.dir
107
+ # 同理只在非空时写出,没有退出项的账号配置内容不变。
108
+ if self.shared_exclude:
109
+ data["shared_exclude"] = list(self.shared_exclude)
110
+ return data
111
+
112
+
113
+ class Config(object):
114
+ def __init__(self, root: str, bin_dir: str, shared_dir: Optional[str], shared_items: List[str],
115
+ accounts: Dict[str, Account], defaults_env: Optional[Dict[str, str]] = None,
116
+ defaults_args: Optional[List[str]] = None, route_rules: Optional[List[RouteRule]] = None,
117
+ route_default: Optional[str] = None) -> None:
118
+ self.root = root
119
+ self.bin_dir = bin_dir
120
+ self.shared_dir = shared_dir
121
+ self.shared_items = list(shared_items)
122
+ # 键保持账号名的原始大小写;查找一律经 find() 做大小写不敏感匹配。
123
+ self.accounts = accounts
124
+ self.defaults_env = dict(defaults_env or {})
125
+ self.defaults_args = list(defaults_args or [])
126
+ # 路由规则保持配置中的顺序:两条规则的物理路径相同时,靠前的一条生效。
127
+ self.route_rules = list(route_rules or [])
128
+ # 没有规则命中时 claude-auto 使用的账号名;None 表示直接运行 claude。
129
+ self.route_default = route_default
130
+
131
+ @property
132
+ def routes_enabled(self) -> bool:
133
+ return bool(self.route_rules) or self.route_default is not None
134
+
135
+ def find(self, name: str) -> Optional[Account]:
136
+ """按大小写不敏感匹配查找账号。
137
+
138
+ macOS 默认文件系统不区分大小写,`Work` 与 `work` 实际指向同一个目录和同一个启动命令,
139
+ 所以必须视为同一个账号。
140
+ """
141
+ folded = name.casefold()
142
+ for account in self.accounts.values():
143
+ if account.name.casefold() == folded:
144
+ return account
145
+ return None
146
+
147
+ def default_account(self) -> Optional[Account]:
148
+ for account in self.accounts.values():
149
+ if account.identity == IDENTITY_DEFAULT:
150
+ return account
151
+ return None
152
+
153
+ def effective_env(self, account: Account) -> Dict[str, str]:
154
+ """全局 defaults.env 再用账号 env 覆盖同名键(方案 §5.1.5)。"""
155
+ merged = dict(self.defaults_env)
156
+ merged.update(account.env)
157
+ return merged
158
+
159
+ def effective_args(self, account: Account) -> List[str]:
160
+ """全局 defaults.args 在前、账号 args 在后;启动命令再把 "$@" 接在最后。"""
161
+ return list(self.defaults_args) + list(account.args)
162
+
163
+ def copy(self) -> "Config":
164
+ return Config(self.root, self.bin_dir, self.shared_dir, self.shared_items,
165
+ {key: value.copy() for key, value in self.accounts.items()},
166
+ dict(self.defaults_env), list(self.defaults_args), list(self.route_rules), self.route_default)
167
+
168
+ def to_dict(self) -> dict:
169
+ return {
170
+ "version": CONFIG_VERSION,
171
+ "root": self.root,
172
+ "bin_dir": self.bin_dir,
173
+ "shared": {"dir": self.shared_dir, "items": list(self.shared_items)},
174
+ "defaults": {"env": dict(self.defaults_env), "args": list(self.defaults_args)},
175
+ "accounts": {name: account.to_dict() for name, account in self.accounts.items()},
176
+ "routes": {"default": self.route_default,
177
+ "rules": [{"path": rule.path, "account": rule.account} for rule in self.route_rules]},
178
+ }
179
+
180
+
181
+ def default_config() -> Config:
182
+ return Config(platform.default_root(), platform.default_bin_dir(), None,
183
+ DEFAULT_SHARED_ITEMS, {})
184
+
185
+
186
+ def config_path() -> str:
187
+ return os.path.join(platform.state_dir(), "config.json")
188
+
189
+
190
+ def dump_config(config: Config) -> str:
191
+ return json.dumps(config.to_dict(), indent=2, ensure_ascii=False) + "\n"
192
+
193
+
194
+ def load_config() -> Tuple[Config, bool]:
195
+ """读取 config.json,返回 (配置, 文件是否存在)。
196
+
197
+ 文件不存在时返回默认配置,第一次写命令会把它写出来(方案 §5.1.1 自动初始化)。
198
+ """
199
+ path = config_path()
200
+ try:
201
+ with open(path, "r", encoding="utf-8") as handle:
202
+ raw = handle.read()
203
+ except FileNotFoundError:
204
+ return default_config(), False
205
+ except OSError as exc:
206
+ raise ConfigError("cannot read {}: {}".format(path, exc))
207
+ return parse_config(raw, path), True
208
+
209
+
210
+ def save_config(config: Config) -> None:
211
+ atomic_write(config_path(), dump_config(config), mode=0o600)
212
+
213
+
214
+ def parse_config(raw: str, source: str) -> Config:
215
+ """解析并校验配置文本;任何不合法内容都抛出 ConfigError,并指明出错的字段。"""
216
+ try:
217
+ data = json.loads(raw)
218
+ except ValueError as exc:
219
+ raise ConfigError("{} is not valid JSON: {}".format(source, exc))
220
+ if not isinstance(data, dict):
221
+ raise ConfigError("{}: top level must be an object".format(source))
222
+ if data.get("version", CONFIG_VERSION) != CONFIG_VERSION:
223
+ raise ConfigError("{}: unsupported version {!r}".format(source, data.get("version")))
224
+
225
+ defaults = default_config()
226
+ root = _string_field(data, "root", defaults.root, source)
227
+ bin_dir = _string_field(data, "bin_dir", defaults.bin_dir, source)
228
+
229
+ shared = data.get("shared", {})
230
+ if not isinstance(shared, dict):
231
+ raise ConfigError("{}: 'shared' must be an object".format(source))
232
+ shared_dir = shared.get("dir")
233
+ if shared_dir is not None and (not isinstance(shared_dir, str) or not shared_dir):
234
+ raise ConfigError("{}: 'shared.dir' must be a non-empty string or null".format(source))
235
+ shared_items = shared.get("items", DEFAULT_SHARED_ITEMS)
236
+ if not isinstance(shared_items, list) or not all(_valid_item(item) for item in shared_items):
237
+ raise ConfigError("{}: 'shared.items' must be a list of plain file names".format(source))
238
+ for item in shared_items:
239
+ if is_unshareable(item):
240
+ raise ConfigError("{}: 'shared.items' must not include {}: it holds account-specific state".format(
241
+ source, item))
242
+
243
+ launch_defaults = data.get("defaults", {})
244
+ if not isinstance(launch_defaults, dict):
245
+ raise ConfigError("{}: 'defaults' must be an object".format(source))
246
+ defaults_env = _parse_env(launch_defaults.get("env", {}), "{}: defaults.env".format(source))
247
+ defaults_args = _parse_args(launch_defaults.get("args", []), "{}: defaults.args".format(source))
248
+
249
+ accounts_raw = data.get("accounts", {})
250
+ if not isinstance(accounts_raw, dict):
251
+ raise ConfigError("{}: 'accounts' must be an object".format(source))
252
+ accounts: Dict[str, Account] = {}
253
+ seen = {}
254
+ default_name = None
255
+ for name, value in accounts_raw.items():
256
+ if not NAME_PATTERN.match(name):
257
+ raise ConfigError("{}: invalid account name {!r}".format(source, name))
258
+ folded = name.casefold()
259
+ if folded in seen:
260
+ raise ConfigError("{}: account names {!r} and {!r} differ only in case".format(
261
+ source, seen[folded], name))
262
+ seen[folded] = name
263
+ account = _parse_account(name, value, source)
264
+ if account.identity == IDENTITY_DEFAULT:
265
+ # 一台机器只有一个 ~/.claude,不可能有两个 default 身份的账号。
266
+ if default_name is not None:
267
+ raise ConfigError("{}: accounts {!r} and {!r} both have identity 'default'".format(
268
+ source, default_name, name))
269
+ default_name = name
270
+ accounts[name] = account
271
+ _check_dirs(accounts, source)
272
+ route_rules, route_default = _parse_routes(data.get("routes", {}), source)
273
+ return Config(root, bin_dir, shared_dir, shared_items, accounts, defaults_env, defaults_args,
274
+ route_rules, route_default)
275
+
276
+
277
+ def _check_dirs(accounts: Dict[str, Account], source: str) -> None:
278
+ """账号目录互不相同,且账号名不能等于另一个账号的目录(否则新账号会落进别人的目录)。"""
279
+ owners: Dict[str, str] = {}
280
+ for account in accounts.values():
281
+ folded = account.dir.casefold()
282
+ if folded in owners:
283
+ raise ConfigError("{}: accounts {!r} and {!r} use the same directory {!r}".format(
284
+ source, owners[folded], account.name, account.dir))
285
+ owners[folded] = account.name
286
+ for account in accounts.values():
287
+ owner = owners.get(account.name.casefold())
288
+ if owner is not None and owner != account.name:
289
+ raise ConfigError("{}: account {!r} has the name of the directory of account {!r}".format(
290
+ source, account.name, owner))
291
+
292
+
293
+ def _parse_routes(value: object, source: str) -> Tuple[List[RouteRule], Optional[str]]:
294
+ """只校验形状;“规则引用的账号已登记”放到收敛计划里判冲突,remove 与 apply -f 才能得到同样的退出码 3。"""
295
+ if not isinstance(value, dict):
296
+ raise ConfigError("{}: 'routes' must be an object".format(source))
297
+ default = value.get("default")
298
+ if default is not None and (not isinstance(default, str) or not NAME_PATTERN.match(default)):
299
+ raise ConfigError("{}: 'routes.default' must be an account name or null".format(source))
300
+ rules_raw = value.get("rules", [])
301
+ if not isinstance(rules_raw, list):
302
+ raise ConfigError("{}: 'routes.rules' must be a list".format(source))
303
+ rules: List[RouteRule] = []
304
+ seen = {}
305
+ for item in rules_raw:
306
+ if not isinstance(item, dict) or set(item) != {"path", "account"}:
307
+ raise ConfigError("{}: each route must be an object with exactly 'path' and 'account'".format(source))
308
+ path, account = item["path"], item["account"]
309
+ if not isinstance(account, str) or not NAME_PATTERN.match(account):
310
+ raise ConfigError("{}: route {!r}: invalid account name {!r}".format(source, path, account))
311
+ try:
312
+ expanded = validate_route_path(path)
313
+ except ValueError as exc:
314
+ raise ConfigError("{}: {}".format(source, exc))
315
+ if expanded in seen:
316
+ raise ConfigError("{}: routes {!r} and {!r} point to the same directory".format(
317
+ source, seen[expanded], path))
318
+ seen[expanded] = path
319
+ rules.append(RouteRule(path, account))
320
+ return rules, default
321
+
322
+
323
+ def validate_route_path(path: object) -> str:
324
+ """规则目录必须是绝对路径(可用 ~)且不是 /;返回 expand 后的路径。"""
325
+ if not isinstance(path, str) or not path:
326
+ raise ValueError("route path must be a non-empty string")
327
+ if not (path.startswith("/") or path == "~" or path.startswith("~/")):
328
+ raise ValueError("route path {!r} must be absolute or start with ~/".format(path))
329
+ validate_value(path)
330
+ expanded = expand(path)
331
+ if expanded == "/":
332
+ raise ValueError("route path must not be / (use `route --default` instead)")
333
+ return expanded
334
+
335
+
336
+ def _string_field(data: dict, key: str, default: str, source: str) -> str:
337
+ value = data.get(key, default)
338
+ if not isinstance(value, str) or not value:
339
+ raise ConfigError("{}: '{}' must be a non-empty string".format(source, key))
340
+ return value
341
+
342
+
343
+ def _valid_item(item: object) -> bool:
344
+ # 共享条目只能是账号目录下的一级名称,不能带路径分隔符或指向上级目录。
345
+ return isinstance(item, str) and item not in ("", ".", "..") and "/" not in item
346
+
347
+
348
+ def _parse_account(name: str, value: object, source: str) -> Account:
349
+ if not isinstance(value, dict):
350
+ raise ConfigError("{}: account {!r} must be an object".format(source, name))
351
+ proxy_raw = value.get("proxy", PROXY_INHERIT)
352
+ try:
353
+ proxy = normalize_proxy(proxy_raw if proxy_raw is not None else PROXY_INHERIT)
354
+ except ValueError as exc:
355
+ raise ConfigError("{}: account {!r}: {}".format(source, name, exc))
356
+ shared = value.get("shared", False)
357
+ if not isinstance(shared, bool):
358
+ raise ConfigError("{}: account {!r}: 'shared' must be true or false".format(source, name))
359
+ links = value.get("managed_links", [])
360
+ if not isinstance(links, list) or not all(_valid_item(item) for item in links):
361
+ raise ConfigError("{}: account {!r}: 'managed_links' must be a list of names".format(source, name))
362
+ identity = value.get("identity", IDENTITY_DIR)
363
+ if identity not in IDENTITIES:
364
+ raise ConfigError("{}: account {!r}: 'identity' must be one of {}".format(
365
+ source, name, ", ".join(IDENTITIES)))
366
+ where = "{}: account {!r}".format(source, name)
367
+ env = _parse_env(value.get("env", {}), where + " env")
368
+ args = _parse_args(value.get("args", []), where + " args")
369
+ directory = value.get("dir", name)
370
+ if not isinstance(directory, str) or not NAME_PATTERN.match(directory):
371
+ raise ConfigError("{}: 'dir' must be a directory name like an account name".format(where))
372
+ exclude = value.get("shared_exclude", [])
373
+ if not isinstance(exclude, list) or not all(_valid_item(item) for item in exclude):
374
+ raise ConfigError("{}: 'shared_exclude' must be a list of names".format(where))
375
+ return Account(name, proxy, shared, links, identity, env, args, directory, _unique(exclude))
376
+
377
+
378
+ def _unique(items: List[str]) -> List[str]:
379
+ """去重并保持首次出现的顺序。"""
380
+ seen: List[str] = []
381
+ for item in items:
382
+ if item not in seen:
383
+ seen.append(item)
384
+ return seen
385
+
386
+
387
+ def _parse_env(value: object, where: str) -> Dict[str, str]:
388
+ if not isinstance(value, dict):
389
+ raise ConfigError("{} must be an object".format(where))
390
+ result: Dict[str, str] = {}
391
+ for key, item in value.items():
392
+ if not isinstance(item, str):
393
+ raise ConfigError("{}: value of {!r} must be a string".format(where, key))
394
+ try:
395
+ validate_env_key(key)
396
+ validate_value(item)
397
+ except ValueError as exc:
398
+ raise ConfigError("{}: {}".format(where, exc))
399
+ result[key] = item
400
+ return result
401
+
402
+
403
+ def _parse_args(value: object, where: str) -> List[str]:
404
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
405
+ raise ConfigError("{} must be a list of strings".format(where))
406
+ for item in value:
407
+ try:
408
+ validate_value(item)
409
+ except ValueError as exc:
410
+ raise ConfigError("{}: {}".format(where, exc))
411
+ return list(value)
412
+
413
+
414
+ def validate_name(name: str) -> None:
415
+ if not NAME_PATTERN.match(name):
416
+ raise ValueError(
417
+ "invalid account name {!r}: use 1-64 characters from [A-Za-z0-9._@+-], "
418
+ "starting with a letter or digit".format(name))
419
+
420
+
421
+ def validate_env_key(key: str) -> None:
422
+ """拒绝不能写进启动命令的环境变量名(方案 §5.1.5),比较时不分大小写。"""
423
+ if not isinstance(key, str) or not ENV_KEY_PATTERN.match(key):
424
+ raise ValueError("invalid environment variable name {!r}".format(key))
425
+ upper = key.upper()
426
+ if upper in _IDENTITY_KEYS:
427
+ raise ValueError("{} decides which login the account uses and cannot be set per launcher".format(key))
428
+ if upper in (var.upper() for var in PROXY_VARS):
429
+ raise ValueError("{} is managed by the proxy setting; use `multi-claude proxy`".format(key))
430
+ if (upper in _CREDENTIAL_KEYS or upper == "API_KEY" or upper.endswith("_API_KEY")
431
+ or any(segment in _CREDENTIAL_SEGMENTS for segment in upper.split("_"))):
432
+ raise ValueError("{} looks like a credential; launchers are world-readable files, so put it in the "
433
+ "account's settings.json env or use apiKeyHelper instead".format(key))
434
+
435
+
436
+ def validate_value(value: str) -> None:
437
+ # 换行和 NUL 写进 sh 脚本会破坏单行赋值的结构,也传不进 exec 的参数。
438
+ if "\n" in value or "\r" in value or "\0" in value:
439
+ raise ValueError("values must not contain line breaks or NUL characters")
440
+
441
+
442
+ def normalize_proxy(value: object) -> str:
443
+ """把用户输入的代理值规范化为 `inherit`、`off` 或 `scheme://host:port`。
444
+
445
+ 只写端口号时展开为 http://127.0.0.1:<端口>。
446
+ 带用户名密码的 URL 一律拒绝:启动命令是所有人可读的明文文件,写进去就泄露了。
447
+ """
448
+ if not isinstance(value, str) or not value:
449
+ raise ValueError("proxy must be a port number, a URL, 'off' or 'inherit'")
450
+ if value in (PROXY_INHERIT, PROXY_OFF):
451
+ return value
452
+ if value.isdigit():
453
+ port = int(value)
454
+ _check_port(port)
455
+ return "http://127.0.0.1:{}".format(port)
456
+ if "://" not in value:
457
+ # 例如 `7891x`、`127.0.0.1:7890`:urlsplit 会得到空的或错误的 scheme,原报错让人看不出该怎么写。
458
+ raise ValueError("proxy must be a port (e.g. 7890), an http(s) URL (e.g. http://127.0.0.1:7890), "
459
+ "off or inherit; got {!r}".format(value))
460
+ parts = urllib.parse.urlsplit(value)
461
+ if parts.scheme in ("socks5", "socks5h", "socks4", "socks4a", "socks"):
462
+ raise ValueError("SOCKS proxies are not supported by Claude Code "
463
+ "(https://code.claude.com/docs/en/network-config); use an HTTP proxy")
464
+ if parts.scheme not in PROXY_SCHEMES:
465
+ raise ValueError("unsupported proxy scheme {!r}; use one of {}".format(
466
+ parts.scheme, ", ".join(PROXY_SCHEMES)))
467
+ if parts.username is not None or parts.password is not None:
468
+ raise ValueError("proxy URL must not contain credentials")
469
+ if parts.path not in ("", "/") or parts.query or parts.fragment:
470
+ raise ValueError("proxy URL must not contain a path, query or fragment")
471
+ host = parts.hostname
472
+ if not host:
473
+ raise ValueError("proxy URL must contain a host")
474
+ try:
475
+ port = parts.port
476
+ except ValueError:
477
+ raise ValueError("proxy URL has an invalid port")
478
+ if port is None:
479
+ raise ValueError("proxy URL must contain a port")
480
+ _check_port(port)
481
+ if ":" in host:
482
+ host = "[{}]".format(host)
483
+ return "{}://{}:{}".format(parts.scheme, host, port)
484
+
485
+
486
+ def _check_port(port: int) -> None:
487
+ if not 1 <= port <= 65535:
488
+ raise ValueError("proxy port must be between 1 and 65535, got {}".format(port))