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.
- {multi_codex-0.9.0/src/multi_codex.egg-info → multi_codex-0.10.0}/PKG-INFO +10 -5
- {multi_codex-0.9.0 → multi_codex-0.10.0}/README.md +9 -4
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/__init__.py +1 -1
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/accounts.py +41 -1
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/cli.py +38 -0
- multi_codex-0.10.0/src/multi_codex/router.py +121 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/switch.py +25 -5
- {multi_codex-0.9.0 → multi_codex-0.10.0/src/multi_codex.egg-info}/PKG-INFO +10 -5
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex.egg-info/SOURCES.txt +2 -0
- multi_codex-0.10.0/tests/test_router.py +370 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_switch.py +20 -3
- {multi_codex-0.9.0 → multi_codex-0.10.0}/LICENSE +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/pyproject.toml +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/setup.cfg +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/__main__.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/actions.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/apps.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/binding.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/completion.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/config.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/doctor.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/fsutil.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/identity.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/launcher.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/lock.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/migrate.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/platform.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/shared.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/shellpath.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex/usage.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex.egg-info/dependency_links.txt +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex.egg-info/entry_points.txt +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/src/multi_codex.egg-info/top_level.txt +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_accounts.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_apps.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_binding.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_config.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_config_copy.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_ergonomics.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_everyday.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_fsutil.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_insight.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_install.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_isolation.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_migrate.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_onboarding.py +0 -0
- {multi_codex-0.9.0 → multi_codex-0.10.0}/tests/test_onboarding_commands.py +0 -0
- {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.
|
|
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 `
|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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.
|
|
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 `
|
|
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
|
|
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
|
|
232
|
-
# b
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|