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
multi_codex/cli.py
ADDED
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
"""命令行入口:解析参数、加锁、组装新配置,再交给收敛引擎或迁移模块。
|
|
2
|
+
|
|
3
|
+
退出码契约(方案 §5.2):0 成功或已是目标状态;1 运行错误;2 参数不合法;
|
|
4
|
+
3 存在冲突且未做任何修改;4 迁移源目录正被占用。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from typing import List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from . import (__version__, accounts, apps, binding, completion, doctor, identity, launcher, migrate, platform,
|
|
16
|
+
switch, usage)
|
|
17
|
+
from .actions import CREATE, DELETE, UNCHANGED, UPDATE, Action, error, hint, info, print_action, warn
|
|
18
|
+
from .config import (DEFAULT_SHARED_ITEMS, Account, Config, ConfigError, is_socks, load_config, normalize_proxy,
|
|
19
|
+
parse_config, validate_env_key, validate_env_value, validate_name)
|
|
20
|
+
from .fsutil import KIND_DIR, KIND_MISSING, display_path, entry_kind, expand
|
|
21
|
+
from .lock import LockBusyError, WriteLock
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UsageError(Exception):
|
|
25
|
+
"""命令行参数不合法,对应退出码 2。"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# 顶层帮助的子命令分组:新用户先看到上手要用的几条,进阶命令放最后。
|
|
29
|
+
# 这是子命令一句话说明的唯一来源:顶层分组列表与 `multi-codex <命令> -h` 的说明都从这里取;
|
|
30
|
+
# 新增子命令必须加进某一组,测试会核对这里与 parser 注册的子命令集合完全相等。
|
|
31
|
+
COMMAND_GROUPS = (
|
|
32
|
+
("Get started", (
|
|
33
|
+
("add", "add an account, adopt an existing directory, or change its options"),
|
|
34
|
+
("login", "log in to an account (runs codex login with its environment)"),
|
|
35
|
+
("migrate-default", "turn the default ~/.codex into a named account"),
|
|
36
|
+
("list", "show accounts, their status and who is logged in"),
|
|
37
|
+
("doctor", "check the installation, configuration and accounts (read-only)"),
|
|
38
|
+
)),
|
|
39
|
+
("Everyday", (
|
|
40
|
+
("run", "run a command with an account's environment (default: codex)"),
|
|
41
|
+
("usage", "show rate-limit usage of accounts"),
|
|
42
|
+
("use", "show or change the default account (what ~/.codex points to)"),
|
|
43
|
+
("bind", "bind a directory to an account (no arguments: list bindings)"),
|
|
44
|
+
("unbind", "remove the binding of a directory"),
|
|
45
|
+
("code", "open VS Code for an account (experimental)"),
|
|
46
|
+
("app", "open the Codex desktop app for an account (macOS, experimental)"),
|
|
47
|
+
("proxy", "set the proxy of an account"),
|
|
48
|
+
("path", "print an account's directory"),
|
|
49
|
+
("completion", "print a shell completion script"),
|
|
50
|
+
)),
|
|
51
|
+
("Advanced", (
|
|
52
|
+
("env", "list or change an account's extra environment variables"),
|
|
53
|
+
("remove", "unregister an account (its directory is kept)"),
|
|
54
|
+
("restore", "undo migrate-default: move an account back to ~/.codex"),
|
|
55
|
+
("apply", "converge all accounts to the configuration"),
|
|
56
|
+
("init", "create or update the global settings"),
|
|
57
|
+
)),
|
|
58
|
+
)
|
|
59
|
+
COMMAND_SUMMARY = {name: summary for _, commands in COMMAND_GROUPS for name, summary in commands}
|
|
60
|
+
|
|
61
|
+
HELP_EXAMPLES = """examples:
|
|
62
|
+
multi-codex add work create account "work" and its launcher codex-work
|
|
63
|
+
multi-codex login work log in once
|
|
64
|
+
multi-codex list who is logged in where
|
|
65
|
+
multi-codex use work make plain codex and the Dock apps use "work"
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _grouped_commands_text() -> str:
|
|
70
|
+
width = max(len(name) for name in COMMAND_SUMMARY) + 2
|
|
71
|
+
lines = []
|
|
72
|
+
for title, commands in COMMAND_GROUPS:
|
|
73
|
+
lines.append("{}:".format(title))
|
|
74
|
+
lines.extend(" {}{}".format(name.ljust(width), summary) for name, summary in commands)
|
|
75
|
+
lines.append("")
|
|
76
|
+
return "\n".join(lines).rstrip("\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
80
|
+
# 子命令不传 help=:argparse 只为带 help 的子命令生成自动列表,去掉后由 description 里的分组列表代替。
|
|
81
|
+
# RawDescriptionHelpFormatter 保留分组列表与示例的换行和对齐。
|
|
82
|
+
parser = argparse.ArgumentParser(
|
|
83
|
+
prog="multi-codex",
|
|
84
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
85
|
+
description="Manage multiple Codex CLI accounts: separate CODEX_HOME directories,\n"
|
|
86
|
+
"per-account launchers and per-account proxies.\n\n" + _grouped_commands_text(),
|
|
87
|
+
epilog=HELP_EXAMPLES)
|
|
88
|
+
parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
|
|
89
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
90
|
+
sub.required = True
|
|
91
|
+
|
|
92
|
+
p_init = sub.add_parser("init", description=COMMAND_SUMMARY["init"])
|
|
93
|
+
p_init.add_argument("--root", help="directory that holds account directories (default ~/.cx)")
|
|
94
|
+
p_init.add_argument("--bin-dir", help="directory for codex-<name> launchers (default ~/.local/bin)")
|
|
95
|
+
p_init.add_argument("--shared-dir", help="directory whose items can be linked into accounts")
|
|
96
|
+
p_init.add_argument("--shared-items", help="comma-separated items to share "
|
|
97
|
+
"(default {})".format(",".join(DEFAULT_SHARED_ITEMS)))
|
|
98
|
+
_add_dry_run(p_init)
|
|
99
|
+
_add_verbose(p_init)
|
|
100
|
+
|
|
101
|
+
p_mig = sub.add_parser("migrate-default", description=COMMAND_SUMMARY["migrate-default"])
|
|
102
|
+
# 省略时从源目录的 auth.json 读邮箱作为账号名(_derive_migrate_name)。
|
|
103
|
+
p_mig.add_argument("name", nargs="?")
|
|
104
|
+
p_mig.add_argument("--source", help="directory to migrate (default ~/.codex)")
|
|
105
|
+
p_mig.add_argument("--copy", action="store_true",
|
|
106
|
+
help="copy and verify instead of renaming (used automatically across file systems)")
|
|
107
|
+
p_mig.add_argument("--keep-backup", action="store_true",
|
|
108
|
+
help="in copy mode, keep the original directory as a backup")
|
|
109
|
+
p_mig.add_argument("--proxy", help="proxy for the new account: port, URL, off or inherit")
|
|
110
|
+
p_mig.add_argument("--skip-process-check", action="store_true",
|
|
111
|
+
help="do not check whether Codex is using the source directory")
|
|
112
|
+
p_mig.add_argument("--accept-relogin", action="store_true",
|
|
113
|
+
help="migrate even though credentials are in the system keyring; "
|
|
114
|
+
"you will need to log in again afterwards")
|
|
115
|
+
_add_dry_run(p_mig)
|
|
116
|
+
|
|
117
|
+
p_add = sub.add_parser("add", description=COMMAND_SUMMARY["add"])
|
|
118
|
+
p_add.add_argument("name")
|
|
119
|
+
p_add.add_argument("--proxy", help="port, URL, off or inherit (new accounts default to inherit)")
|
|
120
|
+
shared_group = p_add.add_mutually_exclusive_group()
|
|
121
|
+
shared_group.add_argument("--shared", dest="shared", action="store_true", default=None,
|
|
122
|
+
help="link shared items into this account")
|
|
123
|
+
shared_group.add_argument("--no-shared", dest="shared", action="store_false",
|
|
124
|
+
help="do not link shared items (default for new accounts)")
|
|
125
|
+
p_add.add_argument("--config-from", metavar="OTHER",
|
|
126
|
+
help="copy config.toml from account OTHER (once; existing different content is a conflict)")
|
|
127
|
+
p_add.add_argument("--adopt", action="store_true",
|
|
128
|
+
help="take over existing links that already point to the shared items, "
|
|
129
|
+
"so that turning sharing off later removes them too")
|
|
130
|
+
_add_dry_run(p_add)
|
|
131
|
+
_add_verbose(p_add)
|
|
132
|
+
|
|
133
|
+
p_proxy = sub.add_parser("proxy", description=COMMAND_SUMMARY["proxy"])
|
|
134
|
+
p_proxy.add_argument("name")
|
|
135
|
+
p_proxy.add_argument("value", help="port (e.g. 7901), URL, off or inherit")
|
|
136
|
+
_add_dry_run(p_proxy)
|
|
137
|
+
_add_verbose(p_proxy)
|
|
138
|
+
|
|
139
|
+
p_remove = sub.add_parser("remove", description=COMMAND_SUMMARY["remove"])
|
|
140
|
+
p_remove.add_argument("name")
|
|
141
|
+
_add_dry_run(p_remove)
|
|
142
|
+
_add_verbose(p_remove)
|
|
143
|
+
|
|
144
|
+
p_apply = sub.add_parser("apply", description=COMMAND_SUMMARY["apply"])
|
|
145
|
+
p_apply.add_argument("-f", "--file", help="use this file as the new configuration")
|
|
146
|
+
_add_dry_run(p_apply)
|
|
147
|
+
_add_verbose(p_apply)
|
|
148
|
+
|
|
149
|
+
p_list = sub.add_parser("list", description=COMMAND_SUMMARY["list"])
|
|
150
|
+
_add_json(p_list)
|
|
151
|
+
|
|
152
|
+
p_usage = sub.add_parser("usage", description=COMMAND_SUMMARY["usage"])
|
|
153
|
+
p_usage.add_argument("names", nargs="*", metavar="NAME", help="accounts to show (default: all)")
|
|
154
|
+
p_usage.add_argument("--live", action="store_true",
|
|
155
|
+
help="ask Codex (codex app-server, through the account's launcher) for live usage "
|
|
156
|
+
"instead of reading the last snapshot from local session logs")
|
|
157
|
+
p_usage.add_argument("--timeout", type=_positive_seconds, default=usage.DEFAULT_LIVE_TIMEOUT_SEC,
|
|
158
|
+
help="total time limit per account for --live, in seconds (default 30)")
|
|
159
|
+
_add_json(p_usage)
|
|
160
|
+
|
|
161
|
+
p_doctor = sub.add_parser("doctor", description=COMMAND_SUMMARY["doctor"])
|
|
162
|
+
_add_json(p_doctor)
|
|
163
|
+
|
|
164
|
+
p_comp = sub.add_parser("completion", description=COMMAND_SUMMARY["completion"])
|
|
165
|
+
p_comp.add_argument("shell", nargs="?", choices=completion.SHELLS)
|
|
166
|
+
# 补全脚本在按 Tab 时调用它读取账号名;不是给人用的,所以不出现在帮助里。
|
|
167
|
+
p_comp.add_argument("--list-accounts", action="store_true", help=argparse.SUPPRESS)
|
|
168
|
+
|
|
169
|
+
# 不依赖 PATH:启动命令目录还没加进 PATH 时,codex-<名> login 找不到,这条照样能用。
|
|
170
|
+
p_login = sub.add_parser("login", description=COMMAND_SUMMARY["login"],
|
|
171
|
+
usage="multi-codex login NAME [-- ARGS ...]")
|
|
172
|
+
p_login.add_argument("name")
|
|
173
|
+
|
|
174
|
+
p_run = sub.add_parser("run", description=COMMAND_SUMMARY["run"],
|
|
175
|
+
usage="multi-codex run [NAME] [-- COMMAND [ARG ...]]")
|
|
176
|
+
# 省略 NAME 时按当前目录的绑定选账号(bind)。
|
|
177
|
+
p_run.add_argument("name", nargs="?")
|
|
178
|
+
|
|
179
|
+
p_bind = sub.add_parser("bind", description=COMMAND_SUMMARY["bind"])
|
|
180
|
+
p_bind.add_argument("name", nargs="?")
|
|
181
|
+
p_bind.add_argument("dir", nargs="?", help="directory to bind (default: current directory)")
|
|
182
|
+
_add_dry_run(p_bind)
|
|
183
|
+
_add_verbose(p_bind)
|
|
184
|
+
|
|
185
|
+
p_unbind = sub.add_parser("unbind", description=COMMAND_SUMMARY["unbind"])
|
|
186
|
+
p_unbind.add_argument("dir", nargs="?", help="directory to unbind (default: current directory)")
|
|
187
|
+
_add_dry_run(p_unbind)
|
|
188
|
+
_add_verbose(p_unbind)
|
|
189
|
+
|
|
190
|
+
p_code = sub.add_parser("code", description=COMMAND_SUMMARY["code"],
|
|
191
|
+
usage="multi-codex code NAME [PATH] [--bin CODE] [-- CODE_ARGS ...]")
|
|
192
|
+
p_code.add_argument("name")
|
|
193
|
+
p_code.add_argument("path", nargs="?")
|
|
194
|
+
p_code.add_argument("--bin", help="path of the VS Code `code` command (default: `code` on PATH)")
|
|
195
|
+
|
|
196
|
+
p_app = sub.add_parser("app", description=COMMAND_SUMMARY["app"])
|
|
197
|
+
p_app.add_argument("name")
|
|
198
|
+
p_app.add_argument("--app", help="path of the Codex desktop app (default {})".format(apps.DEFAULT_DESKTOP_APP))
|
|
199
|
+
|
|
200
|
+
p_path = sub.add_parser("path", description=COMMAND_SUMMARY["path"])
|
|
201
|
+
p_path.add_argument("name")
|
|
202
|
+
|
|
203
|
+
p_env = sub.add_parser("env", description=COMMAND_SUMMARY["env"])
|
|
204
|
+
p_env.add_argument("name")
|
|
205
|
+
p_env.add_argument("assignments", nargs="*", metavar="KEY=VALUE", help="variables to set")
|
|
206
|
+
p_env.add_argument("--unset", action="append", default=[], metavar="KEY", help="variable to remove")
|
|
207
|
+
p_env.add_argument("--clear", action="store_true", help="remove all variables of the account")
|
|
208
|
+
_add_dry_run(p_env)
|
|
209
|
+
_add_verbose(p_env)
|
|
210
|
+
|
|
211
|
+
p_use = sub.add_parser("use", description=COMMAND_SUMMARY["use"])
|
|
212
|
+
p_use.add_argument("name", nargs="?")
|
|
213
|
+
p_use.add_argument("--skip-process-check", action="store_true",
|
|
214
|
+
help="switch even if a process may be using the current default account")
|
|
215
|
+
_add_dry_run(p_use)
|
|
216
|
+
|
|
217
|
+
p_restore = sub.add_parser("restore", description=COMMAND_SUMMARY["restore"])
|
|
218
|
+
p_restore.add_argument("name")
|
|
219
|
+
p_restore.add_argument("--skip-process-check", action="store_true",
|
|
220
|
+
help="do not check whether the account directory is in use")
|
|
221
|
+
p_restore.add_argument("--accept-relogin", action="store_true",
|
|
222
|
+
help="restore even though credentials are in the system keyring; "
|
|
223
|
+
"you will need to log in again afterwards")
|
|
224
|
+
_add_dry_run(p_restore)
|
|
225
|
+
return parser
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _add_json(parser: argparse.ArgumentParser) -> None:
|
|
229
|
+
parser.add_argument("--json", action="store_true",
|
|
230
|
+
help="print one JSON object on stdout; warnings still go to stderr")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _positive_seconds(value: str) -> float:
|
|
234
|
+
try:
|
|
235
|
+
seconds = float(value)
|
|
236
|
+
except ValueError:
|
|
237
|
+
raise argparse.ArgumentTypeError("must be a number of seconds")
|
|
238
|
+
if not seconds > 0 or seconds == float("inf"):
|
|
239
|
+
raise argparse.ArgumentTypeError("must be greater than 0")
|
|
240
|
+
return seconds
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _add_dry_run(parser: argparse.ArgumentParser) -> None:
|
|
244
|
+
parser.add_argument("--dry-run", action="store_true", help="show the planned actions without changing anything")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _add_verbose(parser: argparse.ArgumentParser) -> None:
|
|
248
|
+
# 只加给经 dispatch 末尾 converge 输出动作行的写命令;默认不打印 unchanged 行。
|
|
249
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="also show items that are unchanged")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _split_run_command(argv: List[str]) -> Tuple[List[str], List[str]]:
|
|
253
|
+
"""`run NAME -- CMD ...`:在第一个 `--` 处分开,`--` 之后原样作为要运行的命令。
|
|
254
|
+
|
|
255
|
+
不交给 argparse 的 REMAINDER:不同 Python 版本对 `--` 的处理不一致(3.9 起会吞掉第一个 `--`),
|
|
256
|
+
`run a -- -- x` 这种命令就会被解析成不同的样子。
|
|
257
|
+
"""
|
|
258
|
+
if argv[:1] in (["run"], ["code"], ["login"]) and "--" in argv:
|
|
259
|
+
index = argv.index("--")
|
|
260
|
+
return argv[:index], argv[index + 1:]
|
|
261
|
+
return argv, []
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
265
|
+
parser = build_parser()
|
|
266
|
+
argv, run_command = _split_run_command(list(sys.argv[1:] if argv is None else argv))
|
|
267
|
+
if not argv:
|
|
268
|
+
# 不带任何参数:新用户最常见的第一次运行,给上手指引而不是 argparse 的“缺少 COMMAND”报错。
|
|
269
|
+
print_getting_started()
|
|
270
|
+
return accounts.EXIT_OK
|
|
271
|
+
try:
|
|
272
|
+
args = parser.parse_args(argv)
|
|
273
|
+
except SystemExit as exc:
|
|
274
|
+
# argparse 在参数错误时以 2 退出、--help/--version 以 0 退出,与本工具的退出码契约一致。
|
|
275
|
+
return int(exc.code or 0)
|
|
276
|
+
|
|
277
|
+
if not platform.is_supported():
|
|
278
|
+
error("this platform is not supported yet (macOS and Linux only)")
|
|
279
|
+
return accounts.EXIT_ERROR
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
# 只读命令,与迁移无关:放在读取事务记录之前分派,记录损坏时 doctor 才能把它报出来。
|
|
283
|
+
if args.command == "usage":
|
|
284
|
+
return cmd_usage(args)
|
|
285
|
+
if args.command == "doctor":
|
|
286
|
+
return cmd_doctor(args)
|
|
287
|
+
if args.command == "completion":
|
|
288
|
+
return cmd_completion(args, parser)
|
|
289
|
+
if args.command == "run":
|
|
290
|
+
return cmd_run(args.name, run_command)
|
|
291
|
+
if args.command == "login":
|
|
292
|
+
# `--` 之后的参数原样交给 codex login,本工具不校验(取决于上游支持哪些参数)。
|
|
293
|
+
return cmd_run(args.name, ["codex", "login"] + run_command)
|
|
294
|
+
if args.command == "code":
|
|
295
|
+
return cmd_code(args, run_command)
|
|
296
|
+
if args.command == "app":
|
|
297
|
+
return cmd_app(args)
|
|
298
|
+
if args.command == "path":
|
|
299
|
+
return cmd_path(args.name)
|
|
300
|
+
notice = migrate.pending_journal_notice()
|
|
301
|
+
if notice:
|
|
302
|
+
warn(notice)
|
|
303
|
+
restore_notice = switch.pending_restore_notice()
|
|
304
|
+
if restore_notice:
|
|
305
|
+
warn(restore_notice)
|
|
306
|
+
if args.command == "list":
|
|
307
|
+
return cmd_list(args.json)
|
|
308
|
+
# 以下两种只读模式不加锁,未完成的迁移或 restore 也不拦。
|
|
309
|
+
if args.command == "env" and not (args.assignments or args.unset or args.clear):
|
|
310
|
+
return cmd_env_list(args.name)
|
|
311
|
+
if args.command == "use" and args.name is None:
|
|
312
|
+
return cmd_use_show()
|
|
313
|
+
if args.command == "bind" and args.name is None:
|
|
314
|
+
return cmd_bind_list()
|
|
315
|
+
if _blocked_by_migration(args, notice):
|
|
316
|
+
return accounts.EXIT_ERROR
|
|
317
|
+
with WriteLock():
|
|
318
|
+
# 加锁前的检查与加锁之间,另一条命令可能刚开始一次迁移或 restore,拿到锁后再确认一次。
|
|
319
|
+
if _blocked_by_migration(args, migrate.pending_journal_notice()):
|
|
320
|
+
return accounts.EXIT_ERROR
|
|
321
|
+
return dispatch(args)
|
|
322
|
+
except migrate.JournalError as exc:
|
|
323
|
+
error("{}. Check where the complete data of the unfinished migration is (the source, "
|
|
324
|
+
"the target account directory, or a *.multi-codex-bak.* backup next to the source), "
|
|
325
|
+
"make sure it is back at the source path, then delete the journal file".format(exc),
|
|
326
|
+
phase="resume")
|
|
327
|
+
return accounts.EXIT_ERROR
|
|
328
|
+
except switch.RestoreJournalError as exc:
|
|
329
|
+
error("{}. Check whether the account data is at ~/.codex or in the account directory, "
|
|
330
|
+
"then delete the marker file".format(exc), phase="restore")
|
|
331
|
+
return accounts.EXIT_ERROR
|
|
332
|
+
except LockBusyError as exc:
|
|
333
|
+
error("another multi-codex command is running (pid {})".format(exc.holder_pid or "unknown"))
|
|
334
|
+
return accounts.EXIT_ERROR
|
|
335
|
+
except UsageError as exc:
|
|
336
|
+
error(str(exc))
|
|
337
|
+
return accounts.EXIT_USAGE
|
|
338
|
+
except ConfigError as exc:
|
|
339
|
+
error(str(exc), phase="config")
|
|
340
|
+
return accounts.EXIT_ERROR
|
|
341
|
+
except OSError as exc:
|
|
342
|
+
error("{}: {}".format(type(exc).__name__, exc))
|
|
343
|
+
return accounts.EXIT_ERROR
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _blocked_by_migration(args: argparse.Namespace, notice: Optional[str]) -> bool:
|
|
347
|
+
# 迁移中途禁止其它写命令,防止根目录或账号在迁移过程中被改动。
|
|
348
|
+
if notice and args.command != "migrate-default" and not args.dry_run:
|
|
349
|
+
error("finish the unfinished migration before running `{}`".format(args.command))
|
|
350
|
+
return True
|
|
351
|
+
# restore 做到一半时 ~/.codex 可能不存在或是普通目录,这时 use 会新建它、migrate-default 的预检也能通过,
|
|
352
|
+
# restore 就再也无法完成。每次都重新读取标记:拿到锁之后的第二次检查也要看到别的命令刚写出的标记。
|
|
353
|
+
journal = switch.load_restore_journal()
|
|
354
|
+
if journal and not args.dry_run and not (
|
|
355
|
+
args.command == "restore" and args.name.casefold() == journal["name"].casefold()):
|
|
356
|
+
error("finish the unfinished restore first: rerun `multi-codex restore {}`".format(journal["name"]))
|
|
357
|
+
return True
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
362
|
+
if args.command == "migrate-default":
|
|
363
|
+
name = _checked_name(args.name if args.name is not None else _derive_migrate_name(args.source))
|
|
364
|
+
proxy = _checked_proxy(args.proxy) if args.proxy is not None else None
|
|
365
|
+
code = migrate.migrate_default(name, args.source, args.copy, args.keep_backup, proxy,
|
|
366
|
+
args.skip_process_check, args.dry_run,
|
|
367
|
+
accept_relogin=args.accept_relogin)
|
|
368
|
+
if code == accounts.EXIT_OK and not args.dry_run:
|
|
369
|
+
# 迁移过来的目录本来就带着登录,只提醒启动命令目录是否在 PATH 中。
|
|
370
|
+
_hint_after_setup(load_config()[0], name, check_login=False)
|
|
371
|
+
return code
|
|
372
|
+
|
|
373
|
+
old, exists = load_config()
|
|
374
|
+
new = old.copy()
|
|
375
|
+
orphan_scope = accounts.NO_ORPHANS
|
|
376
|
+
adopt_accounts = frozenset()
|
|
377
|
+
extra_actions: List = []
|
|
378
|
+
# bind / unbind 的变化行:不放进 plan(execute 把第一项当配置动作,doctor 也调用 plan),
|
|
379
|
+
# 等 converge 成功后再打印,避免冲突时用户看到 create 却什么都没写。
|
|
380
|
+
binding_change: Optional[Action] = None
|
|
381
|
+
|
|
382
|
+
if args.command == "init":
|
|
383
|
+
if args.root:
|
|
384
|
+
new.root = args.root
|
|
385
|
+
if args.bin_dir:
|
|
386
|
+
new.bin_dir = args.bin_dir
|
|
387
|
+
if args.shared_dir:
|
|
388
|
+
new.shared_dir = args.shared_dir
|
|
389
|
+
if args.shared_items is not None:
|
|
390
|
+
items = [item.strip() for item in args.shared_items.split(",") if item.strip()]
|
|
391
|
+
if any(item in (".", "..") or "/" in item for item in items):
|
|
392
|
+
raise UsageError("--shared-items must be plain names without '/'")
|
|
393
|
+
new.shared_items = items
|
|
394
|
+
elif args.command == "add":
|
|
395
|
+
name = _checked_name(args.name)
|
|
396
|
+
account = new.find(name)
|
|
397
|
+
if account is None:
|
|
398
|
+
account = Account(name)
|
|
399
|
+
new.accounts[name] = account
|
|
400
|
+
if args.proxy is not None:
|
|
401
|
+
account.proxy = _checked_proxy(args.proxy)
|
|
402
|
+
if args.shared is not None:
|
|
403
|
+
account.shared = args.shared
|
|
404
|
+
if args.adopt:
|
|
405
|
+
# 接管只对开启了共享的账号有意义;关闭状态下工具本来就不管这些软链。
|
|
406
|
+
if not account.shared:
|
|
407
|
+
raise UsageError("--adopt requires sharing to be on for {!r}; add --shared".format(account.name))
|
|
408
|
+
adopt_accounts = frozenset([account.name.casefold()])
|
|
409
|
+
if args.config_from:
|
|
410
|
+
extra_actions = _config_copy_actions(new, account, args.config_from)
|
|
411
|
+
if extra_actions is None:
|
|
412
|
+
return accounts.EXIT_ERROR
|
|
413
|
+
elif args.command == "proxy":
|
|
414
|
+
account = new.find(_checked_name(args.name))
|
|
415
|
+
if account is None:
|
|
416
|
+
error(new.not_registered(args.name))
|
|
417
|
+
return accounts.EXIT_ERROR
|
|
418
|
+
account.proxy = _checked_proxy(args.value)
|
|
419
|
+
elif args.command == "remove":
|
|
420
|
+
name = _checked_name(args.name)
|
|
421
|
+
account = new.find(name)
|
|
422
|
+
if account is None:
|
|
423
|
+
# 退出码仍是 0(已处于目标状态);下面照常收敛,负责清理这个名字残留的受管启动命令。
|
|
424
|
+
info("nothing to remove: {}".format(new.not_registered(name)))
|
|
425
|
+
else:
|
|
426
|
+
del new.accounts[account.name]
|
|
427
|
+
binding.drop_account(new, account.name)
|
|
428
|
+
orphan_scope = frozenset([name.casefold()])
|
|
429
|
+
elif args.command == "apply":
|
|
430
|
+
orphan_scope = accounts.ALL_ORPHANS
|
|
431
|
+
if args.file:
|
|
432
|
+
new = _load_apply_file(args.file, old)
|
|
433
|
+
elif args.command == "env":
|
|
434
|
+
name = _checked_name(args.name)
|
|
435
|
+
account = new.find(name)
|
|
436
|
+
if account is None:
|
|
437
|
+
error(new.not_registered(name))
|
|
438
|
+
return accounts.EXIT_ERROR
|
|
439
|
+
_apply_env_changes(account, args)
|
|
440
|
+
elif args.command == "bind":
|
|
441
|
+
account = new.find(_checked_name(args.name))
|
|
442
|
+
if account is None:
|
|
443
|
+
error(new.not_registered(args.name))
|
|
444
|
+
return accounts.EXIT_ERROR
|
|
445
|
+
target = args.dir or os.getcwd()
|
|
446
|
+
if not os.path.isdir(target):
|
|
447
|
+
error("not a directory: {}".format(target))
|
|
448
|
+
return accounts.EXIT_ERROR
|
|
449
|
+
key = binding.normalize_dir(target)
|
|
450
|
+
previous = new.bindings.get(key)
|
|
451
|
+
new.bindings[key] = account.name
|
|
452
|
+
status = UNCHANGED if previous == account.name else (UPDATE if previous else CREATE)
|
|
453
|
+
binding_change = Action(status, "binding", key, "-> " + account.name)
|
|
454
|
+
elif args.command == "unbind":
|
|
455
|
+
target = args.dir or os.getcwd()
|
|
456
|
+
key = binding.normalize_dir(target)
|
|
457
|
+
if not exists or key not in new.bindings:
|
|
458
|
+
info("not bound: {}".format(key))
|
|
459
|
+
effective = binding.resolve(new, target) if exists else None
|
|
460
|
+
if effective is not None:
|
|
461
|
+
info("the effective binding is {} -> {}".format(*effective))
|
|
462
|
+
return accounts.EXIT_OK
|
|
463
|
+
binding_change = Action(DELETE, "binding", key, "-> " + new.bindings.pop(key))
|
|
464
|
+
elif args.command == "use":
|
|
465
|
+
return switch.use_account(old, _checked_name(args.name), args.skip_process_check, args.dry_run)
|
|
466
|
+
elif args.command == "restore":
|
|
467
|
+
return switch.restore_account(old, exists, _checked_name(args.name), args.skip_process_check,
|
|
468
|
+
args.accept_relogin, args.dry_run)
|
|
469
|
+
|
|
470
|
+
code = accounts.converge(old, new, config_exists=exists, dry_run=args.dry_run,
|
|
471
|
+
orphan_scope=orphan_scope, adopt_accounts=adopt_accounts, extra_actions=extra_actions,
|
|
472
|
+
verbose=args.verbose)
|
|
473
|
+
if binding_change is not None:
|
|
474
|
+
# 绑定没变时配置也没变,execute 已经说了 already up to date,非 verbose 不再重复。
|
|
475
|
+
if code == accounts.EXIT_OK and (args.verbose or binding_change.status != UNCHANGED):
|
|
476
|
+
print_action(binding_change, dry_run=args.dry_run)
|
|
477
|
+
elif code != accounts.EXIT_OK:
|
|
478
|
+
info("binding not changed because of the errors above")
|
|
479
|
+
if args.command == "add" and code == accounts.EXIT_OK and not args.dry_run:
|
|
480
|
+
_hint_after_setup(new, args.name, check_login=True)
|
|
481
|
+
return code
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# identity.read_identity 的 login 取值 → 读不出邮箱的原因(给 _derive_migrate_name 的报错用)。
|
|
485
|
+
_NO_EMAIL_REASONS = {
|
|
486
|
+
identity.LOGIN_LOGGED_OUT: "not logged in",
|
|
487
|
+
identity.LOGIN_APIKEY: "logged in with an API key (no e-mail)",
|
|
488
|
+
identity.LOGIN_KEYRING: "credentials are in the system keyring",
|
|
489
|
+
identity.LOGIN_UNREADABLE: "auth.json cannot be read",
|
|
490
|
+
identity.LOGIN_CHATGPT: "the login has no e-mail",
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _derive_migrate_name(source: Optional[str]) -> str:
|
|
495
|
+
"""`migrate-default` 省略 NAME 时推导账号名(feature-onboarding-commands B2)。
|
|
496
|
+
|
|
497
|
+
有未完成的迁移时用记录里的名称:中途 ~/.codex 可能已被移走、读不到 auth.json,
|
|
498
|
+
而续跑本来就必须用记录里的名称(migrate._resume 会核对)。
|
|
499
|
+
否则读源目录 auth.json 里的邮箱;读不到或不是合法账号名时抛 UsageError(退出码 2),
|
|
500
|
+
什么都不动——不把邮箱改写成“合法”的名字,生成的名字用户预料不到,还可能撞上已有账号。
|
|
501
|
+
"""
|
|
502
|
+
journal = migrate.load_journal()
|
|
503
|
+
if journal is not None:
|
|
504
|
+
return journal["name"]
|
|
505
|
+
source_path = expand(source or platform.default_source())
|
|
506
|
+
found = identity.read_identity(source_path)
|
|
507
|
+
reason = _NO_EMAIL_REASONS.get(found.login, "unrecognized login type")
|
|
508
|
+
if found.login == identity.LOGIN_CHATGPT and found.email:
|
|
509
|
+
try:
|
|
510
|
+
validate_name(found.email)
|
|
511
|
+
except ValueError:
|
|
512
|
+
reason = "e-mail {} is not a valid account name".format(found.email)
|
|
513
|
+
else:
|
|
514
|
+
info("using account name {!r} from {}".format(
|
|
515
|
+
found.email, display_path(os.path.join(source_path, "auth.json"))))
|
|
516
|
+
return found.email
|
|
517
|
+
raise UsageError("cannot tell the account name from {}: {}; pass a NAME, "
|
|
518
|
+
"e.g. multi-codex migrate-default main".format(display_path(source_path), reason))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _hint_after_setup(config: Config, name: str, check_login: bool) -> None:
|
|
522
|
+
"""add / migrate-default 成功后告诉用户下一步:未登录时怎么登录,启动命令目录不在 PATH 时怎么加。
|
|
523
|
+
|
|
524
|
+
只读:读一次 auth.json、扫描一次 PATH,不改任何文件,也不影响退出码。
|
|
525
|
+
"""
|
|
526
|
+
account = config.find(name)
|
|
527
|
+
if account is None:
|
|
528
|
+
return
|
|
529
|
+
bin_dir = expand(config.bin_dir)
|
|
530
|
+
on_path = platform.dir_on_path(bin_dir)
|
|
531
|
+
if check_login:
|
|
532
|
+
found = identity.read_identity(accounts.account_dir(config, account.name))
|
|
533
|
+
# 只提示“未登录”;keyring、unreadable 等情况交给 doctor,那里有完整说明。
|
|
534
|
+
if found.login == identity.LOGIN_LOGGED_OUT:
|
|
535
|
+
# 用 multi-codex login 而不是 codex-<名> login:前者不依赖 PATH,两种情况都能照做。
|
|
536
|
+
hint("next: log in with `multi-codex login {}`".format(account.name))
|
|
537
|
+
if not on_path:
|
|
538
|
+
sys.stdout.flush() # 同 hint():让警告排在动作行之后
|
|
539
|
+
warn("{} is not on PATH, so `codex-{}` will not be found; add it in your shell profile, "
|
|
540
|
+
"e.g. export PATH=\"{}:$PATH\"".format(display_path(bin_dir), account.name, bin_dir))
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def print_getting_started() -> None:
|
|
544
|
+
"""不带参数运行时的上手指引(stdout,退出码 0)。
|
|
545
|
+
|
|
546
|
+
配置读不了时不报错,只给通用步骤:这里是新用户的第一眼,不能因为配置问题变成报错;
|
|
547
|
+
配置问题由 `multi-codex doctor` 负责说明。
|
|
548
|
+
"""
|
|
549
|
+
try:
|
|
550
|
+
config, exists = load_config()
|
|
551
|
+
except (ConfigError, OSError):
|
|
552
|
+
config, exists = None, False
|
|
553
|
+
print("multi-codex: run several Codex accounts side by side.")
|
|
554
|
+
print()
|
|
555
|
+
names = list(config.accounts) if config is not None and exists else []
|
|
556
|
+
if names:
|
|
557
|
+
print("Accounts: {} (multi-codex list for details)".format(", ".join(names)))
|
|
558
|
+
print()
|
|
559
|
+
print(" codex-NAME use an account instead of codex")
|
|
560
|
+
print(" multi-codex usage 5-hour and weekly usage")
|
|
561
|
+
print(" multi-codex doctor find problems and how to fix them")
|
|
562
|
+
else:
|
|
563
|
+
print("Get started:")
|
|
564
|
+
print(" multi-codex add NAME create an account and its launcher codex-NAME")
|
|
565
|
+
print(" multi-codex login NAME log in once")
|
|
566
|
+
print(" codex-NAME use it instead of codex")
|
|
567
|
+
if entry_kind(switch.default_link()) == KIND_DIR:
|
|
568
|
+
print(" multi-codex migrate-default [NAME] keep your current ~/.codex login as an account")
|
|
569
|
+
print()
|
|
570
|
+
print("Run `multi-codex -h` for all commands.")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _config_copy_actions(new: Config, account: Account, other_name: str) -> Optional[List]:
|
|
574
|
+
"""校验 --config-from 并生成复制动作;校验失败时输出原因并返回 None(退出码 1),参数错误抛 UsageError。"""
|
|
575
|
+
other = new.find(_checked_name(other_name))
|
|
576
|
+
if other is None:
|
|
577
|
+
error(new.not_registered(other_name))
|
|
578
|
+
return None
|
|
579
|
+
if other.name.casefold() == account.name.casefold():
|
|
580
|
+
raise UsageError("--config-from must name another account")
|
|
581
|
+
source = os.path.join(accounts.account_dir(new, other.name), "config.toml")
|
|
582
|
+
if not os.path.isfile(source):
|
|
583
|
+
error("account {!r} has no config.toml".format(other.name), path=source)
|
|
584
|
+
return None
|
|
585
|
+
with open(source, "rb") as handle:
|
|
586
|
+
raw = handle.read()
|
|
587
|
+
try:
|
|
588
|
+
# TOML 规定必须是 UTF-8;不是的话 Codex 自己也读不了,复制没有意义。
|
|
589
|
+
content = raw.decode("utf-8")
|
|
590
|
+
except UnicodeDecodeError:
|
|
591
|
+
error("config.toml of {!r} is not valid UTF-8".format(other.name), path=source)
|
|
592
|
+
return None
|
|
593
|
+
return accounts.plan_config_copy(new, account, content, other.name)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _apply_env_changes(account: Account, args: argparse.Namespace) -> None:
|
|
597
|
+
"""按命令行参数修改账号的环境变量;参数不合法时抛 UsageError(退出码 2),什么都不改。"""
|
|
598
|
+
if args.clear and (args.assignments or args.unset):
|
|
599
|
+
raise UsageError("--clear cannot be combined with KEY=VALUE or --unset")
|
|
600
|
+
updates = {}
|
|
601
|
+
for assignment in args.assignments:
|
|
602
|
+
if "=" not in assignment:
|
|
603
|
+
raise UsageError("expected KEY=VALUE, got {!r}".format(assignment))
|
|
604
|
+
key, value = assignment.split("=", 1)
|
|
605
|
+
try:
|
|
606
|
+
validate_env_key(key)
|
|
607
|
+
validate_env_value(value)
|
|
608
|
+
except ValueError as exc:
|
|
609
|
+
raise UsageError(str(exc))
|
|
610
|
+
updates[key] = value
|
|
611
|
+
for key in args.unset:
|
|
612
|
+
try:
|
|
613
|
+
validate_env_key(key)
|
|
614
|
+
except ValueError as exc:
|
|
615
|
+
raise UsageError(str(exc))
|
|
616
|
+
if key in updates:
|
|
617
|
+
raise UsageError("{} is both set and unset in the same command".format(key))
|
|
618
|
+
if args.clear:
|
|
619
|
+
account.env = {}
|
|
620
|
+
return
|
|
621
|
+
account.env.update(updates)
|
|
622
|
+
for key in args.unset:
|
|
623
|
+
account.env.pop(key, None)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _load_apply_file(path: str, old: Config) -> Config:
|
|
627
|
+
"""读取 apply -f 的文件;managed_links 是工具内部状态,一律沿用当前配置里的值。"""
|
|
628
|
+
try:
|
|
629
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
630
|
+
raw = handle.read()
|
|
631
|
+
except OSError as exc:
|
|
632
|
+
raise ConfigError("cannot read {}: {}".format(path, exc))
|
|
633
|
+
new = parse_config(raw, path)
|
|
634
|
+
for account in new.accounts.values():
|
|
635
|
+
_warn_if_socks(account.proxy, account.name)
|
|
636
|
+
current = old.find(account.name)
|
|
637
|
+
account.managed_links = list(current.managed_links) if current else []
|
|
638
|
+
# 绑定是本机状态,沿用当前值;文件中已删除的账号,它的绑定一并删除。
|
|
639
|
+
new.bindings = dict(old.bindings)
|
|
640
|
+
for name in set(new.bindings.values()):
|
|
641
|
+
if new.find(name) is None:
|
|
642
|
+
binding.drop_account(new, name)
|
|
643
|
+
return new
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _checked_name(name: str) -> str:
|
|
647
|
+
try:
|
|
648
|
+
validate_name(name)
|
|
649
|
+
except ValueError as exc:
|
|
650
|
+
raise UsageError(str(exc))
|
|
651
|
+
return name
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _checked_proxy(value: str) -> str:
|
|
655
|
+
try:
|
|
656
|
+
proxy = normalize_proxy(value)
|
|
657
|
+
except ValueError as exc:
|
|
658
|
+
raise UsageError(str(exc))
|
|
659
|
+
_warn_if_socks(proxy)
|
|
660
|
+
return proxy
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _warn_if_socks(proxy: str, account: Optional[str] = None) -> None:
|
|
664
|
+
"""socks 地址照常接受,但要提醒:实测 codex-cli 0.159.0 拿到 socks5h 地址时,
|
|
665
|
+
14 个连接里有 13 个仍按 HTTP CONNECT 发往该端口(见方案 §10)。
|
|
666
|
+
只支持 SOCKS 的端口会让大部分请求失败,同时支持 HTTP 的 mixed 端口才能正常工作。
|
|
667
|
+
只提示、不拒绝,也不改变退出码。
|
|
668
|
+
"""
|
|
669
|
+
if not is_socks(proxy):
|
|
670
|
+
return
|
|
671
|
+
where = " for account {!r}".format(account) if account else ""
|
|
672
|
+
warn("SOCKS proxy{}: Codex sends most requests as HTTP CONNECT even with a SOCKS URL, so this only "
|
|
673
|
+
"works if {} also accepts HTTP proxy requests (a \"mixed\" port); prefer an HTTP proxy".format(where, proxy))
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _print_json(data: dict) -> None:
|
|
677
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def cmd_list(as_json: bool = False) -> int:
|
|
681
|
+
config, exists = load_config()
|
|
682
|
+
migrate.warn_isolation_env()
|
|
683
|
+
if not exists:
|
|
684
|
+
if as_json:
|
|
685
|
+
_print_json({"version": 1, "configured": False, "accounts": [], "duplicates": []})
|
|
686
|
+
else:
|
|
687
|
+
info("no configuration yet at {}; run `multi-codex add NAME` to start".format(
|
|
688
|
+
os.path.join(platform.state_dir(), "config.json")))
|
|
689
|
+
return accounts.EXIT_OK
|
|
690
|
+
entries = []
|
|
691
|
+
for name, account in config.accounts.items():
|
|
692
|
+
directory = accounts.account_dir(config, name)
|
|
693
|
+
is_dir = os.path.isdir(directory)
|
|
694
|
+
# 目录不存在时 read_identity 只会得到“未登录”,这里直接按未登录显示,不去读不存在的文件。
|
|
695
|
+
found = identity.read_identity(directory) if is_dir else None
|
|
696
|
+
entries.append((name, account, directory, is_dir, found))
|
|
697
|
+
duplicates = identity.duplicate_groups((name, found) for name, _, _, _, found in entries if found)
|
|
698
|
+
|
|
699
|
+
if as_json:
|
|
700
|
+
_print_json({
|
|
701
|
+
"version": 1,
|
|
702
|
+
"configured": True,
|
|
703
|
+
"root": expand(config.root),
|
|
704
|
+
"bin_dir": expand(config.bin_dir),
|
|
705
|
+
"shared_dir": expand(config.shared_dir) if config.shared_dir else None,
|
|
706
|
+
"default_account": switch.describe_default(config)[0],
|
|
707
|
+
"accounts": [{
|
|
708
|
+
"name": name,
|
|
709
|
+
"dir": directory,
|
|
710
|
+
"dir_status": "ok" if is_dir else "missing-dir",
|
|
711
|
+
"proxy": account.proxy,
|
|
712
|
+
"shared": account.shared,
|
|
713
|
+
"launcher": accounts.launcher_status(config, name),
|
|
714
|
+
"credentials_store": found.store if found else None,
|
|
715
|
+
# 只给键名:值里可能有密钥。
|
|
716
|
+
"env_keys": sorted(account.env),
|
|
717
|
+
"login": {"type": found.login if found else identity.LOGIN_LOGGED_OUT,
|
|
718
|
+
"email": found.email if found else None,
|
|
719
|
+
"plan": found.plan if found else None},
|
|
720
|
+
} for name, account, directory, is_dir, found in entries],
|
|
721
|
+
"duplicates": duplicates,
|
|
722
|
+
})
|
|
723
|
+
_warn_duplicates(duplicates, entries)
|
|
724
|
+
return accounts.EXIT_OK
|
|
725
|
+
|
|
726
|
+
print("root: {}".format(display_path(expand(config.root))))
|
|
727
|
+
print("bin_dir: {}".format(display_path(expand(config.bin_dir))))
|
|
728
|
+
# 表头路径缩写成 ~/… 只为好读;行数不变,按行位置解析表格的脚本不受影响(脚本应使用 --json)。
|
|
729
|
+
print("shared.dir: {}".format(display_path(expand(config.shared_dir)) if config.shared_dir else "(not set)"))
|
|
730
|
+
print("default: {}".format(switch.describe_default(config)[1]))
|
|
731
|
+
if not config.accounts:
|
|
732
|
+
print("no accounts registered")
|
|
733
|
+
return accounts.EXIT_OK
|
|
734
|
+
# 前 5 列与 0.2.0 相同,新列只追加在后面,按列位置解析旧输出的脚本不受影响。
|
|
735
|
+
rows = [("NAME", "DIR", "PROXY", "SHARED", "LAUNCHER", "LOGIN", "PLAN")]
|
|
736
|
+
for name, account, directory, is_dir, found in entries:
|
|
737
|
+
rows.append((name, "ok" if is_dir else "missing-dir", account.proxy,
|
|
738
|
+
"yes" if account.shared else "no", accounts.launcher_status(config, name),
|
|
739
|
+
identity.display_login(found) if found else "-",
|
|
740
|
+
(found.plan if found and found.plan else "-")))
|
|
741
|
+
widths = [max(len(row[index]) for row in rows) for index in range(len(rows[0]))]
|
|
742
|
+
for row in rows:
|
|
743
|
+
print(" ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip())
|
|
744
|
+
_warn_duplicates(duplicates, entries)
|
|
745
|
+
return accounts.EXIT_OK
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _warn_duplicates(duplicates: List[List[str]], entries) -> None:
|
|
749
|
+
emails = {name: found.email for name, _, _, _, found in entries if found}
|
|
750
|
+
for group in duplicates:
|
|
751
|
+
warn("accounts {} are logged in as the same ChatGPT account ({}); they share one usage quota".format(
|
|
752
|
+
", ".join(group), emails.get(group[0]) or "same user and workspace"))
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def cmd_usage(args: argparse.Namespace) -> int:
|
|
756
|
+
"""额度。只读、不加锁;一个账号失败不影响其它账号,最后以退出码 1 汇总。"""
|
|
757
|
+
config, exists = load_config()
|
|
758
|
+
if not exists or not config.accounts:
|
|
759
|
+
if args.json:
|
|
760
|
+
_print_json({"version": 1, "accounts": []})
|
|
761
|
+
else:
|
|
762
|
+
print("no accounts registered")
|
|
763
|
+
return accounts.EXIT_OK
|
|
764
|
+
targets = []
|
|
765
|
+
for name in args.names or list(config.accounts):
|
|
766
|
+
account = config.find(name)
|
|
767
|
+
targets.append((name, account))
|
|
768
|
+
|
|
769
|
+
results = []
|
|
770
|
+
for name, account in targets:
|
|
771
|
+
if account is None:
|
|
772
|
+
result = usage.UsageResult(name, usage.SOURCE_LIVE if args.live else usage.SOURCE_LOCAL, False,
|
|
773
|
+
config.not_registered(name), None, False, [])
|
|
774
|
+
elif args.live:
|
|
775
|
+
status = accounts.launcher_status(config, account.name)
|
|
776
|
+
if status != "ok":
|
|
777
|
+
result = usage.UsageResult(account.name, usage.SOURCE_LIVE, False,
|
|
778
|
+
"launcher is {}; run `multi-codex apply` first".format(status),
|
|
779
|
+
None, False, [])
|
|
780
|
+
else:
|
|
781
|
+
result = usage.live_snapshot(account.name, launcher.launcher_path(expand(config.bin_dir),
|
|
782
|
+
account.name), args.timeout)
|
|
783
|
+
else:
|
|
784
|
+
result = usage.local_snapshot(account.name, accounts.account_dir(config, account.name))
|
|
785
|
+
results.append(result)
|
|
786
|
+
if not args.json:
|
|
787
|
+
# 实时查询每个账号要几秒,逐个输出,不等全部完成。
|
|
788
|
+
for line in usage.format_result(result, time.time()):
|
|
789
|
+
print(line)
|
|
790
|
+
sys.stdout.flush()
|
|
791
|
+
if args.json:
|
|
792
|
+
_print_json({"version": 1, "accounts": [usage.to_json(result) for result in results]})
|
|
793
|
+
return accounts.EXIT_OK if all(result.ok for result in results) else accounts.EXIT_ERROR
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
797
|
+
"""体检。只读、不加锁;有任一 fail 时退出码为 1,只有 warn 时仍为 0。"""
|
|
798
|
+
checks = doctor.run_checks()
|
|
799
|
+
if args.json:
|
|
800
|
+
_print_json(doctor.to_json(checks))
|
|
801
|
+
else:
|
|
802
|
+
for line in doctor.format_checks(checks):
|
|
803
|
+
print(line)
|
|
804
|
+
return accounts.EXIT_ERROR if any(check.status == doctor.FAIL for check in checks) else accounts.EXIT_OK
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def cmd_completion(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
808
|
+
if args.list_accounts:
|
|
809
|
+
# 补全时每按一次 Tab 调用一次:任何问题都静默,不能在用户的命令行上打出警告。
|
|
810
|
+
try:
|
|
811
|
+
config, _ = load_config()
|
|
812
|
+
except (ConfigError, OSError):
|
|
813
|
+
return accounts.EXIT_OK
|
|
814
|
+
for name in config.accounts:
|
|
815
|
+
print(name)
|
|
816
|
+
return accounts.EXIT_OK
|
|
817
|
+
if args.shell is None:
|
|
818
|
+
raise UsageError("completion needs a shell: bash, zsh or fish")
|
|
819
|
+
sys.stdout.write(completion.script(args.shell, parser))
|
|
820
|
+
return accounts.EXIT_OK
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _account_exec_args(config: Config, account: Account, command: List[str]) -> Tuple[List[str], dict]:
|
|
824
|
+
"""返回在账号环境下运行 command 所需的 (argv, env),交给 os.execve("/bin/sh", ...);run 与 code 共用。
|
|
825
|
+
|
|
826
|
+
环境由 launcher.render 生成的脚本设置,与启动命令逐字一致。脚本经环境变量交给 sh,
|
|
827
|
+
不放进命令行参数:里面可能有账号环境变量里的密钥,命令行参数其它用户用 ps 就能看到。
|
|
828
|
+
"""
|
|
829
|
+
script = launcher.render(account.name, accounts.account_dir(config, account.name), account.proxy,
|
|
830
|
+
account.env, command_mode=True)
|
|
831
|
+
env = dict(os.environ)
|
|
832
|
+
env[launcher.RUN_SCRIPT_ENV] = script
|
|
833
|
+
return ["sh", "-c", 'eval "${}"'.format(launcher.RUN_SCRIPT_ENV), "sh"] + command, env
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def cmd_run(name: Optional[str], command: List[str]) -> int:
|
|
837
|
+
"""在账号的环境下运行命令(默认 codex),成功时不返回:本进程被替换成 sh,再被替换成该命令。
|
|
838
|
+
|
|
839
|
+
省略 name 时,从当前目录向上找最近一个绑定(feature-dir-binding §5.1.2)。
|
|
840
|
+
"""
|
|
841
|
+
config, exists = load_config()
|
|
842
|
+
if name is None:
|
|
843
|
+
found = binding.resolve(config, os.getcwd()) if exists else None
|
|
844
|
+
if found is None:
|
|
845
|
+
error("no account is bound to {} or its parents; use `multi-codex run NAME` or "
|
|
846
|
+
"`multi-codex bind NAME`".format(os.getcwd()))
|
|
847
|
+
return accounts.EXIT_ERROR
|
|
848
|
+
bound_dir, name = found
|
|
849
|
+
if config.find(name) is None:
|
|
850
|
+
error("account {!r} bound to {} is not registered; run `multi-codex unbind {}`".format(
|
|
851
|
+
name, bound_dir, bound_dir))
|
|
852
|
+
return accounts.EXIT_ERROR
|
|
853
|
+
print("[multi-codex] using account {} (bound to {})".format(name, bound_dir), file=sys.stderr)
|
|
854
|
+
account = config.find(name) if exists else None
|
|
855
|
+
if account is None:
|
|
856
|
+
error(config.not_registered(name))
|
|
857
|
+
return accounts.EXIT_ERROR
|
|
858
|
+
argv, env = _account_exec_args(config, account, command or ["codex"])
|
|
859
|
+
sys.stdout.flush()
|
|
860
|
+
sys.stderr.flush()
|
|
861
|
+
os.execve("/bin/sh", argv, env)
|
|
862
|
+
return accounts.EXIT_ERROR # 不会执行到这里;execve 失败时抛 OSError,由 main 统一处理
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _registered_with_dir(name: str) -> Tuple[Optional[Config], Optional[Account]]:
|
|
866
|
+
config, exists = load_config()
|
|
867
|
+
account = config.find(name) if exists else None
|
|
868
|
+
if account is None:
|
|
869
|
+
error(config.not_registered(name))
|
|
870
|
+
return None, None
|
|
871
|
+
directory = accounts.account_dir(config, account.name)
|
|
872
|
+
if not os.path.isdir(directory):
|
|
873
|
+
error("account directory does not exist", path=directory)
|
|
874
|
+
return None, None
|
|
875
|
+
return config, account
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def cmd_code(args: argparse.Namespace, extra: List[str]) -> int:
|
|
879
|
+
"""以账号环境打开一个独立的 VS Code 实例(实验功能,feature-app-launch §5.1.1)。"""
|
|
880
|
+
warn("experimental: `code` relies on undocumented behaviour of VS Code and its OpenAI extension "
|
|
881
|
+
"(verified with {})".format(apps.VERIFIED_WITH))
|
|
882
|
+
config, account = _registered_with_dir(args.name)
|
|
883
|
+
if account is None:
|
|
884
|
+
return accounts.EXIT_ERROR
|
|
885
|
+
code_bin = apps.find_code(args.bin)
|
|
886
|
+
if code_bin is None:
|
|
887
|
+
error("VS Code's `code` command was not found{}; install it from VS Code (\"Shell Command: Install "
|
|
888
|
+
"'code' command in PATH\") or pass --bin".format(" at " + args.bin if args.bin else ""))
|
|
889
|
+
return accounts.EXIT_ERROR
|
|
890
|
+
data_dir = apps.gui_data_dir(config, account.name, "vscode")
|
|
891
|
+
if sys.platform == "darwin":
|
|
892
|
+
if len(data_dir) > apps.SOCKET_DIR_WARN_LENGTH:
|
|
893
|
+
warn("{} is long; VS Code's socket path inside it may exceed the 104-byte limit".format(data_dir))
|
|
894
|
+
warn("on macOS, `code` passes the whole environment (including this account's variables) to "
|
|
895
|
+
"`open --env`, so the values are briefly visible in the process list")
|
|
896
|
+
command = [code_bin, "--user-data-dir", data_dir] + ([args.path] if args.path else []) + extra
|
|
897
|
+
argv, env = _account_exec_args(config, account, command)
|
|
898
|
+
sys.stdout.flush()
|
|
899
|
+
sys.stderr.flush()
|
|
900
|
+
os.execve("/bin/sh", argv, env)
|
|
901
|
+
return accounts.EXIT_ERROR
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def cmd_app(args: argparse.Namespace) -> int:
|
|
905
|
+
"""以账号的 CODEX_HOME 打开一个独立的 Codex 桌面端实例(仅 macOS,实验功能,feature-app-launch §5.1.2)。
|
|
906
|
+
|
|
907
|
+
经 LaunchServices(open -n)启动,只传两个不含密钥的变量:桌面端会用登录 shell 的环境覆盖代理等变量,
|
|
908
|
+
传账号环境变量既无效,又会把值放进 open 的命令行参数。
|
|
909
|
+
"""
|
|
910
|
+
if sys.platform != "darwin":
|
|
911
|
+
error("`app` is only supported on macOS")
|
|
912
|
+
return accounts.EXIT_ERROR
|
|
913
|
+
warn("experimental: `app` relies on undocumented behaviour of the Codex desktop app "
|
|
914
|
+
"(verified with {})".format(apps.VERIFIED_WITH))
|
|
915
|
+
config, account = _registered_with_dir(args.name)
|
|
916
|
+
if account is None:
|
|
917
|
+
return accounts.EXIT_ERROR
|
|
918
|
+
app_path = args.app or apps.DEFAULT_DESKTOP_APP
|
|
919
|
+
problem = apps.desktop_app_problem(app_path)
|
|
920
|
+
if problem:
|
|
921
|
+
error(problem)
|
|
922
|
+
return accounts.EXIT_ERROR
|
|
923
|
+
data_dir = apps.gui_data_dir(config, account.name, "desktop")
|
|
924
|
+
log = apps.desktop_log(config, account.name)
|
|
925
|
+
argv = [apps.open_command(), "-n",
|
|
926
|
+
"--env", "CODEX_HOME=" + accounts.account_dir(config, account.name),
|
|
927
|
+
"--env", "CODEX_ELECTRON_USER_DATA_PATH=" + data_dir,
|
|
928
|
+
"--stdout", log, "--stderr", log,
|
|
929
|
+
"-a", app_path, "--args", "--user-data-dir=" + data_dir]
|
|
930
|
+
try:
|
|
931
|
+
proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True,
|
|
932
|
+
timeout=60)
|
|
933
|
+
except subprocess.TimeoutExpired:
|
|
934
|
+
error("`open` did not return within 60s")
|
|
935
|
+
return accounts.EXIT_ERROR
|
|
936
|
+
if proc.returncode != 0:
|
|
937
|
+
error("`open` failed (exit code {}): {}".format(proc.returncode, proc.stderr.strip()[:500]))
|
|
938
|
+
return accounts.EXIT_ERROR
|
|
939
|
+
info("started Codex desktop for {} (log {})".format(account.name, log))
|
|
940
|
+
return accounts.EXIT_OK
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def cmd_bind_list() -> int:
|
|
944
|
+
config, exists = load_config()
|
|
945
|
+
if not exists or not config.bindings:
|
|
946
|
+
print("no bindings")
|
|
947
|
+
return accounts.EXIT_OK
|
|
948
|
+
effective = binding.resolve(config, os.getcwd())
|
|
949
|
+
for path in sorted(config.bindings):
|
|
950
|
+
marker = "*" if effective and effective[0] == path else " "
|
|
951
|
+
print("{} {} {}".format(marker, path, config.bindings[path]))
|
|
952
|
+
return accounts.EXIT_OK
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def cmd_path(name: str) -> int:
|
|
956
|
+
config, exists = load_config()
|
|
957
|
+
account = config.find(name) if exists else None
|
|
958
|
+
if account is None:
|
|
959
|
+
error(config.not_registered(name))
|
|
960
|
+
return accounts.EXIT_ERROR
|
|
961
|
+
directory = accounts.account_dir(config, account.name)
|
|
962
|
+
print(directory)
|
|
963
|
+
if not os.path.isdir(directory):
|
|
964
|
+
warn("account directory does not exist: {}".format(directory))
|
|
965
|
+
return accounts.EXIT_OK
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def cmd_env_list(name: str) -> int:
|
|
969
|
+
config, exists = load_config()
|
|
970
|
+
account = config.find(name) if exists else None
|
|
971
|
+
if account is None:
|
|
972
|
+
error(config.not_registered(name))
|
|
973
|
+
return accounts.EXIT_ERROR
|
|
974
|
+
for key in sorted(account.env):
|
|
975
|
+
print("{}={}".format(key, account.env[key]))
|
|
976
|
+
return accounts.EXIT_OK
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def cmd_use_show() -> int:
|
|
980
|
+
config, _ = load_config()
|
|
981
|
+
owner, description = switch.describe_default(config)
|
|
982
|
+
# stdout 保持原样(脚本可能读取账号名或 `(none)`),下一步提示写 stderr。
|
|
983
|
+
print(description)
|
|
984
|
+
if owner is None:
|
|
985
|
+
kind = entry_kind(switch.default_link())
|
|
986
|
+
if kind == KIND_MISSING:
|
|
987
|
+
hint("no default account yet; `multi-codex use NAME` makes ~/.codex point to an account")
|
|
988
|
+
elif kind == KIND_DIR:
|
|
989
|
+
hint("~/.codex is not managed yet; run `multi-codex migrate-default NAME` first")
|
|
990
|
+
return accounts.EXIT_OK
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
if __name__ == "__main__":
|
|
994
|
+
sys.exit(main())
|