multi-codex 0.7.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_codex/__init__.py +3 -0
- multi_codex/__main__.py +5 -0
- multi_codex/accounts.py +245 -0
- multi_codex/actions.py +76 -0
- multi_codex/apps.py +74 -0
- multi_codex/binding.py +66 -0
- multi_codex/cli.py +994 -0
- multi_codex/completion.py +254 -0
- multi_codex/config.py +327 -0
- multi_codex/doctor.py +244 -0
- multi_codex/fsutil.py +250 -0
- multi_codex/identity.py +171 -0
- multi_codex/launcher.py +151 -0
- multi_codex/lock.py +47 -0
- multi_codex/migrate.py +672 -0
- multi_codex/platform.py +184 -0
- multi_codex/shared.py +127 -0
- multi_codex/switch.py +286 -0
- multi_codex/usage.py +475 -0
- multi_codex-0.7.0.dist-info/METADATA +452 -0
- multi_codex-0.7.0.dist-info/RECORD +25 -0
- multi_codex-0.7.0.dist-info/WHEEL +5 -0
- multi_codex-0.7.0.dist-info/entry_points.txt +2 -0
- multi_codex-0.7.0.dist-info/licenses/LICENSE +21 -0
- multi_codex-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""生成 bash / zsh / fish 的补全脚本(feature-cli-ergonomics §5.1.1)。
|
|
2
|
+
|
|
3
|
+
子命令、选项、哪些选项带值,都在生成时从 argparse 解析器读出,新增选项不必另外维护列表。
|
|
4
|
+
账号名在补全时调用 `multi-codex completion --list-accounts` 动态读取。
|
|
5
|
+
|
|
6
|
+
三种 shell 判断“当前是第几个参数”的规则相同:按空白切分已输入的部分,以 `-` 开头的词不算,
|
|
7
|
+
带值选项后面紧跟的那个词也不算;`run` 的 `--` 之后不再补全。
|
|
8
|
+
bash 不能用 COMP_WORDS / COMP_CWORD:它们按 COMP_WORDBREAKS 切分,默认含 `@` 和 `=`,
|
|
9
|
+
会把邮箱形式的账号名拆成几段,参数位置就数错了。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
from typing import Dict, List, Tuple
|
|
14
|
+
|
|
15
|
+
# 第一个位置参数是账号名的子命令;usage 的每个位置参数都是账号名。
|
|
16
|
+
ACCOUNT_COMMANDS = ("add", "login", "proxy", "remove", "migrate-default", "run", "path", "env", "usage", "use",
|
|
17
|
+
"restore", "bind", "code", "app")
|
|
18
|
+
MULTI_ACCOUNT_COMMANDS = ("usage",)
|
|
19
|
+
SHELLS = ("bash", "zsh", "fish")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def command_spec(parser: argparse.ArgumentParser) -> Dict[str, Tuple[List[str], List[str]]]:
|
|
23
|
+
"""返回 {子命令: (全部选项, 带值的选项)};不含 -h/--help 和隐藏选项。"""
|
|
24
|
+
spec: Dict[str, Tuple[List[str], List[str]]] = {}
|
|
25
|
+
for action in parser._actions: # noqa: SLF001 —— argparse 没有公开的子命令遍历接口
|
|
26
|
+
if not isinstance(action, argparse._SubParsersAction): # noqa: SLF001
|
|
27
|
+
continue
|
|
28
|
+
for name, sub in action.choices.items():
|
|
29
|
+
options: List[str] = []
|
|
30
|
+
valued: List[str] = []
|
|
31
|
+
for sub_action in sub._actions: # noqa: SLF001
|
|
32
|
+
if not sub_action.option_strings or sub_action.help == argparse.SUPPRESS:
|
|
33
|
+
continue
|
|
34
|
+
if isinstance(sub_action, argparse._HelpAction): # noqa: SLF001
|
|
35
|
+
continue
|
|
36
|
+
options.extend(sub_action.option_strings)
|
|
37
|
+
if sub_action.nargs != 0:
|
|
38
|
+
valued.extend(sub_action.option_strings)
|
|
39
|
+
spec[name] = (options, valued)
|
|
40
|
+
return spec
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _case_lines(spec: Dict[str, Tuple[List[str], List[str]]], index: int, indent: str) -> List[str]:
|
|
44
|
+
lines = []
|
|
45
|
+
for name in sorted(spec):
|
|
46
|
+
words = spec[name][index]
|
|
47
|
+
if words:
|
|
48
|
+
lines.append('{}{}) echo "{}" ;;'.format(indent, name, " ".join(words)))
|
|
49
|
+
return lines
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bash_script(spec: Dict[str, Tuple[List[str], List[str]]]) -> str:
|
|
53
|
+
commands = " ".join(sorted(spec))
|
|
54
|
+
return "\n".join([
|
|
55
|
+
"# multi-codex bash completion. Load with: eval \"$(multi-codex completion bash)\"",
|
|
56
|
+
"_multi_codex_options() {",
|
|
57
|
+
' case "$1" in',
|
|
58
|
+
] + _case_lines(spec, 0, " ") + [
|
|
59
|
+
" esac",
|
|
60
|
+
"}",
|
|
61
|
+
"_multi_codex_value_options() {",
|
|
62
|
+
' case "$1" in',
|
|
63
|
+
] + _case_lines(spec, 1, " ") + [
|
|
64
|
+
" esac",
|
|
65
|
+
"}",
|
|
66
|
+
"_multi_codex() {",
|
|
67
|
+
' local line="${COMP_LINE:0:COMP_POINT}"',
|
|
68
|
+
" local -a words",
|
|
69
|
+
' read -r -a words <<< "$line"',
|
|
70
|
+
' local count=${#words[@]} cur=""',
|
|
71
|
+
' if [[ ! "$line" =~ [[:space:]]$ ]] && (( count > 1 )); then',
|
|
72
|
+
' cur="${words[count-1]}"',
|
|
73
|
+
" count=$((count - 1))",
|
|
74
|
+
" fi",
|
|
75
|
+
' local cmd="" npos=0 skip=0 after_dd=0 i w',
|
|
76
|
+
" for ((i = 1; i < count; i++)); do",
|
|
77
|
+
' w="${words[i]}"',
|
|
78
|
+
' if [[ -z "$cmd" ]]; then',
|
|
79
|
+
' [[ "$w" == -* ]] || cmd="$w"',
|
|
80
|
+
" continue",
|
|
81
|
+
" fi",
|
|
82
|
+
' if [[ ( "$cmd" == run || "$cmd" == login ) && "$w" == "--" ]]; then after_dd=1; break; fi',
|
|
83
|
+
" if (( skip )); then skip=0; continue; fi",
|
|
84
|
+
' if [[ "$w" == -* ]]; then',
|
|
85
|
+
' case " $(_multi_codex_value_options "$cmd") " in *" $w "*) skip=1 ;; esac',
|
|
86
|
+
" else",
|
|
87
|
+
" npos=$((npos + 1))",
|
|
88
|
+
" fi",
|
|
89
|
+
" done",
|
|
90
|
+
' local candidates=""',
|
|
91
|
+
' if [[ -z "$cmd" ]]; then',
|
|
92
|
+
' candidates="{}"'.format(commands),
|
|
93
|
+
" elif (( after_dd || skip )); then",
|
|
94
|
+
" COMPREPLY=()",
|
|
95
|
+
" return 0",
|
|
96
|
+
' elif [[ "$cur" == -* ]]; then',
|
|
97
|
+
' candidates="$(_multi_codex_options "$cmd")"',
|
|
98
|
+
" else",
|
|
99
|
+
' case " {} " in'.format(" ".join(ACCOUNT_COMMANDS)),
|
|
100
|
+
' *" $cmd "*)',
|
|
101
|
+
' if (( npos == 0 )) || [[ "$cmd" == usage ]]; then',
|
|
102
|
+
' candidates="$(multi-codex completion --list-accounts 2>/dev/null)"',
|
|
103
|
+
" fi ;;",
|
|
104
|
+
" esac",
|
|
105
|
+
' [[ "$cmd" == proxy ]] && (( npos == 1 )) && candidates="off inherit"',
|
|
106
|
+
' [[ "$cmd" == completion ]] && (( npos == 0 )) && candidates="{}"'.format(" ".join(SHELLS)),
|
|
107
|
+
" fi",
|
|
108
|
+
' COMPREPLY=($(compgen -W "$candidates" -- "$cur"))',
|
|
109
|
+
" # bash 只会替换当前词最后一个 @ 之后的部分,候选里要去掉它之前的前缀。",
|
|
110
|
+
' if [[ "$cur" == *@* && "$COMP_WORDBREAKS" == *@* ]]; then',
|
|
111
|
+
' local prefix="${cur%"${cur##*@}"}"',
|
|
112
|
+
' COMPREPLY=("${COMPREPLY[@]#"$prefix"}")',
|
|
113
|
+
" fi",
|
|
114
|
+
" return 0",
|
|
115
|
+
"}",
|
|
116
|
+
"# -o default:候选为空时(带值选项之后、run -- 之后)退回 bash 自带的文件名补全。",
|
|
117
|
+
"complete -o default -F _multi_codex multi-codex",
|
|
118
|
+
"",
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def zsh_script(spec: Dict[str, Tuple[List[str], List[str]]]) -> str:
|
|
123
|
+
commands = " ".join(sorted(spec))
|
|
124
|
+
return "\n".join([
|
|
125
|
+
"# multi-codex zsh completion. Load after compinit with: eval \"$(multi-codex completion zsh)\"",
|
|
126
|
+
"_multi_codex_options() {",
|
|
127
|
+
' case "$1" in',
|
|
128
|
+
] + _case_lines(spec, 0, " ") + [
|
|
129
|
+
" esac",
|
|
130
|
+
"}",
|
|
131
|
+
"_multi_codex_value_options() {",
|
|
132
|
+
' case "$1" in',
|
|
133
|
+
] + _case_lines(spec, 1, " ") + [
|
|
134
|
+
" esac",
|
|
135
|
+
"}",
|
|
136
|
+
"_multi_codex() {",
|
|
137
|
+
' local cur="${words[CURRENT]}" cmd="" w',
|
|
138
|
+
" local -i npos=0 skip=0 after_dd=0",
|
|
139
|
+
" for w in \"${(@)words[2,CURRENT-1]}\"; do",
|
|
140
|
+
' if [[ -z "$cmd" ]]; then',
|
|
141
|
+
' [[ "$w" == -* ]] || cmd="$w"',
|
|
142
|
+
" continue",
|
|
143
|
+
" fi",
|
|
144
|
+
' if [[ ( "$cmd" == run || "$cmd" == login ) && "$w" == "--" ]]; then after_dd=1; break; fi',
|
|
145
|
+
" if (( skip )); then skip=0; continue; fi",
|
|
146
|
+
' if [[ "$w" == -* ]]; then',
|
|
147
|
+
' [[ " $(_multi_codex_value_options "$cmd") " == *" $w "* ]] && skip=1',
|
|
148
|
+
" else",
|
|
149
|
+
" npos=$((npos + 1))",
|
|
150
|
+
" fi",
|
|
151
|
+
" done",
|
|
152
|
+
" local -a candidates",
|
|
153
|
+
' if [[ -z "$cmd" ]]; then',
|
|
154
|
+
" candidates=({})".format(commands),
|
|
155
|
+
" elif (( after_dd || skip )); then",
|
|
156
|
+
" _files",
|
|
157
|
+
" return",
|
|
158
|
+
' elif [[ "$cur" == -* ]]; then',
|
|
159
|
+
' candidates=(${=$(_multi_codex_options "$cmd")})',
|
|
160
|
+
" else",
|
|
161
|
+
# 与 bash 版相同:先确认是收账号名的子命令,再看是第一个参数,或者是 usage(每个参数都是账号名)。
|
|
162
|
+
' if [[ " {} " == *" $cmd "* ]] && {{ (( npos == 0 )) || [[ "$cmd" == usage ]]; }}; then'.format(
|
|
163
|
+
" ".join(ACCOUNT_COMMANDS)),
|
|
164
|
+
' candidates=(${(f)"$(multi-codex completion --list-accounts 2>/dev/null)"})',
|
|
165
|
+
" fi",
|
|
166
|
+
' [[ "$cmd" == proxy ]] && (( npos == 1 )) && candidates=(off inherit)',
|
|
167
|
+
' [[ "$cmd" == completion ]] && (( npos == 0 )) && candidates=({})'.format(" ".join(SHELLS)),
|
|
168
|
+
" fi",
|
|
169
|
+
" # 没有候选时(bind 的目录、code 的路径等)退回文件名补全,与 bash 的 -o default 一致。",
|
|
170
|
+
" if (( ${#candidates} == 0 )); then",
|
|
171
|
+
" _files",
|
|
172
|
+
" return",
|
|
173
|
+
" fi",
|
|
174
|
+
' compadd -- "${candidates[@]}"',
|
|
175
|
+
"}",
|
|
176
|
+
"compdef _multi_codex multi-codex",
|
|
177
|
+
"",
|
|
178
|
+
])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def fish_script(spec: Dict[str, Tuple[List[str], List[str]]]) -> str:
|
|
182
|
+
lines = [
|
|
183
|
+
"# multi-codex fish completion. Load with: multi-codex completion fish | source",
|
|
184
|
+
"function __multi_codex_value_options",
|
|
185
|
+
" switch $argv[1]",
|
|
186
|
+
]
|
|
187
|
+
for name in sorted(spec):
|
|
188
|
+
if spec[name][1]:
|
|
189
|
+
lines.append(" case {}".format(name))
|
|
190
|
+
lines.append(" printf '%s\\n' {}".format(" ".join(spec[name][1])))
|
|
191
|
+
lines += [
|
|
192
|
+
" end",
|
|
193
|
+
"end",
|
|
194
|
+
"# 判断当前位置要补什么:account、proxy-value、shell,或什么都不补。",
|
|
195
|
+
"function __multi_codex_is",
|
|
196
|
+
" set -l tokens (commandline -opc)",
|
|
197
|
+
" set -e tokens[1]",
|
|
198
|
+
" set -l cmd ''",
|
|
199
|
+
" set -l npos 0",
|
|
200
|
+
" set -l skip 0",
|
|
201
|
+
" for w in $tokens",
|
|
202
|
+
" if test -z \"$cmd\"",
|
|
203
|
+
" string match -q -- '-*' $w; or set cmd $w",
|
|
204
|
+
" continue",
|
|
205
|
+
" end",
|
|
206
|
+
" if contains -- \"$cmd\" run login; and test \"$w\" = --",
|
|
207
|
+
" return 1",
|
|
208
|
+
" end",
|
|
209
|
+
" if test $skip -eq 1",
|
|
210
|
+
" set skip 0",
|
|
211
|
+
" continue",
|
|
212
|
+
" end",
|
|
213
|
+
" if string match -q -- '-*' $w",
|
|
214
|
+
" contains -- $w (__multi_codex_value_options $cmd); and set skip 1",
|
|
215
|
+
" else",
|
|
216
|
+
" set npos (math $npos + 1)",
|
|
217
|
+
" end",
|
|
218
|
+
" end",
|
|
219
|
+
" test -n \"$cmd\"; or return 1",
|
|
220
|
+
" test $skip -eq 0; or return 1",
|
|
221
|
+
" set -l kind ''",
|
|
222
|
+
" if contains -- $cmd {}".format(" ".join(ACCOUNT_COMMANDS)),
|
|
223
|
+
" if test $npos -eq 0; or test \"$cmd\" = usage",
|
|
224
|
+
" set kind account",
|
|
225
|
+
" end",
|
|
226
|
+
" end",
|
|
227
|
+
" if test \"$cmd\" = proxy; and test $npos -eq 1",
|
|
228
|
+
" set kind proxy-value",
|
|
229
|
+
" end",
|
|
230
|
+
" if test \"$cmd\" = completion; and test $npos -eq 0",
|
|
231
|
+
" set kind shell",
|
|
232
|
+
" end",
|
|
233
|
+
" test \"$kind\" = \"$argv[1]\"",
|
|
234
|
+
"end",
|
|
235
|
+
"complete -c multi-codex -f -n __fish_use_subcommand -a '{}'".format(" ".join(sorted(spec))),
|
|
236
|
+
]
|
|
237
|
+
for name in sorted(spec):
|
|
238
|
+
valued = set(spec[name][1])
|
|
239
|
+
for option in spec[name][0]:
|
|
240
|
+
flag = "-l {}".format(option[2:]) if option.startswith("--") else "-s {}".format(option[1:])
|
|
241
|
+
lines.append("complete -c multi-codex -n '__fish_seen_subcommand_from {}' {}{}".format(
|
|
242
|
+
name, flag, " -r" if option in valued else ""))
|
|
243
|
+
lines += [
|
|
244
|
+
"complete -c multi-codex -f -n '__multi_codex_is account' -a '(multi-codex completion --list-accounts)'",
|
|
245
|
+
"complete -c multi-codex -f -n '__multi_codex_is proxy-value' -a 'off inherit'",
|
|
246
|
+
"complete -c multi-codex -f -n '__multi_codex_is shell' -a '{}'".format(" ".join(SHELLS)),
|
|
247
|
+
"",
|
|
248
|
+
]
|
|
249
|
+
return "\n".join(lines)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def script(shell: str, parser: argparse.ArgumentParser) -> str:
|
|
253
|
+
spec = command_spec(parser)
|
|
254
|
+
return {"bash": bash_script, "zsh": zsh_script, "fish": fish_script}[shell](spec)
|
multi_codex/config.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""配置模型:读写 config.json,校验账号名与代理值。
|
|
2
|
+
|
|
3
|
+
config.json 是唯一的权威源,启动命令、共享链接都是从它推导出来的产物(方案 §5.1.1)。
|
|
4
|
+
本模块只负责数据本身,不创建或删除任何账号文件。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import difflib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import urllib.parse
|
|
12
|
+
from typing import Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from . import platform
|
|
15
|
+
from .fsutil import atomic_write
|
|
16
|
+
|
|
17
|
+
CONFIG_VERSION = 1
|
|
18
|
+
DEFAULT_SHARED_ITEMS = ["AGENTS.md", "skills", "rules", "agents"]
|
|
19
|
+
|
|
20
|
+
# 以字母或数字开头:不会被当成命令行选项,也不会以 `.` 开头与 `.migration` 等目录混淆。
|
|
21
|
+
NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@+-]{0,63}$")
|
|
22
|
+
|
|
23
|
+
PROXY_INHERIT = "inherit"
|
|
24
|
+
PROXY_OFF = "off"
|
|
25
|
+
PROXY_SCHEMES = ("http", "https", "socks5", "socks5h")
|
|
26
|
+
|
|
27
|
+
# 启动命令的代理逻辑管理的 8 个变量(launcher.py 也用这份列表)。账号环境变量不得设置它们:
|
|
28
|
+
# 代理一律由 `multi-codex proxy` 管理,两处都能设置时,谁覆盖谁取决于行的顺序,用户很难看懂。
|
|
29
|
+
PROXY_ENV_VARS = ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY",
|
|
30
|
+
"https_proxy", "http_proxy", "all_proxy", "no_proxy")
|
|
31
|
+
# CODEX_HOME 由启动命令设为账号目录,被覆盖就失去了按账号隔离的意义。
|
|
32
|
+
RESERVED_ENV_KEYS = ("CODEX_HOME",) + PROXY_ENV_VARS
|
|
33
|
+
ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConfigError(Exception):
|
|
37
|
+
"""配置文件内容不合法,对应退出码 1。"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Account(object):
|
|
41
|
+
"""一个已登记的账号。
|
|
42
|
+
|
|
43
|
+
managed_links 是工具内部状态:记录本工具在该账号目录里建立过的共享链接,
|
|
44
|
+
关闭共享时只删除这些链接,用户自己建的同名链接不受影响。
|
|
45
|
+
|
|
46
|
+
env 是写进启动命令的额外环境变量,值按字面写入、不经 shell 求值。
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, name: str, proxy: str = PROXY_INHERIT, shared: bool = False,
|
|
50
|
+
managed_links: Optional[List[str]] = None, env: Optional[Dict[str, str]] = None) -> None:
|
|
51
|
+
self.name = name
|
|
52
|
+
self.proxy = proxy
|
|
53
|
+
self.shared = shared
|
|
54
|
+
self.managed_links = list(managed_links or [])
|
|
55
|
+
self.env = dict(env or {})
|
|
56
|
+
|
|
57
|
+
def copy(self) -> "Account":
|
|
58
|
+
return Account(self.name, self.proxy, self.shared, list(self.managed_links), dict(self.env))
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
data = {"proxy": self.proxy, "shared": self.shared, "managed_links": list(self.managed_links)}
|
|
62
|
+
# 没有环境变量时不写这个字段:0.3.0 的配置序列化结果保持不变,升级后的第一条写命令
|
|
63
|
+
# 才不会因为“配置文本变了”而把每个账号都判为 update(accounts.plan 按序列化文本比较)。
|
|
64
|
+
# 按键名排序:apply -f 文件里键的顺序不同,不应被当成改动。
|
|
65
|
+
if self.env:
|
|
66
|
+
data["env"] = {key: self.env[key] for key in sorted(self.env)}
|
|
67
|
+
return data
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Config(object):
|
|
71
|
+
def __init__(self, root: str, bin_dir: str, shared_dir: Optional[str],
|
|
72
|
+
shared_items: List[str], accounts: Dict[str, Account],
|
|
73
|
+
bindings: Optional[Dict[str, str]] = None) -> None:
|
|
74
|
+
self.root = root
|
|
75
|
+
self.bin_dir = bin_dir
|
|
76
|
+
self.shared_dir = shared_dir
|
|
77
|
+
self.shared_items = list(shared_items)
|
|
78
|
+
# 键保持账号名的原始大小写;查找一律经 find() 做大小写不敏感匹配。
|
|
79
|
+
self.accounts = accounts
|
|
80
|
+
# 目录绑定:规范路径(binding.normalize_dir)-> 账号名。属于本机状态,apply -f 时沿用当前值。
|
|
81
|
+
self.bindings = dict(bindings or {})
|
|
82
|
+
|
|
83
|
+
def find(self, name: str) -> Optional[Account]:
|
|
84
|
+
"""按大小写不敏感匹配查找账号。
|
|
85
|
+
|
|
86
|
+
macOS 默认文件系统不区分大小写,`Work` 与 `work` 实际指向同一个目录和同一个启动命令,
|
|
87
|
+
所以必须视为同一个账号。
|
|
88
|
+
"""
|
|
89
|
+
folded = name.casefold()
|
|
90
|
+
for account in self.accounts.values():
|
|
91
|
+
if account.name.casefold() == folded:
|
|
92
|
+
return account
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def not_registered(self, name: str) -> str:
|
|
96
|
+
"""账号不存在时的报错文本,附带下一步:相近的账号名、已登记的账号,或先 add。
|
|
97
|
+
|
|
98
|
+
前半句 `account '<名>' is not registered` 保持旧版原文,按它 grep 的脚本和日志检索不受影响。
|
|
99
|
+
相近匹配用 casefold 比较,与 find() 的大小写不敏感一致;给出的名字用登记时的原始大小写。
|
|
100
|
+
"""
|
|
101
|
+
message = "account {!r} is not registered".format(name)
|
|
102
|
+
names = list(self.accounts)
|
|
103
|
+
folded = {account.casefold(): account for account in names}
|
|
104
|
+
close = difflib.get_close_matches(name.casefold(), list(folded), n=1, cutoff=0.6)
|
|
105
|
+
if close:
|
|
106
|
+
return "{}; did you mean {!r}?".format(message, folded[close[0]])
|
|
107
|
+
if not names:
|
|
108
|
+
return "{}; run `multi-codex add NAME` first".format(message)
|
|
109
|
+
# 账号多时整列打出来反而难读,只在不超过 5 个时列出。
|
|
110
|
+
if len(names) <= 5:
|
|
111
|
+
return "{}; registered: {}".format(message, ", ".join(names))
|
|
112
|
+
return message
|
|
113
|
+
|
|
114
|
+
def copy(self) -> "Config":
|
|
115
|
+
return Config(self.root, self.bin_dir, self.shared_dir, self.shared_items,
|
|
116
|
+
{key: value.copy() for key, value in self.accounts.items()}, dict(self.bindings))
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict:
|
|
119
|
+
data = {
|
|
120
|
+
"version": CONFIG_VERSION,
|
|
121
|
+
"root": self.root,
|
|
122
|
+
"bin_dir": self.bin_dir,
|
|
123
|
+
"shared": {"dir": self.shared_dir, "items": list(self.shared_items)},
|
|
124
|
+
"accounts": {name: account.to_dict() for name, account in self.accounts.items()},
|
|
125
|
+
}
|
|
126
|
+
# 没有绑定时不写这个字段,0.4.0 的配置序列化结果保持不变(原因同 Account.to_dict 的 env)。
|
|
127
|
+
if self.bindings:
|
|
128
|
+
data["bindings"] = {path: self.bindings[path] for path in sorted(self.bindings)}
|
|
129
|
+
return data
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def default_config() -> Config:
|
|
133
|
+
return Config(platform.default_root(), platform.default_bin_dir(), None,
|
|
134
|
+
DEFAULT_SHARED_ITEMS, {})
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def config_path() -> str:
|
|
138
|
+
return os.path.join(platform.state_dir(), "config.json")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def dump_config(config: Config) -> str:
|
|
142
|
+
return json.dumps(config.to_dict(), indent=2, ensure_ascii=False) + "\n"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_config() -> Tuple[Config, bool]:
|
|
146
|
+
"""读取 config.json,返回 (配置, 文件是否存在)。
|
|
147
|
+
|
|
148
|
+
文件不存在时返回默认配置,第一次写命令会把它写出来(方案 §5.1.1 自动初始化)。
|
|
149
|
+
"""
|
|
150
|
+
path = config_path()
|
|
151
|
+
try:
|
|
152
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
153
|
+
raw = handle.read()
|
|
154
|
+
except FileNotFoundError:
|
|
155
|
+
return default_config(), False
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
raise ConfigError("cannot read {}: {}".format(path, exc))
|
|
158
|
+
return parse_config(raw, path), True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def save_config(config: Config) -> None:
|
|
162
|
+
atomic_write(config_path(), dump_config(config), mode=0o600)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def parse_config(raw: str, source: str) -> Config:
|
|
166
|
+
"""解析并校验配置文本;任何不合法内容都抛出 ConfigError,并指明出错的字段。"""
|
|
167
|
+
try:
|
|
168
|
+
data = json.loads(raw)
|
|
169
|
+
except ValueError as exc:
|
|
170
|
+
raise ConfigError("{} is not valid JSON: {}".format(source, exc))
|
|
171
|
+
if not isinstance(data, dict):
|
|
172
|
+
raise ConfigError("{}: top level must be an object".format(source))
|
|
173
|
+
if data.get("version", CONFIG_VERSION) != CONFIG_VERSION:
|
|
174
|
+
raise ConfigError("{}: unsupported version {!r}".format(source, data.get("version")))
|
|
175
|
+
|
|
176
|
+
defaults = default_config()
|
|
177
|
+
root = _string_field(data, "root", defaults.root, source)
|
|
178
|
+
bin_dir = _string_field(data, "bin_dir", defaults.bin_dir, source)
|
|
179
|
+
|
|
180
|
+
shared = data.get("shared", {})
|
|
181
|
+
if not isinstance(shared, dict):
|
|
182
|
+
raise ConfigError("{}: 'shared' must be an object".format(source))
|
|
183
|
+
shared_dir = shared.get("dir")
|
|
184
|
+
if shared_dir is not None and (not isinstance(shared_dir, str) or not shared_dir):
|
|
185
|
+
raise ConfigError("{}: 'shared.dir' must be a non-empty string or null".format(source))
|
|
186
|
+
shared_items = shared.get("items", DEFAULT_SHARED_ITEMS)
|
|
187
|
+
if not isinstance(shared_items, list) or not all(_valid_item(item) for item in shared_items):
|
|
188
|
+
raise ConfigError("{}: 'shared.items' must be a list of plain file names".format(source))
|
|
189
|
+
|
|
190
|
+
accounts_raw = data.get("accounts", {})
|
|
191
|
+
if not isinstance(accounts_raw, dict):
|
|
192
|
+
raise ConfigError("{}: 'accounts' must be an object".format(source))
|
|
193
|
+
accounts: Dict[str, Account] = {}
|
|
194
|
+
seen = {}
|
|
195
|
+
for name, value in accounts_raw.items():
|
|
196
|
+
if not NAME_PATTERN.match(name):
|
|
197
|
+
raise ConfigError("{}: invalid account name {!r}".format(source, name))
|
|
198
|
+
folded = name.casefold()
|
|
199
|
+
if folded in seen:
|
|
200
|
+
raise ConfigError("{}: account names {!r} and {!r} differ only in case".format(
|
|
201
|
+
source, seen[folded], name))
|
|
202
|
+
seen[folded] = name
|
|
203
|
+
accounts[name] = _parse_account(name, value, source)
|
|
204
|
+
|
|
205
|
+
bindings = data.get("bindings", {})
|
|
206
|
+
if not isinstance(bindings, dict):
|
|
207
|
+
raise ConfigError("{}: 'bindings' must be an object".format(source))
|
|
208
|
+
for path, name in bindings.items():
|
|
209
|
+
# 不要求账号已登记:悬空的绑定由 doctor 报告,不让整个配置无法加载。
|
|
210
|
+
if not isinstance(path, str) or not os.path.isabs(path) or not isinstance(name, str) \
|
|
211
|
+
or not NAME_PATTERN.match(name):
|
|
212
|
+
raise ConfigError("{}: invalid binding {!r} -> {!r}".format(source, path, name))
|
|
213
|
+
return Config(root, bin_dir, shared_dir, shared_items, accounts, bindings)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _string_field(data: dict, key: str, default: str, source: str) -> str:
|
|
217
|
+
value = data.get(key, default)
|
|
218
|
+
if not isinstance(value, str) or not value:
|
|
219
|
+
raise ConfigError("{}: '{}' must be a non-empty string".format(source, key))
|
|
220
|
+
return value
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _valid_item(item: object) -> bool:
|
|
224
|
+
# 共享条目只能是账号目录下的一级名称,不能带路径分隔符或指向上级目录。
|
|
225
|
+
return isinstance(item, str) and item not in ("", ".", "..") and "/" not in item
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _parse_account(name: str, value: object, source: str) -> Account:
|
|
229
|
+
if not isinstance(value, dict):
|
|
230
|
+
raise ConfigError("{}: account {!r} must be an object".format(source, name))
|
|
231
|
+
proxy_raw = value.get("proxy", PROXY_INHERIT)
|
|
232
|
+
try:
|
|
233
|
+
proxy = normalize_proxy(proxy_raw if proxy_raw is not None else PROXY_INHERIT)
|
|
234
|
+
except ValueError as exc:
|
|
235
|
+
raise ConfigError("{}: account {!r}: {}".format(source, name, exc))
|
|
236
|
+
shared = value.get("shared", False)
|
|
237
|
+
if not isinstance(shared, bool):
|
|
238
|
+
raise ConfigError("{}: account {!r}: 'shared' must be true or false".format(source, name))
|
|
239
|
+
links = value.get("managed_links", [])
|
|
240
|
+
if not isinstance(links, list) or not all(_valid_item(item) for item in links):
|
|
241
|
+
raise ConfigError("{}: account {!r}: 'managed_links' must be a list of names".format(source, name))
|
|
242
|
+
env = value.get("env", {})
|
|
243
|
+
if not isinstance(env, dict):
|
|
244
|
+
raise ConfigError("{}: account {!r}: 'env' must be an object".format(source, name))
|
|
245
|
+
for key, item in env.items():
|
|
246
|
+
try:
|
|
247
|
+
validate_env_key(key)
|
|
248
|
+
validate_env_value(item)
|
|
249
|
+
except ValueError as exc:
|
|
250
|
+
raise ConfigError("{}: account {!r}: {}".format(source, name, exc))
|
|
251
|
+
return Account(name, proxy, shared, links, env)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def validate_name(name: str) -> None:
|
|
255
|
+
if not NAME_PATTERN.match(name):
|
|
256
|
+
raise ValueError(
|
|
257
|
+
"invalid account name {!r}: use 1-64 characters from [A-Za-z0-9._@+-], "
|
|
258
|
+
"starting with a letter or digit".format(name))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def validate_env_key(key: object) -> None:
|
|
262
|
+
if not isinstance(key, str) or not ENV_KEY_PATTERN.match(key):
|
|
263
|
+
raise ValueError("invalid environment variable name {!r}: use letters, digits and '_', "
|
|
264
|
+
"not starting with a digit".format(key))
|
|
265
|
+
if key == "CODEX_HOME":
|
|
266
|
+
raise ValueError("CODEX_HOME is managed by multi-codex and cannot be set per account")
|
|
267
|
+
if key in PROXY_ENV_VARS:
|
|
268
|
+
raise ValueError("{} is a proxy variable; use `multi-codex proxy` for proxy settings".format(key))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def validate_env_value(value: object) -> None:
|
|
272
|
+
if not isinstance(value, str):
|
|
273
|
+
raise ValueError("environment variable values must be strings")
|
|
274
|
+
if "\0" in value:
|
|
275
|
+
# exec 用 C 字符串传递环境变量,NUL 之后的部分会被截掉。
|
|
276
|
+
raise ValueError("environment variable values must not contain NUL characters")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def normalize_proxy(value: object) -> str:
|
|
280
|
+
"""把用户输入的代理值规范化为 `inherit`、`off` 或 `scheme://host:port`。
|
|
281
|
+
|
|
282
|
+
只写端口号时展开为 http://127.0.0.1:<端口>。
|
|
283
|
+
带用户名密码的 URL 一律拒绝:启动命令是所有人可读的明文文件,写进去就泄露了。
|
|
284
|
+
"""
|
|
285
|
+
if not isinstance(value, str) or not value:
|
|
286
|
+
raise ValueError("proxy must be a port number, a URL, 'off' or 'inherit'")
|
|
287
|
+
if value in (PROXY_INHERIT, PROXY_OFF):
|
|
288
|
+
return value
|
|
289
|
+
if value.isdigit():
|
|
290
|
+
port = int(value)
|
|
291
|
+
_check_port(port)
|
|
292
|
+
return "http://127.0.0.1:{}".format(port)
|
|
293
|
+
# 既不是端口也不是 URL(如 `abc`、`127.0.0.1:7901`):urlsplit 会给出空的或奇怪的 scheme,
|
|
294
|
+
# 原来的“unsupported proxy scheme ''”让人看不懂,这里直接说明可以填什么。
|
|
295
|
+
if "://" not in value:
|
|
296
|
+
raise ValueError("invalid proxy {!r}: use a port number (e.g. 7901), a URL such as "
|
|
297
|
+
"http://127.0.0.1:7901, 'off' or 'inherit'".format(value))
|
|
298
|
+
parts = urllib.parse.urlsplit(value)
|
|
299
|
+
if parts.scheme not in PROXY_SCHEMES:
|
|
300
|
+
raise ValueError("unsupported proxy scheme {!r}; use one of {}".format(
|
|
301
|
+
parts.scheme, ", ".join(PROXY_SCHEMES)))
|
|
302
|
+
if parts.username is not None or parts.password is not None:
|
|
303
|
+
raise ValueError("proxy URL must not contain credentials")
|
|
304
|
+
if parts.path not in ("", "/") or parts.query or parts.fragment:
|
|
305
|
+
raise ValueError("proxy URL must not contain a path, query or fragment")
|
|
306
|
+
host = parts.hostname
|
|
307
|
+
if not host:
|
|
308
|
+
raise ValueError("proxy URL must contain a host")
|
|
309
|
+
try:
|
|
310
|
+
port = parts.port
|
|
311
|
+
except ValueError:
|
|
312
|
+
raise ValueError("proxy URL has an invalid port")
|
|
313
|
+
if port is None:
|
|
314
|
+
raise ValueError("proxy URL must contain a port")
|
|
315
|
+
_check_port(port)
|
|
316
|
+
if ":" in host:
|
|
317
|
+
host = "[{}]".format(host)
|
|
318
|
+
return "{}://{}:{}".format(parts.scheme, host, port)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _check_port(port: int) -> None:
|
|
322
|
+
if not 1 <= port <= 65535:
|
|
323
|
+
raise ValueError("proxy port must be between 1 and 65535, got {}".format(port))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def is_socks(proxy: str) -> bool:
|
|
327
|
+
return proxy.startswith("socks5://") or proxy.startswith("socks5h://")
|