multi-codex 0.9.0__tar.gz → 0.10.0__tar.gz

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.
Files changed (48) hide show
  1. {multi_codex-0.9.0/src/multi_codex.egg-info → multi_codex-0.10.0}/PKG-INFO +10 -5
  2. {multi_codex-0.9.0 → multi_codex-0.10.0}/README.md +9 -4
  3. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/__init__.py +1 -1
  4. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/accounts.py +41 -1
  5. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/cli.py +38 -0
  6. multi_codex-0.10.0/src/multi_codex/router.py +121 -0
  7. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/switch.py +25 -5
  8. {multi_codex-0.9.0 → multi_codex-0.10.0/src/multi_codex.egg-info}/PKG-INFO +10 -5
  9. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex.egg-info/SOURCES.txt +2 -0
  10. multi_codex-0.10.0/tests/test_router.py +370 -0
  11. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_switch.py +20 -3
  12. {multi_codex-0.9.0 → multi_codex-0.10.0}/LICENSE +0 -0
  13. {multi_codex-0.9.0 → multi_codex-0.10.0}/pyproject.toml +0 -0
  14. {multi_codex-0.9.0 → multi_codex-0.10.0}/setup.cfg +0 -0
  15. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/__main__.py +0 -0
  16. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/actions.py +0 -0
  17. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/apps.py +0 -0
  18. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/binding.py +0 -0
  19. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/completion.py +0 -0
  20. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/config.py +0 -0
  21. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/doctor.py +0 -0
  22. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/fsutil.py +0 -0
  23. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/identity.py +0 -0
  24. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/launcher.py +0 -0
  25. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/lock.py +0 -0
  26. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/migrate.py +0 -0
  27. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/platform.py +0 -0
  28. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/shared.py +0 -0
  29. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/shellpath.py +0 -0
  30. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/usage.py +0 -0
  31. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex.egg-info/dependency_links.txt +0 -0
  32. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex.egg-info/entry_points.txt +0 -0
  33. {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex.egg-info/top_level.txt +0 -0
  34. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_accounts.py +0 -0
  35. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_apps.py +0 -0
  36. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_binding.py +0 -0
  37. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_config.py +0 -0
  38. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_config_copy.py +0 -0
  39. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_ergonomics.py +0 -0
  40. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_everyday.py +0 -0
  41. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_fsutil.py +0 -0
  42. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_insight.py +0 -0
  43. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_install.py +0 -0
  44. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_isolation.py +0 -0
  45. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_migrate.py +0 -0
  46. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_onboarding.py +0 -0
  47. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_onboarding_commands.py +0 -0
  48. {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_platform.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multi-codex
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Run several Codex CLI accounts side by side: separate CODEX_HOME directories, launchers and proxies.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jakoes-wu/multi-codex
@@ -134,7 +134,7 @@ Without a name, the account is named after the e-mail address in `~/.codex/auth.
134
134
  | Open VS Code with an account | `multi-codex code work ~/src/project` | [VS Code and the desktop app](#vs-code-and-the-desktop-app-experimental) |
135
135
  | Open the desktop app with an account (macOS) | `multi-codex app work` | [VS Code and the desktop app](#vs-code-and-the-desktop-app-experimental) |
136
136
  | Change the account that plain `codex` and the Dock apps use | `multi-codex use work` (after `migrate-default`) | [Default account](#default-account) |
137
- | Always use one account inside a project | In the project directory: `multi-codex bind work`, then `multi-codex run` | [Directory bindings](#directory-bindings) |
137
+ | Always use one account inside a project | In the project directory: `multi-codex bind work`, then `codex-auto` | [Directory bindings](#directory-bindings) |
138
138
  | Set or change an account's proxy | `multi-codex set work --proxy 7901` | [Proxy values](#proxy-values) |
139
139
  | Share `AGENTS.md`, skills and rules between accounts | Put them in `~/.codex-shared`, then run `multi-codex set work --shared` | [Shared resources](#shared-resources) |
140
140
  | Start a new account with another account's settings | `multi-codex add new --config-from work` | [Copying settings](#copying-settings-from-another-account) |
@@ -172,6 +172,7 @@ More questions are answered in the [FAQ](#faq).
172
172
  | `multi-codex doctor [--json]` | Check the installation, configuration and accounts. Read-only. |
173
173
  | `multi-codex run [NAME] [-- COMMAND ...]` | Run a command (default: `codex`) with an account's environment. Without NAME, the account bound to the current directory is used. |
174
174
  | `multi-codex bind [NAME [DIR]]` / `unbind [DIR]` | Bind a directory to an account, list bindings, or remove one. |
175
+ | `multi-codex which [DIR]` | Show which account a directory uses (the same one `codex-auto` and `run` pick). |
175
176
  | `multi-codex code NAME [PATH] [-- ARGS]` | Open VS Code for an account (experimental). |
176
177
  | `multi-codex app NAME` | Open the Codex desktop app for an account (macOS, experimental). |
177
178
  | `multi-codex path NAME` | Print an account's directory. |
@@ -218,7 +219,7 @@ Subcommands, options and registered account names (including e-mail addresses) a
218
219
 
219
220
  ### Running other commands
220
221
 
221
- `multi-codex run NAME -- COMMAND ...` runs any command with exactly the environment of `codex-NAME` (`CODEX_HOME`, proxy, extra variables). Without a command it runs `codex`. Everything after the first `--` is passed through unchanged; the exit code is the command's. `multi-codex path NAME` prints the account directory.
222
+ `multi-codex run NAME -- COMMAND ...` runs any command with exactly the environment of `codex-NAME` (`CODEX_HOME`, proxy, extra variables). Without a command it runs `codex`. Everything after the first `--` is passed through unchanged; the exit code is the command's. `multi-codex path NAME` prints the account directory. For example, to manage an account's MCP servers: `multi-codex run work -- codex mcp list`.
222
223
 
223
224
  ### Directory bindings
224
225
 
@@ -227,8 +228,12 @@ cd ~/work/project && multi-codex bind work # this directory and everything b
227
228
  multi-codex run -- codex resume # no account name needed here
228
229
  multi-codex bind # list bindings; * marks the one in effect here
229
230
  multi-codex unbind # remove the binding of the current directory
231
+ codex-auto # runs codex with the bound account
232
+ multi-codex which # prints the account this directory uses
230
233
  ```
231
234
 
235
+ While at least one binding exists, multi-codex generates `codex-auto` next to the other launchers. It looks up the nearest bound directory exactly like `run` and starts that account's launcher; outside any bound directory it runs plain `codex` (the default account, see [Default account](#default-account)) and says so on stderr. It is updated whenever bindings or accounts change and removed with the last binding. An account named `auto` cannot coexist with bindings: rename it first (`multi-codex rename auto NEW`).
236
+
232
237
  `run` without an account name walks up from the current directory and uses the nearest bound directory. Bindings are stored in `config.json` (not in your project), keyed by the real path of the directory (links resolved, the on-disk letter case used on case-insensitive file systems). `apply -f` keeps the current bindings; removing an account (`remove`, `restore`, `apply -f`) also removes its bindings. Older versions of multi-codex drop the `bindings` field on their next write.
233
238
 
234
239
  ### Copying settings from another account
@@ -241,7 +246,7 @@ multi-codex unbind # remove the binding of the curre
241
246
  multi-codex rename work client-a
242
247
  ```
243
248
 
244
- The account and its launcher get the new name (`codex-client-a`; `codex-work` is deleted), and directory bindings and the default account follow. The directory stays where it is, so the login, sessions and shared links are kept; `config.json` records it as `"dir": "work"` (in `list --json`, `dir` is the full path instead). A name that is still used as another account's directory, such as `work` after this rename, cannot be given to a new account. Renaming only the letter case is not supported. Before downgrading to a version older than 0.9, rename the account back: older versions ignore `dir` and would use `<root>/client-a`.
249
+ The account and its launcher get the new name (`codex-client-a`; `codex-work` is deleted), and directory bindings, `codex-auto` and the default account follow. The directory stays where it is, so the login, sessions and shared links are kept; `config.json` records it as `"dir": "work"` (in `list --json`, `dir` is the full path instead). A name that is still used as another account's directory, such as `work` after this rename, cannot be given to a new account. Renaming only the letter case is not supported. Before downgrading to a version older than 0.9, rename the account back: older versions ignore `dir` and would use `<root>/client-a`.
245
250
 
246
251
  ### Per-account environment variables
247
252
 
@@ -492,7 +497,7 @@ The tool goes to `~/.local/share/multi-codex` and the `multi-codex` command to `
492
497
  curl -fsSL https://raw.githubusercontent.com/jakoes-wu/multi-codex/main/install.sh | sh -s -- --uninstall # without a clone
493
498
  ```
494
499
 
495
- This removes the tool only. Your configuration, account directories and `codex-<name>` launchers stay; the launchers keep working because they do not depend on multi-codex.
500
+ This removes the tool only. Your configuration, account directories, `codex-<name>` launchers and `codex-auto` stay; they keep working because they do not depend on multi-codex.
496
501
 
497
502
  ## Contributing
498
503
 
@@ -110,7 +110,7 @@ Without a name, the account is named after the e-mail address in `~/.codex/auth.
110
110
  | Open VS Code with an account | `multi-codex code work ~/src/project` | [VS Code and the desktop app](#vs-code-and-the-desktop-app-experimental) |
111
111
  | Open the desktop app with an account (macOS) | `multi-codex app work` | [VS Code and the desktop app](#vs-code-and-the-desktop-app-experimental) |
112
112
  | Change the account that plain `codex` and the Dock apps use | `multi-codex use work` (after `migrate-default`) | [Default account](#default-account) |
113
- | Always use one account inside a project | In the project directory: `multi-codex bind work`, then `multi-codex run` | [Directory bindings](#directory-bindings) |
113
+ | Always use one account inside a project | In the project directory: `multi-codex bind work`, then `codex-auto` | [Directory bindings](#directory-bindings) |
114
114
  | Set or change an account's proxy | `multi-codex set work --proxy 7901` | [Proxy values](#proxy-values) |
115
115
  | Share `AGENTS.md`, skills and rules between accounts | Put them in `~/.codex-shared`, then run `multi-codex set work --shared` | [Shared resources](#shared-resources) |
116
116
  | Start a new account with another account's settings | `multi-codex add new --config-from work` | [Copying settings](#copying-settings-from-another-account) |
@@ -148,6 +148,7 @@ More questions are answered in the [FAQ](#faq).
148
148
  | `multi-codex doctor [--json]` | Check the installation, configuration and accounts. Read-only. |
149
149
  | `multi-codex run [NAME] [-- COMMAND ...]` | Run a command (default: `codex`) with an account's environment. Without NAME, the account bound to the current directory is used. |
150
150
  | `multi-codex bind [NAME [DIR]]` / `unbind [DIR]` | Bind a directory to an account, list bindings, or remove one. |
151
+ | `multi-codex which [DIR]` | Show which account a directory uses (the same one `codex-auto` and `run` pick). |
151
152
  | `multi-codex code NAME [PATH] [-- ARGS]` | Open VS Code for an account (experimental). |
152
153
  | `multi-codex app NAME` | Open the Codex desktop app for an account (macOS, experimental). |
153
154
  | `multi-codex path NAME` | Print an account's directory. |
@@ -194,7 +195,7 @@ Subcommands, options and registered account names (including e-mail addresses) a
194
195
 
195
196
  ### Running other commands
196
197
 
197
- `multi-codex run NAME -- COMMAND ...` runs any command with exactly the environment of `codex-NAME` (`CODEX_HOME`, proxy, extra variables). Without a command it runs `codex`. Everything after the first `--` is passed through unchanged; the exit code is the command's. `multi-codex path NAME` prints the account directory.
198
+ `multi-codex run NAME -- COMMAND ...` runs any command with exactly the environment of `codex-NAME` (`CODEX_HOME`, proxy, extra variables). Without a command it runs `codex`. Everything after the first `--` is passed through unchanged; the exit code is the command's. `multi-codex path NAME` prints the account directory. For example, to manage an account's MCP servers: `multi-codex run work -- codex mcp list`.
198
199
 
199
200
  ### Directory bindings
200
201
 
@@ -203,8 +204,12 @@ cd ~/work/project && multi-codex bind work # this directory and everything b
203
204
  multi-codex run -- codex resume # no account name needed here
204
205
  multi-codex bind # list bindings; * marks the one in effect here
205
206
  multi-codex unbind # remove the binding of the current directory
207
+ codex-auto # runs codex with the bound account
208
+ multi-codex which # prints the account this directory uses
206
209
  ```
207
210
 
211
+ While at least one binding exists, multi-codex generates `codex-auto` next to the other launchers. It looks up the nearest bound directory exactly like `run` and starts that account's launcher; outside any bound directory it runs plain `codex` (the default account, see [Default account](#default-account)) and says so on stderr. It is updated whenever bindings or accounts change and removed with the last binding. An account named `auto` cannot coexist with bindings: rename it first (`multi-codex rename auto NEW`).
212
+
208
213
  `run` without an account name walks up from the current directory and uses the nearest bound directory. Bindings are stored in `config.json` (not in your project), keyed by the real path of the directory (links resolved, the on-disk letter case used on case-insensitive file systems). `apply -f` keeps the current bindings; removing an account (`remove`, `restore`, `apply -f`) also removes its bindings. Older versions of multi-codex drop the `bindings` field on their next write.
209
214
 
210
215
  ### Copying settings from another account
@@ -217,7 +222,7 @@ multi-codex unbind # remove the binding of the curre
217
222
  multi-codex rename work client-a
218
223
  ```
219
224
 
220
- The account and its launcher get the new name (`codex-client-a`; `codex-work` is deleted), and directory bindings and the default account follow. The directory stays where it is, so the login, sessions and shared links are kept; `config.json` records it as `"dir": "work"` (in `list --json`, `dir` is the full path instead). A name that is still used as another account's directory, such as `work` after this rename, cannot be given to a new account. Renaming only the letter case is not supported. Before downgrading to a version older than 0.9, rename the account back: older versions ignore `dir` and would use `<root>/client-a`.
225
+ The account and its launcher get the new name (`codex-client-a`; `codex-work` is deleted), and directory bindings, `codex-auto` and the default account follow. The directory stays where it is, so the login, sessions and shared links are kept; `config.json` records it as `"dir": "work"` (in `list --json`, `dir` is the full path instead). A name that is still used as another account's directory, such as `work` after this rename, cannot be given to a new account. Renaming only the letter case is not supported. Before downgrading to a version older than 0.9, rename the account back: older versions ignore `dir` and would use `<root>/client-a`.
221
226
 
222
227
  ### Per-account environment variables
223
228
 
@@ -468,7 +473,7 @@ The tool goes to `~/.local/share/multi-codex` and the `multi-codex` command to `
468
473
  curl -fsSL https://raw.githubusercontent.com/jakoes-wu/multi-codex/main/install.sh | sh -s -- --uninstall # without a clone
469
474
  ```
470
475
 
471
- This removes the tool only. Your configuration, account directories and `codex-<name>` launchers stay; the launchers keep working because they do not depend on multi-codex.
476
+ This removes the tool only. Your configuration, account directories, `codex-<name>` launchers and `codex-auto` stay; they keep working because they do not depend on multi-codex.
472
477
 
473
478
  ## Contributing
474
479
 
@@ -1,3 +1,3 @@
1
1
  """multi-codex:管理多个 Codex CLI 账号目录、启动命令与代理设置。"""
2
2
 
3
- __version__ = "0.9.0"
3
+ __version__ = "0.10.0"
@@ -14,7 +14,7 @@
14
14
  import os
15
15
  from typing import Dict, FrozenSet, Iterable, List, Optional, Sequence, Set, Union
16
16
 
17
- from . import launcher, shared
17
+ from . import launcher, router, shared
18
18
  from .actions import (CONFLICT, CREATE, DELETE, SKIP, UNCHANGED, UPDATE, Action, error,
19
19
  has_conflict, info, print_action)
20
20
  from .config import Account, Config, config_path, dump_config, save_config
@@ -121,6 +121,8 @@ def plan(old: Config, new: Config, *, config_exists: bool = True,
121
121
  actions.extend(_plan_launcher_delete(new_bin, name, planned_deletes,
122
122
  "orphan launcher", path=path))
123
123
 
124
+ actions.extend(_plan_router(old_bin, new_bin, new, planned_deletes))
125
+
124
126
  config_changed = not config_exists or dump_config(old) != dump_config(new)
125
127
  config_action = Action(CREATE if not config_exists else (UPDATE if config_changed else UNCHANGED),
126
128
  "config", config_path())
@@ -191,6 +193,44 @@ def _plan_launcher(bin_dir: str, name: str, directory: str, proxy: str,
191
193
  return [Action(UPDATE, "launcher", path, run=_write_launcher(path, content, mode))]
192
194
 
193
195
 
196
+ def _plan_router(old_bin: str, new_bin: str, new: Config, planned_deletes: Set[str]) -> List[Action]:
197
+ """codex-auto 入口(feature-auto-launcher §5.1.2):有目录绑定时生成或更新,没有时删除。
198
+
199
+ 必须放在账号启动命令的删除(含孤儿清理)之后计算:rename / remove / restore 名为 auto 的账号时,
200
+ codex-auto 原本是它的启动命令,正被删除,这里要把它当作不存在、重建成入口。
201
+ """
202
+ actions: List[Action] = []
203
+ path = router.router_path(new_bin)
204
+ if old_bin != new_bin:
205
+ old_path = router.router_path(old_bin)
206
+ if router.is_router(old_path):
207
+ actions.append(Action(DELETE, "launcher", old_path, "bin_dir changed", _unlink_file(old_path)))
208
+ if not new.bindings:
209
+ if router.is_router(path):
210
+ actions.append(Action(DELETE, "launcher", path, "no directory bindings", _unlink_file(path)))
211
+ return actions
212
+ owner = new.find(router.ROUTER_NAME)
213
+ if owner is not None:
214
+ return actions + [Action(CONFLICT, "launcher", path,
215
+ "codex-auto is generated from directory bindings; rename the account named {!r} "
216
+ "(multi-codex rename {} NEW) or remove the bindings".format(owner.name, owner.name))]
217
+ content = router.render(new)
218
+ write = _write_launcher(path, content, router.ROUTER_MODE)
219
+ if entry_kind(path) == KIND_MISSING or router.same_file(path, planned_deletes):
220
+ return actions + [Action(CREATE, "launcher", path, run=write)]
221
+ if not router.is_router(path):
222
+ return actions + [Action(CONFLICT, "launcher", path, "already exists and is not the multi-codex router")]
223
+ if launcher.launcher_file_ok(path, content, None):
224
+ return actions + [Action(UNCHANGED, "launcher", path)]
225
+ return actions + [Action(UPDATE, "launcher", path, run=write)]
226
+
227
+
228
+ def _unlink_file(path: str):
229
+ def run() -> None:
230
+ os.unlink(path)
231
+ return run
232
+
233
+
194
234
  def _write_launcher(path: str, content: str, mode: int):
195
235
  def run() -> None:
196
236
  atomic_write(path, content, mode=mode)
@@ -46,6 +46,7 @@ COMMAND_GROUPS = (
46
46
  ("use", "show or change the default account (what ~/.codex points to)"),
47
47
  ("bind", "bind a directory to an account (no arguments: list bindings)"),
48
48
  ("unbind", "remove the binding of a directory"),
49
+ ("which", "show which account a directory uses (codex-auto picks the same one)"),
49
50
  ("code", "open VS Code for an account (experimental)"),
50
51
  ("app", "open the Codex desktop app for an account (macOS, experimental)"),
51
52
  ("proxy", "set the proxy of an account"),
@@ -191,6 +192,9 @@ def build_parser() -> argparse.ArgumentParser:
191
192
  _add_dry_run(p_unbind)
192
193
  _add_verbose(p_unbind)
193
194
 
195
+ p_which = sub.add_parser("which", description=COMMAND_SUMMARY["which"])
196
+ p_which.add_argument("dir", nargs="?", help="directory to check (default: current directory)")
197
+
194
198
  p_code = sub.add_parser("code", description=COMMAND_SUMMARY["code"],
195
199
  usage="multi-codex code NAME [PATH] [--bin CODE] [-- CODE_ARGS ...]")
196
200
  p_code.add_argument("name")
@@ -355,6 +359,8 @@ def main(argv: Optional[List[str]] = None) -> int:
355
359
  return cmd_app(args)
356
360
  if args.command == "path":
357
361
  return cmd_path(args.name)
362
+ if args.command == "which":
363
+ return cmd_which(args.dir)
358
364
  notice = migrate.pending_journal_notice()
359
365
  if notice:
360
366
  warn(notice)
@@ -1249,6 +1255,38 @@ def cmd_bind_list() -> int:
1249
1255
  return accounts.EXIT_OK
1250
1256
 
1251
1257
 
1258
+ def cmd_which(directory: Optional[str]) -> int:
1259
+ """某目录会用哪个账号(feature-auto-launcher §5.1.3)。只读,不加锁。
1260
+
1261
+ 规则与 run 省略账号名、以及 codex-auto 完全相同(binding.resolve)。stdout 只输出一行,便于脚本读取:
1262
+ 命中绑定时是账号名(登记时的大小写);没有绑定时是默认账号的说明(与 `use` 不带参数相同,
1263
+ 以 `(` 开头表示没有已登记的默认账号)。依据写 stderr。
1264
+ """
1265
+ target = directory or os.getcwd()
1266
+ if not os.path.isdir(target):
1267
+ error("not a directory: {}".format(target))
1268
+ return accounts.EXIT_ERROR
1269
+ config, exists = load_config()
1270
+ found = binding.resolve(config, target) if exists else None
1271
+ if found is None:
1272
+ print(switch.describe_default(config)[1])
1273
+ hint("no binding for {}; plain codex uses the default account".format(binding.normalize_dir(target)))
1274
+ if os.environ.get("CODEX_HOME"):
1275
+ hint("CODEX_HOME is set in this shell, so plain codex uses {} instead".format(os.environ["CODEX_HOME"]))
1276
+ return accounts.EXIT_OK
1277
+ bound_dir, name = found
1278
+ account = config.find(name)
1279
+ if account is None:
1280
+ error("account {!r} bound to {} is not registered; run `multi-codex unbind {}`".format(
1281
+ name, bound_dir, bound_dir))
1282
+ return accounts.EXIT_ERROR
1283
+ print(account.name)
1284
+ # 依据写 stderr(同 cmd_run):actions.info 写 stdout,会破坏“stdout 只有一行”的约定。
1285
+ sys.stdout.flush()
1286
+ print("[multi-codex] bound to {}".format(bound_dir), file=sys.stderr)
1287
+ return accounts.EXIT_OK
1288
+
1289
+
1252
1290
  def cmd_path(name: str) -> int:
1253
1291
  config, exists = load_config()
1254
1292
  account = config.find(name) if exists else None
@@ -0,0 +1,121 @@
1
+ """按目录绑定自动选账号的入口命令 `<bin_dir>/codex-auto`(feature-auto-launcher)。
2
+
3
+ 入口与账号启动命令一样是由 config.json 推导出的产物:有目录绑定时由收敛计划(accounts.plan)生成,
4
+ 没有绑定时删除。它只负责选账号,再用绝对路径 exec 该账号的 `codex-<名称>`;代理、隔离变量与
5
+ 环境变量都由账号启动命令负责,入口本身不设置任何环境。
6
+
7
+ 选账号的规则必须与 binding.resolve(`multi-codex run` 省略账号名时)完全一致:从当前目录向上找最近的绑定;
8
+ 最近的绑定指向未登记账号时报错退出 1,不继续向上找;一条都没有时运行普通 codex(默认账号)。
9
+ """
10
+
11
+ import os
12
+ import shlex
13
+ from typing import List
14
+
15
+ from . import launcher
16
+ from .config import Config
17
+ from .fsutil import KIND_FILE, entry_kind, expand, read_text
18
+
19
+ ROUTER_NAME = "auto"
20
+ # 不以 launcher.MARKER_PREFIX(`… account=`)开头:managed_account 对入口返回 None,
21
+ # 孤儿清理与 restore 的残留清理都不会把它当成账号启动命令。
22
+ ROUTER_MARKER = "# managed-by: multi-codex router"
23
+ ROUTER_MODE = 0o755 # 入口不含任何密钥,所有人可读
24
+
25
+
26
+ def router_path(bin_dir: str) -> str:
27
+ return launcher.launcher_path(bin_dir, ROUTER_NAME)
28
+
29
+
30
+ def is_router(path: str) -> bool:
31
+ """path 是否是本工具生成的入口:只认普通文件,第 2 行必须完全等于标记(同 launcher.managed_account)。"""
32
+ if entry_kind(path) != KIND_FILE:
33
+ return False
34
+ content = read_text(path)
35
+ if content is None:
36
+ return False
37
+ lines = content.split("\n", 2)
38
+ return len(lines) >= 2 and lines[1] == ROUTER_MARKER
39
+
40
+
41
+ def render(config: Config) -> str:
42
+ """生成入口脚本。键是 binding.normalize_dir 得到的物理路径,按字典序输出,内容稳定。
43
+
44
+ 当前目录必须用外部命令 /bin/pwd -P 取:macOS 的 /bin/sh(bash)内建 pwd -P 按继承来的 $PWD 逐段解析,
45
+ 用户换了大小写 cd 进来时会保留输入的大小写,与绑定键(磁盘上的真实大小写)对不上,账号会悄悄选错。
46
+
47
+ 每段的长度用 Python 算好写成字面数字:多条命中时取最长的键,等价于 resolve 的“最近祖先”;
48
+ 不在 sh 里用 ${#var},它对多字节字符按字节还是按字符计在不同 shell 里不一致。
49
+ """
50
+ bin_dir = expand(config.bin_dir)
51
+ lines: List[str] = [
52
+ "#!/bin/sh",
53
+ ROUTER_MARKER,
54
+ "# Generated by multi-codex from directory bindings. Do not edit; use `multi-codex bind` / `unbind`.",
55
+ "here=$(/bin/pwd -P 2>/dev/null || pwd -P) || exit 1",
56
+ "best=0",
57
+ "target=''",
58
+ "stale=''",
59
+ ]
60
+ for key in sorted(config.bindings):
61
+ name = config.bindings[key]
62
+ account = config.find(name)
63
+ if account is not None:
64
+ assign = "target={}; stale=''".format(shlex.quote(launcher.launcher_path(bin_dir, account.name)))
65
+ else:
66
+ # 悬空绑定:与 cmd_run 同样报错退出。文字里不放反引号和 $:单引号里出现它们会触发 shellcheck SC2016。
67
+ message = "account {} bound to {} is not registered; run: multi-codex unbind {}".format(name, key, key)
68
+ assign = "target=''; stale={}".format(shlex.quote(message))
69
+ length = len(key)
70
+ update = 'if [ {0} -gt "$best" ]; then best={0}; {1}; fi'.format(length, assign)
71
+ if key == "/":
72
+ # "$here/" 永远以 "/" 开头;写成 case 模式会变成 "//"*,匹配不上,所以根目录无条件命中。
73
+ lines.append(update)
74
+ continue
75
+ lines.extend([
76
+ 'case "$here/" in',
77
+ " {}/*)".format(shlex.quote(key)),
78
+ " {} ;;".format(update),
79
+ "esac",
80
+ ])
81
+ lines.extend([
82
+ 'if [ -n "$stale" ]; then',
83
+ " printf 'multi-codex: %s\\n' \"$stale\" >&2",
84
+ " exit 1",
85
+ "fi",
86
+ 'if [ -n "$target" ]; then',
87
+ ' if [ ! -x "$target" ]; then',
88
+ " printf 'multi-codex: launcher %s is missing; run multi-codex apply\\n' \"$target\" >&2",
89
+ " exit 1",
90
+ " fi",
91
+ ' exec "$target" "$@"',
92
+ "fi",
93
+ "printf 'multi-codex: no account is bound to %s or its parents; running codex (the default account)\\n' "
94
+ '"$here" >&2',
95
+ "if ! command -v codex >/dev/null 2>&1; then",
96
+ ' echo "multi-codex: codex not found in PATH" >&2',
97
+ " exit 127",
98
+ "fi",
99
+ 'exec codex "$@"',
100
+ "",
101
+ ])
102
+ return "\n".join(lines)
103
+
104
+
105
+ def same_file(path: str, others) -> bool:
106
+ """path 与 others 中任一路径是否是同一个文件(按 st_dev / st_ino)。
107
+
108
+ 计划里判断“入口位置上的文件是否正要被删”只能按文件身份:macOS 上 codex-Auto 与 codex-auto 是同一个文件,
109
+ 字符串却不同;按 casefold 比较文件名又会在区分大小写的 Linux 上把用户自己的 codex-auto 误当成要删的文件。
110
+ """
111
+ try:
112
+ target = os.lstat(path)
113
+ except OSError:
114
+ return False
115
+ for other in others:
116
+ try:
117
+ if os.path.samestat(target, os.lstat(other)):
118
+ return True
119
+ except OSError:
120
+ continue
121
+ return False
@@ -241,6 +241,20 @@ def restore_account(config: Config, config_exists: bool, name: str, skip_process
241
241
  if code:
242
242
  return code
243
243
 
244
+ if state == STATE_A:
245
+ # 先移动数据、最后才收敛注销:收敛若冲突(例如 codex-auto 与名为 auto 的账号),会停在“数据已回到
246
+ # ~/.codex、注销失败”的半完成状态。所以在写标记、动数据之前,用同样的参数先预检一次
247
+ # (feature-auto-launcher §5.1.4)。A2/B/C 已写标记,配置被锁住,进入前已在状态 A 通过预检。
248
+ new = _unregistered(config, canonical)
249
+ conflicts = [action for action in accounts.plan(config, new, config_exists=config_exists,
250
+ orphan_scope=frozenset([canonical.casefold()]))
251
+ if action.status == accounts.CONFLICT]
252
+ if conflicts:
253
+ for action in conflicts:
254
+ error(action.reason, phase="precheck", path=action.path)
255
+ error("unregistering {} would conflict; nothing was moved".format(canonical), phase="precheck")
256
+ return accounts.EXIT_CONFLICT
257
+
244
258
  if dry_run:
245
259
  if state in (STATE_A, STATE_A2):
246
260
  info("(dry-run) would unlink ~/.codex")
@@ -270,11 +284,7 @@ def restore_account(config: Config, config_exists: bool, name: str, skip_process
270
284
  migrate._test_hook("restore-renamed")
271
285
 
272
286
  info("step=unregister {}".format(canonical))
273
- new = config.copy()
274
- current = new.find(canonical)
275
- if current is not None:
276
- del new.accounts[current.name]
277
- binding.drop_account(new, canonical)
287
+ new = _unregistered(config, canonical)
278
288
  code = accounts.converge(config, new, config_exists=config_exists, dry_run=False,
279
289
  orphan_scope=frozenset([canonical.casefold()]))
280
290
  if code != accounts.EXIT_OK:
@@ -288,3 +298,13 @@ def restore_account(config: Config, config_exists: bool, name: str, skip_process
288
298
  if relogin_needed:
289
299
  info("log in again: codex login")
290
300
  return accounts.EXIT_OK
301
+
302
+
303
+ def _unregistered(config: Config, canonical: str) -> Config:
304
+ """注销 canonical 之后的配置:删账号与指向它的绑定。预检与真正注销必须用同一份。"""
305
+ new = config.copy()
306
+ current = new.find(canonical)
307
+ if current is not None:
308
+ del new.accounts[current.name]
309
+ binding.drop_account(new, canonical)
310
+ return new
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multi-codex
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Run several Codex CLI accounts side by side: separate CODEX_HOME directories, launchers and proxies.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jakoes-wu/multi-codex
@@ -134,7 +134,7 @@ Without a name, the account is named after the e-mail address in `~/.codex/auth.
134
134
  | Open VS Code with an account | `multi-codex code work ~/src/project` | [VS Code and the desktop app](#vs-code-and-the-desktop-app-experimental) |
135
135
  | Open the desktop app with an account (macOS) | `multi-codex app work` | [VS Code and the desktop app](#vs-code-and-the-desktop-app-experimental) |
136
136
  | Change the account that plain `codex` and the Dock apps use | `multi-codex use work` (after `migrate-default`) | [Default account](#default-account) |
137
- | Always use one account inside a project | In the project directory: `multi-codex bind work`, then `multi-codex run` | [Directory bindings](#directory-bindings) |
137
+ | Always use one account inside a project | In the project directory: `multi-codex bind work`, then `codex-auto` | [Directory bindings](#directory-bindings) |
138
138
  | Set or change an account's proxy | `multi-codex set work --proxy 7901` | [Proxy values](#proxy-values) |
139
139
  | Share `AGENTS.md`, skills and rules between accounts | Put them in `~/.codex-shared`, then run `multi-codex set work --shared` | [Shared resources](#shared-resources) |
140
140
  | Start a new account with another account's settings | `multi-codex add new --config-from work` | [Copying settings](#copying-settings-from-another-account) |
@@ -172,6 +172,7 @@ More questions are answered in the [FAQ](#faq).
172
172
  | `multi-codex doctor [--json]` | Check the installation, configuration and accounts. Read-only. |
173
173
  | `multi-codex run [NAME] [-- COMMAND ...]` | Run a command (default: `codex`) with an account's environment. Without NAME, the account bound to the current directory is used. |
174
174
  | `multi-codex bind [NAME [DIR]]` / `unbind [DIR]` | Bind a directory to an account, list bindings, or remove one. |
175
+ | `multi-codex which [DIR]` | Show which account a directory uses (the same one `codex-auto` and `run` pick). |
175
176
  | `multi-codex code NAME [PATH] [-- ARGS]` | Open VS Code for an account (experimental). |
176
177
  | `multi-codex app NAME` | Open the Codex desktop app for an account (macOS, experimental). |
177
178
  | `multi-codex path NAME` | Print an account's directory. |
@@ -218,7 +219,7 @@ Subcommands, options and registered account names (including e-mail addresses) a
218
219
 
219
220
  ### Running other commands
220
221
 
221
- `multi-codex run NAME -- COMMAND ...` runs any command with exactly the environment of `codex-NAME` (`CODEX_HOME`, proxy, extra variables). Without a command it runs `codex`. Everything after the first `--` is passed through unchanged; the exit code is the command's. `multi-codex path NAME` prints the account directory.
222
+ `multi-codex run NAME -- COMMAND ...` runs any command with exactly the environment of `codex-NAME` (`CODEX_HOME`, proxy, extra variables). Without a command it runs `codex`. Everything after the first `--` is passed through unchanged; the exit code is the command's. `multi-codex path NAME` prints the account directory. For example, to manage an account's MCP servers: `multi-codex run work -- codex mcp list`.
222
223
 
223
224
  ### Directory bindings
224
225
 
@@ -227,8 +228,12 @@ cd ~/work/project && multi-codex bind work # this directory and everything b
227
228
  multi-codex run -- codex resume # no account name needed here
228
229
  multi-codex bind # list bindings; * marks the one in effect here
229
230
  multi-codex unbind # remove the binding of the current directory
231
+ codex-auto # runs codex with the bound account
232
+ multi-codex which # prints the account this directory uses
230
233
  ```
231
234
 
235
+ While at least one binding exists, multi-codex generates `codex-auto` next to the other launchers. It looks up the nearest bound directory exactly like `run` and starts that account's launcher; outside any bound directory it runs plain `codex` (the default account, see [Default account](#default-account)) and says so on stderr. It is updated whenever bindings or accounts change and removed with the last binding. An account named `auto` cannot coexist with bindings: rename it first (`multi-codex rename auto NEW`).
236
+
232
237
  `run` without an account name walks up from the current directory and uses the nearest bound directory. Bindings are stored in `config.json` (not in your project), keyed by the real path of the directory (links resolved, the on-disk letter case used on case-insensitive file systems). `apply -f` keeps the current bindings; removing an account (`remove`, `restore`, `apply -f`) also removes its bindings. Older versions of multi-codex drop the `bindings` field on their next write.
233
238
 
234
239
  ### Copying settings from another account
@@ -241,7 +246,7 @@ multi-codex unbind # remove the binding of the curre
241
246
  multi-codex rename work client-a
242
247
  ```
243
248
 
244
- The account and its launcher get the new name (`codex-client-a`; `codex-work` is deleted), and directory bindings and the default account follow. The directory stays where it is, so the login, sessions and shared links are kept; `config.json` records it as `"dir": "work"` (in `list --json`, `dir` is the full path instead). A name that is still used as another account's directory, such as `work` after this rename, cannot be given to a new account. Renaming only the letter case is not supported. Before downgrading to a version older than 0.9, rename the account back: older versions ignore `dir` and would use `<root>/client-a`.
249
+ The account and its launcher get the new name (`codex-client-a`; `codex-work` is deleted), and directory bindings, `codex-auto` and the default account follow. The directory stays where it is, so the login, sessions and shared links are kept; `config.json` records it as `"dir": "work"` (in `list --json`, `dir` is the full path instead). A name that is still used as another account's directory, such as `work` after this rename, cannot be given to a new account. Renaming only the letter case is not supported. Before downgrading to a version older than 0.9, rename the account back: older versions ignore `dir` and would use `<root>/client-a`.
245
250
 
246
251
  ### Per-account environment variables
247
252
 
@@ -492,7 +497,7 @@ The tool goes to `~/.local/share/multi-codex` and the `multi-codex` command to `
492
497
  curl -fsSL https://raw.githubusercontent.com/jakoes-wu/multi-codex/main/install.sh | sh -s -- --uninstall # without a clone
493
498
  ```
494
499
 
495
- This removes the tool only. Your configuration, account directories and `codex-<name>` launchers stay; the launchers keep working because they do not depend on multi-codex.
500
+ This removes the tool only. Your configuration, account directories, `codex-<name>` launchers and `codex-auto` stay; they keep working because they do not depend on multi-codex.
496
501
 
497
502
  ## Contributing
498
503
 
@@ -17,6 +17,7 @@ src/multi_codex/launcher.py
17
17
  src/multi_codex/lock.py
18
18
  src/multi_codex/migrate.py
19
19
  src/multi_codex/platform.py
20
+ src/multi_codex/router.py
20
21
  src/multi_codex/shared.py
21
22
  src/multi_codex/shellpath.py
22
23
  src/multi_codex/switch.py
@@ -41,4 +42,5 @@ tests/test_migrate.py
41
42
  tests/test_onboarding.py
42
43
  tests/test_onboarding_commands.py
43
44
  tests/test_platform.py
45
+ tests/test_router.py
44
46
  tests/test_switch.py
@@ -0,0 +1,370 @@
1
+ """feature-auto-launcher §8:codex-auto 入口与 which。"""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import unittest
9
+
10
+ from helpers import SRC, CliTestCase
11
+ from test_binding import case_insensitive
12
+
13
+ sys.path.insert(0, SRC)
14
+ from multi_codex import cli, completion, router # noqa: E402
15
+
16
+
17
+ class RouterBase(CliTestCase):
18
+ def setUp(self):
19
+ super().setUp()
20
+ self.auto = os.path.join(self.bin, "codex-auto")
21
+ self.projects = os.path.join(self.tmp, "p")
22
+ os.makedirs(self.projects)
23
+
24
+ def project(self, *parts):
25
+ path = os.path.join(self.projects, *parts)
26
+ os.makedirs(path, exist_ok=True)
27
+ return path
28
+
29
+ def config(self):
30
+ with open(os.path.join(self.state, "config.json")) as handle:
31
+ return json.load(handle)
32
+
33
+ def write_config(self, data):
34
+ with open(os.path.join(self.state, "config.json"), "w") as handle:
35
+ json.dump(data, handle)
36
+
37
+ def run_auto(self, cwd, *args, env=None):
38
+ """在 cwd 运行 codex-auto,返回 (退出码, stderr, 假 codex 看到的环境或 None)。"""
39
+ full_env = dict(self.env)
40
+ full_env.update(env or {})
41
+ for suffix in (".args", ".env"):
42
+ if os.path.exists(self.fake_out + suffix):
43
+ os.unlink(self.fake_out + suffix)
44
+ proc = subprocess.run([self.auto] + list(args), cwd=cwd, env=full_env, stdout=subprocess.PIPE,
45
+ stderr=subprocess.PIPE, universal_newlines=True)
46
+ seen = None
47
+ if os.path.exists(self.fake_out + ".env"):
48
+ seen = {}
49
+ with open(self.fake_out + ".env") as handle:
50
+ for line in handle.read().splitlines():
51
+ if "=" in line:
52
+ key, value = line.split("=", 1)
53
+ seen[key] = value
54
+ return proc.returncode, proc.stderr, seen
55
+
56
+ def account_of(self, seen):
57
+ """从假 codex 看到的 CODEX_HOME 反推账号名;没有 CODEX_HOME 表示普通 codex。"""
58
+ if seen is None or "CODEX_HOME" not in seen:
59
+ return None
60
+ return os.path.basename(seen["CODEX_HOME"])
61
+
62
+ def run_choice(self, cwd):
63
+ """`run` 省略账号名时选中的账号,或 None(没有绑定 / 报错)。"""
64
+ result = self.run_cli("run", "--", "codex", cwd=cwd)
65
+ if result.code != 0:
66
+ return None
67
+ with open(self.fake_out + ".env") as handle:
68
+ for line in handle.read().splitlines():
69
+ if line.startswith("CODEX_HOME="):
70
+ return os.path.basename(line.split("=", 1)[1])
71
+ return None
72
+
73
+
74
+ class GenerateTest(RouterBase):
75
+ """T1、T2、T9、T12、T13、T14。"""
76
+
77
+ def test_lifecycle(self):
78
+ self.ok("add", "work")
79
+ self.assertFalse(os.path.lexists(self.auto))
80
+ result = self.ok("bind", "work", self.project("a"))
81
+ self.assertIn("create launcher ~/.local/bin/codex-auto", result.out)
82
+ self.assertTrue(router.is_router(self.auto))
83
+ self.assertEqual(os.stat(self.auto).st_mode & 0o777, 0o755)
84
+ self.assertIn("already up to date", self.ok("apply").out)
85
+ result = self.ok("unbind", self.project("a"))
86
+ self.assertIn("no directory bindings", result.out)
87
+ self.assertFalse(os.path.lexists(self.auto))
88
+
89
+ def test_bin_dir_change(self):
90
+ self.ok("add", "work")
91
+ self.ok("bind", "work", self.project("a"))
92
+ new_bin = os.path.join(self.home, "bin2")
93
+ self.ok("init", "--bin-dir", new_bin)
94
+ self.assertFalse(os.path.lexists(self.auto))
95
+ self.assertTrue(router.is_router(os.path.join(new_bin, "codex-auto")))
96
+
97
+ def test_doctor_drift(self):
98
+ self.ok("add", "work")
99
+ self.ok("bind", "work", self.project("a"))
100
+ os.unlink(self.auto)
101
+ report = json.loads(self.run_cli("doctor", "--json").out)
102
+ drift = next(item for item in report["checks"] if item["id"] == "drift")
103
+ self.assertNotEqual(drift["status"], "ok")
104
+ self.ok("apply")
105
+ report = json.loads(self.run_cli("doctor", "--json").out)
106
+ drift = next(item for item in report["checks"] if item["id"] == "drift")
107
+ self.assertEqual(drift["status"], "ok")
108
+
109
+ def test_user_file_is_a_conflict(self):
110
+ self.ok("add", "work")
111
+ self.write(self.auto, "#!/bin/sh\necho mine\n", 0o755)
112
+ before = self.snapshot()
113
+ result = self.run_cli("bind", "work", self.project("a"))
114
+ self.assertEqual(result.code, 3, result)
115
+ self.assertIn("not the multi-codex router", result.err)
116
+ self.assertEqual(self.snapshot(), before)
117
+
118
+ @unittest.skipUnless(shutil.which("shellcheck"), "shellcheck is not installed")
119
+ def test_shellcheck(self):
120
+ self.ok("add", "work")
121
+ self.ok("add", "home")
122
+ self.ok("bind", "work", self.project("a"))
123
+ self.ok("bind", "home", self.project("a", "sub dir"))
124
+ self.ok("bind", "home", "/")
125
+ data = self.config()
126
+ data["bindings"][self.project("gone")] = "gone"
127
+ self.write_config(data)
128
+ self.ok("apply")
129
+ proc = subprocess.run(["shellcheck", self.auto], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
130
+ universal_newlines=True)
131
+ self.assertEqual(proc.returncode, 0, proc.stdout)
132
+
133
+
134
+ class MatchTest(RouterBase):
135
+ """T3–T8:选账号的规则与 run 一致。"""
136
+
137
+ def setUp(self):
138
+ super().setUp()
139
+ self.ok("add", "work")
140
+ self.ok("add", "home")
141
+
142
+ def test_choose_like_run(self):
143
+ top = self.project("a")
144
+ sub = self.project("a", "sub")
145
+ self.ok("bind", "work", top)
146
+ self.ok("bind", "home", sub)
147
+ cases = ((top, "work"), (self.project("a", "other"), "work"), (sub, "home"),
148
+ (self.project("a", "sub", "deeper"), "home"))
149
+ for cwd, expected in cases:
150
+ with self.subTest(cwd=cwd):
151
+ code, _, seen = self.run_auto(cwd, "x", env={"CODEX_API_KEY": "leak"})
152
+ self.assertEqual(code, 0)
153
+ self.assertEqual(self.account_of(seen), expected)
154
+ self.assertNotIn("CODEX_API_KEY", seen) # 经账号启动命令,隔离变量被清除
155
+ self.assertEqual(self.run_choice(cwd), expected)
156
+
157
+ def test_unbound_runs_plain_codex(self):
158
+ self.ok("bind", "work", self.project("a"))
159
+ code, err, seen = self.run_auto(self.project("b"), "x")
160
+ self.assertEqual(code, 0)
161
+ self.assertIsNone(self.account_of(seen))
162
+ self.assertIn("no account is bound to", err)
163
+ self.assertIn("default account", err)
164
+
165
+ def test_special_names(self):
166
+ names = ("with space", "it's", "中文", "a*", "a?b", "[x]")
167
+ for name in names:
168
+ self.ok("bind", "work", self.project(name))
169
+ for name in names:
170
+ with self.subTest(name=name):
171
+ self.assertEqual(self.account_of(self.run_auto(self.project(name))[2]), "work")
172
+ for sibling in ("ab", "axb", "x"):
173
+ with self.subTest(sibling=sibling):
174
+ self.assertIsNone(self.account_of(self.run_auto(self.project(sibling))[2]))
175
+
176
+ def test_root_binding(self):
177
+ self.ok("bind", "home", "/")
178
+ self.ok("bind", "work", self.project("a"))
179
+ self.assertEqual(self.account_of(self.run_auto(self.project("a", "x"))[2]), "work")
180
+ self.assertEqual(self.account_of(self.run_auto(self.project("b"))[2]), "home")
181
+
182
+ def test_symlink(self):
183
+ real = self.project("real")
184
+ self.ok("bind", "work", real)
185
+ link = os.path.join(self.tmp, "lnk")
186
+ os.symlink(real, link)
187
+ self.assertEqual(self.account_of(self.run_auto(link)[2]), "work")
188
+
189
+ def test_case_insensitive_cd(self):
190
+ if not case_insensitive(self.projects):
191
+ self.skipTest("the file system is case-sensitive")
192
+ real = self.project("CaseDir")
193
+ self.ok("bind", "work", real)
194
+ typed = os.path.join(self.projects, "casedir")
195
+ # 用 /bin/sh -c 'cd 小写写法 && codex-auto',模拟用户在 shell 里换了大小写 cd 进来。
196
+ proc = subprocess.run(["/bin/sh", "-c", 'cd "$1" && exec "$2"', "sh", typed, self.auto], env=self.env,
197
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
198
+ self.assertEqual(proc.returncode, 0, proc.stderr)
199
+ with open(self.fake_out + ".env") as handle:
200
+ self.assertIn("CODEX_HOME=" + os.path.join(self.root, "work"), handle.read())
201
+
202
+ def test_stale_binding(self):
203
+ self.ok("bind", "work", self.project("a"))
204
+ data = self.config()
205
+ data["bindings"][os.path.realpath(self.project("a", "sub"))] = "gone"
206
+ self.write_config(data)
207
+ self.ok("apply")
208
+ code, err, seen = self.run_auto(self.project("a", "sub"))
209
+ self.assertEqual(code, 1)
210
+ self.assertIn("is not registered", err)
211
+ self.assertIn("multi-codex unbind", err)
212
+ self.assertIsNone(seen)
213
+ self.assertEqual(self.run_cli("run", "--", "codex", cwd=self.project("a", "sub")).code, 1)
214
+
215
+ def test_missing_launcher(self):
216
+ self.ok("bind", "work", self.project("a"))
217
+ os.unlink(os.path.join(self.bin, "codex-work"))
218
+ code, err, _ = self.run_auto(self.project("a"))
219
+ self.assertEqual(code, 1)
220
+ self.assertIn("is missing", err)
221
+
222
+
223
+ class AutoAccountTest(RouterBase):
224
+ """T10–T10f:名为 auto 的账号与入口。"""
225
+
226
+ def setUp(self):
227
+ super().setUp()
228
+ self.ok("add", "work")
229
+
230
+ def test_conflicts(self):
231
+ self.ok("add", "auto")
232
+ before = self.snapshot()
233
+ result = self.run_cli("bind", "work", self.project("a"))
234
+ self.assertEqual(result.code, 3, result)
235
+ self.assertIn("multi-codex rename auto", result.err)
236
+ self.assertEqual(self.snapshot(), before)
237
+ self.ok("remove", "auto")
238
+ self.ok("bind", "work", self.project("a"))
239
+ before = self.snapshot()
240
+ self.assertEqual(self.run_cli("add", "auto").code, 3)
241
+ self.assertEqual(self.snapshot(), before)
242
+
243
+ def auto_with_binding(self, name="auto"):
244
+ """手写配置造出“账号 auto + 绑定”:正常命令不允许这种组合。"""
245
+ self.ok("add", name)
246
+ data = self.config()
247
+ data.setdefault("bindings", {})[os.path.realpath(self.project("a"))] = "work"
248
+ self.write_config(data)
249
+
250
+ def test_rename_remove_restore_auto(self):
251
+ for command in (("rename", "auto", "x"), ("remove", "auto")):
252
+ with self.subTest(command=command):
253
+ self.auto_with_binding()
254
+ self.ok(*command)
255
+ self.assertTrue(router.is_router(self.auto))
256
+ self.assertEqual(self.account_of(self.run_auto(self.project("a"))[2]), "work")
257
+ # 复位:删掉绑定、清理改名后的账号
258
+ self.ok("unbind", self.project("a"))
259
+ if command[0] == "rename":
260
+ self.ok("remove", "x")
261
+ self.auto_with_binding()
262
+ self.ok("use", "auto")
263
+ self.ok("restore", "auto")
264
+ self.assertTrue(router.is_router(self.auto))
265
+
266
+ def test_restore_other_account_is_refused_before_moving(self):
267
+ # 绑定指向第三个账号 home:restore work 之后仍有绑定,名为 auto 的账号与入口冲突。
268
+ self.ok("add", "home")
269
+ self.ok("add", "auto")
270
+ data = self.config()
271
+ data.setdefault("bindings", {})[os.path.realpath(self.project("a"))] = "home"
272
+ self.write_config(data)
273
+ self.ok("use", "work")
274
+ link = os.path.join(self.home, ".codex")
275
+ result = self.run_cli("restore", "work")
276
+ self.assertEqual(result.code, 3, result)
277
+ self.assertIn("nothing was moved", result.err)
278
+ self.assertTrue(os.path.islink(link))
279
+ self.assertTrue(os.path.isdir(os.path.join(self.root, "work")))
280
+ self.assertFalse(os.path.exists(os.path.join(self.state, "restore-journal.json")))
281
+
282
+ def test_case_variant_on_case_insensitive_fs(self):
283
+ if not case_insensitive(self.bin if os.path.isdir(self.bin) else self.tmp):
284
+ self.skipTest("the file system is case-sensitive")
285
+ self.auto_with_binding("Auto")
286
+ self.ok("rename", "Auto", "x")
287
+ self.assertTrue(router.is_router(self.auto))
288
+
289
+
290
+ class FollowTest(RouterBase):
291
+ """T10d、T10e:rename、remove、apply -f、add 让入口跟着变。"""
292
+
293
+ def setUp(self):
294
+ super().setUp()
295
+ self.ok("add", "work")
296
+ self.ok("bind", "work", self.project("a"))
297
+
298
+ def test_rename_updates_target(self):
299
+ result = self.ok("rename", "work", "job")
300
+ self.assertIn("update launcher ~/.local/bin/codex-auto", result.out)
301
+ with open(self.auto) as handle:
302
+ self.assertIn("codex-job", handle.read())
303
+ self.assertEqual(self.account_of(self.run_auto(self.project("a"))[2]), "work") # 目录名仍是 work
304
+ self.assertEqual(self.run_choice(self.project("a")), "work")
305
+
306
+ def test_remove_and_apply_file(self):
307
+ self.ok("remove", "work")
308
+ self.assertFalse(os.path.lexists(self.auto))
309
+ self.ok("add", "work")
310
+ self.ok("bind", "work", self.project("a"))
311
+ data = self.config()
312
+ del data["accounts"]["work"]
313
+ path = os.path.join(self.tmp, "accounts.json")
314
+ self.write(path, json.dumps(data))
315
+ self.ok("apply", "-f", path)
316
+ self.assertFalse(os.path.lexists(self.auto))
317
+
318
+ def test_add_makes_stale_binding_valid(self):
319
+ data = self.config()
320
+ data["bindings"][os.path.realpath(self.project("b"))] = "home"
321
+ self.write_config(data)
322
+ self.ok("apply")
323
+ self.assertEqual(self.run_auto(self.project("b"))[0], 1)
324
+ self.ok("add", "home")
325
+ self.assertEqual(self.account_of(self.run_auto(self.project("b"))[2]), "home")
326
+
327
+
328
+ class WhichTest(RouterBase):
329
+ """T15。"""
330
+
331
+ def setUp(self):
332
+ super().setUp()
333
+ self.ok("add", "work")
334
+ self.ok("bind", "work", self.project("a"))
335
+
336
+ def test_bound(self):
337
+ result = self.ok("which", self.project("a", "deep"))
338
+ self.assertEqual(result.out, "work\n")
339
+ self.assertIn("bound to", result.err)
340
+ result = self.ok("which", cwd=self.project("a"))
341
+ self.assertEqual(result.out, "work\n")
342
+
343
+ def test_unbound(self):
344
+ result = self.ok("which", self.project("b"))
345
+ self.assertEqual(result.out, self.ok("use").out)
346
+ self.assertIn("no binding for", result.err)
347
+ result = self.ok("which", self.project("b"), env={"CODEX_HOME": "/somewhere"})
348
+ self.assertIn("CODEX_HOME is set", result.err)
349
+
350
+ def test_errors(self):
351
+ data = self.config()
352
+ data["bindings"][os.path.realpath(self.project("c"))] = "gone"
353
+ self.write_config(data)
354
+ result = self.run_cli("which", self.project("c"))
355
+ self.assertEqual(result.code, 1, result)
356
+ self.assertIn("is not registered", result.err)
357
+ self.assertEqual(self.run_cli("which", os.path.join(self.tmp, "nope")).code, 1)
358
+
359
+
360
+ class HelpTest(unittest.TestCase):
361
+ """T17。"""
362
+
363
+ def test_help_and_completion(self):
364
+ everyday = dict(cli.COMMAND_GROUPS)["Everyday"]
365
+ self.assertIn("which", [name for name, _ in everyday])
366
+ self.assertIn("which", completion.command_spec(cli.build_parser()))
367
+
368
+
369
+ if __name__ == "__main__":
370
+ unittest.main()
@@ -228,15 +228,32 @@ class RestoreTest(SwitchBase):
228
228
  self.write(self.marker(), json.dumps(data))
229
229
  self.assertEqual(self.run_cli("restore", "a").code, 3)
230
230
 
231
- def test_unregister_failure_keeps_marker(self):
232
- # b 的启动命令被换成不受管的文件:converge 判冲突,第 3 步失败。
231
+ def test_conflict_is_found_before_moving(self):
232
+ # b 的启动命令被换成不受管的文件:注销时的收敛会冲突。v0.10 起在动数据之前预检(feature-auto-launcher
233
+ # §5.1.4),所以什么都不动、不留标记。
233
234
  launcher_b = os.path.join(self.bin, "codex-b")
234
235
  os.unlink(launcher_b)
235
236
  self.write(launcher_b, "#!/bin/sh\n", 0o755)
237
+ before = self.tree(self.dir_of("a"))
236
238
  result = self.run_cli("restore", "a")
237
239
  self.assertEqual(result.code, 3, result)
238
- self.assertTrue(os.path.exists(self.marker()))
240
+ self.assertIn("nothing was moved", result.err)
241
+ self.assertFalse(os.path.exists(self.marker()))
242
+ self.assertTrue(os.path.islink(self.link))
243
+ self.assertEqual(self.tree(self.dir_of("a")), before)
239
244
  os.unlink(launcher_b)
245
+ self.ok("restore", "a")
246
+ self.assert_restored()
247
+
248
+ @unittest.skipIf(os.geteuid() == 0, "root ignores directory permissions")
249
+ def test_unregister_failure_keeps_marker(self):
250
+ # 预检查不出的失败:启动命令目录不可写,删除 codex-a 时 OSError,第 3 步失败,保留标记。
251
+ os.chmod(self.bin, 0o555)
252
+ self.addCleanup(os.chmod, self.bin, 0o755)
253
+ result = self.run_cli("restore", "a")
254
+ self.assertEqual(result.code, 1, result)
255
+ self.assertTrue(os.path.exists(self.marker()))
256
+ os.chmod(self.bin, 0o755)
240
257
  self.assertIn("from state C", self.ok("restore", "a").out)
241
258
  self.assertFalse(os.path.exists(self.marker()))
242
259
 
File without changes
File without changes
File without changes