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/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())