multi-codex 0.8.0__tar.gz → 0.9.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 (46) hide show
  1. {multi_codex-0.8.0/src/multi_codex.egg-info → multi_codex-0.9.0}/PKG-INFO +25 -6
  2. {multi_codex-0.8.0 → multi_codex-0.9.0}/README.md +24 -5
  3. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/__init__.py +1 -1
  4. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/accounts.py +31 -4
  5. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/apps.py +8 -5
  6. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/cli.py +118 -8
  7. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/completion.py +2 -2
  8. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/config.py +70 -3
  9. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/doctor.py +3 -2
  10. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/launcher.py +7 -1
  11. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/migrate.py +2 -2
  12. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/platform.py +2 -1
  13. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/shared.py +4 -0
  14. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/switch.py +5 -1
  15. {multi_codex-0.8.0 → multi_codex-0.9.0/src/multi_codex.egg-info}/PKG-INFO +25 -6
  16. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex.egg-info/SOURCES.txt +1 -0
  17. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_ergonomics.py +11 -8
  18. multi_codex-0.9.0/tests/test_isolation.py +428 -0
  19. {multi_codex-0.8.0 → multi_codex-0.9.0}/LICENSE +0 -0
  20. {multi_codex-0.8.0 → multi_codex-0.9.0}/pyproject.toml +0 -0
  21. {multi_codex-0.8.0 → multi_codex-0.9.0}/setup.cfg +0 -0
  22. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/__main__.py +0 -0
  23. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/actions.py +0 -0
  24. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/binding.py +0 -0
  25. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/fsutil.py +0 -0
  26. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/identity.py +0 -0
  27. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/lock.py +0 -0
  28. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/shellpath.py +0 -0
  29. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex/usage.py +0 -0
  30. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex.egg-info/dependency_links.txt +0 -0
  31. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex.egg-info/entry_points.txt +0 -0
  32. {multi_codex-0.8.0 → multi_codex-0.9.0}/src/multi_codex.egg-info/top_level.txt +0 -0
  33. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_accounts.py +0 -0
  34. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_apps.py +0 -0
  35. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_binding.py +0 -0
  36. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_config.py +0 -0
  37. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_config_copy.py +0 -0
  38. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_everyday.py +0 -0
  39. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_fsutil.py +0 -0
  40. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_insight.py +0 -0
  41. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_install.py +0 -0
  42. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_migrate.py +0 -0
  43. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_onboarding.py +0 -0
  44. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_onboarding_commands.py +0 -0
  45. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_platform.py +0 -0
  46. {multi_codex-0.8.0 → multi_codex-0.9.0}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multi-codex
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -142,6 +142,7 @@ Without a name, the account is named after the e-mail address in `~/.codex/auth.
142
142
  | Set up all accounts on a new machine | `curl -fsSL https://raw.githubusercontent.com/jakoes-wu/multi-codex/main/install.sh \| sh -s -- --config accounts.json` | [Declarative setup](#declarative-setup-with-apply) |
143
143
  | Get tab completion | `eval "$(multi-codex completion zsh)"` | [Shell completion](#shell-completion) |
144
144
  | See what a command would change | add `--dry-run` | [Commands](#commands) |
145
+ | Rename an account | `multi-codex rename work client-a` (the directory and login stay) | [Renaming accounts](#renaming-accounts) |
145
146
  | Remove an account | `multi-codex remove work` (the directory is kept) | [Commands](#commands) |
146
147
 
147
148
  More questions are answered in the [FAQ](#faq).
@@ -159,8 +160,9 @@ More questions are answered in the [FAQ](#faq).
159
160
  | ---- | ---- |
160
161
  | `multi-codex init [--root DIR] [--bin-dir DIR] [--shared-dir DIR] [--shared-items A,B]` | Create or change global settings. |
161
162
  | `multi-codex migrate-default [NAME] [--source DIR] [--copy] [--keep-backup] [--proxy P] [--skip-process-check] [--accept-relogin]` | Turn the default directory into an account. Without NAME, the e-mail address in its `auth.json` is used. |
162
- | `multi-codex add NAME [--proxy P] [--shared [DIR] \| --no-shared] [--adopt] [--config-from OTHER]` | Add an account, adopt an existing directory, or change its options. `--config-from` copies `config.toml` from another account once. |
163
- | `multi-codex set NAME [--proxy P] [--shared [DIR] \| --no-shared] [--adopt] [--config-from OTHER]` | Change an existing account; same options as `add`, but never creates one. |
163
+ | `multi-codex add NAME [--proxy P] [--shared [DIR] \| --no-shared] [--shared-exclude ITEM] [--shared-include ITEM] [--adopt] [--config-from OTHER]` | Add an account, adopt an existing directory, or change its options. `--config-from` copies `config.toml` from another account once. |
164
+ | `multi-codex set NAME [same options as add]` | Change an existing account; same options as `add`, but never creates one. |
165
+ | `multi-codex rename OLD NEW` | Rename an account and its launcher; the directory, login and links stay. |
164
166
  | `multi-codex login NAME [-- ARGS]` | Run `codex login` with an account's environment; arguments after `--` go to `codex login`. Does not need `~/.local/bin` on `PATH`. |
165
167
  | `multi-codex proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
166
168
  | `multi-codex remove NAME` | Unregister an account and delete its launcher. **The account directory is kept.** |
@@ -178,7 +180,7 @@ More questions are answered in the [FAQ](#faq).
178
180
  | `multi-codex restore NAME [--skip-process-check] [--accept-relogin]` | Undo `migrate-default`: move the account back to `~/.codex`. |
179
181
  | `multi-codex completion bash\|zsh\|fish` | Print a shell completion script. |
180
182
 
181
- Every write command accepts `--dry-run`. `init`, `add`, `set`, `proxy`, `remove`, `apply`, `bind`, `unbind` and `env` print only the items they change (or `already up to date`); `-v` / `--verbose` also prints unchanged items. For `list`, `-v` means the full table instead. `list`, `usage` and `doctor` never change anything; with `--json` they print a single JSON object on stdout (with a `"version": 1` field) and keep warnings on stderr. Use `--json` in scripts: the table layout is not guaranteed to stay the same.
183
+ Every write command accepts `--dry-run`. `init`, `add`, `set`, `rename`, `proxy`, `remove`, `apply`, `bind`, `unbind` and `env` print only the items they change (or `already up to date`); `-v` / `--verbose` also prints unchanged items. For `list`, `-v` means the full table instead. `list`, `usage` and `doctor` never change anything; with `--json` they print a single JSON object on stdout (with a `"version": 1` field) and keep warnings on stderr. Use `--json` in scripts: the table layout is not guaranteed to stay the same.
182
184
 
183
185
  ### Login and usage
184
186
 
@@ -233,6 +235,14 @@ multi-codex unbind # remove the binding of the curre
233
235
 
234
236
  `multi-codex add new --config-from work` copies `config.toml` from `work` into `new` once; afterwards the two files are independent. An existing `config.toml` with different content is a conflict (nothing is written), and so is copying into an account that shares `config.toml`. The copy includes everything in the file, such as `cli_auth_credentials_store` or absolute paths that point into the other account.
235
237
 
238
+ ### Renaming accounts
239
+
240
+ ```sh
241
+ multi-codex rename work client-a
242
+ ```
243
+
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`.
245
+
236
246
  ### Per-account environment variables
237
247
 
238
248
  ```sh
@@ -242,6 +252,8 @@ multi-codex env work --unset TERM_PROGRAM
242
252
  multi-codex env work --clear
243
253
  ```
244
254
 
255
+ Launchers (and `run`, `login`, `code` and `usage --live`, which use the same environment) clear `CODEX_API_KEY`, `CODEX_ACCESS_TOKEN` and `CODEX_SQLITE_HOME` inherited from your shell, so one exported API key or database location does not leak into every account. An account that needs an API key sets it here, for example `multi-codex env work CODEX_API_KEY=sk-…`; per-account variables are applied after the clearing.
256
+
245
257
  The variables are stored in `config.json` (`accounts.<name>.env`) and written into the launcher. Values are used literally (no `$VAR` expansion). `CODEX_HOME` and the proxy variables are reserved: use `multi-codex proxy` for proxies. A launcher with environment variables is readable only by you (mode 0700), and `list --json` shows only the variable names; still, the values are stored in plain text, so do not put secrets there that need stronger protection. Older versions of multi-codex ignore the `env` field and drop it on their next write; run `multi-codex env NAME --clear` before downgrading.
246
258
 
247
259
  ### VS Code and the desktop app (experimental)
@@ -327,7 +339,7 @@ Progress is recorded in `~/.config/multi-codex/migrate-journal.json`. If the mig
327
339
 
328
340
  Sockets and FIFOs (runtime files such as `ipc.sock`) are not copied in copy mode. On macOS, copy mode does not preserve extended attributes.
329
341
 
330
- If `CODEX_HOME`, `CODEX_SQLITE_HOME`, `CODEX_API_KEY` or `CODEX_ACCESS_TOKEN` is set in your environment, multi-codex warns you: these variables override or bypass per-account isolation.
342
+ If `CODEX_HOME`, `CODEX_SQLITE_HOME`, `CODEX_API_KEY` or `CODEX_ACCESS_TOKEN` is set in your environment, multi-codex warns you: launchers set or clear them, but plain `codex` still uses them.
331
343
 
332
344
  ### Undoing a migration
333
345
 
@@ -361,6 +373,10 @@ multi-codex creates the missing links and remembers which links it created. Turn
361
373
 
362
374
  If you already linked an account to the shared directory by hand, `multi-codex set NAME --shared --adopt` takes those links over without recreating them: from then on, turning sharing off removes them as well. Only links that already point to the matching shared item are adopted.
363
375
 
376
+ One account can opt out of some shared items: `multi-codex set work --shared-exclude skills` removes the `skills` link multi-codex created in `work` (as if sharing were off for that item) and no longer creates it; `--shared-include skills` undoes that. Both options can be repeated, names match `shared.items` exactly, and `list` shows the exclusions as `yes (not: skills)`. To exclude `config.toml` and then copy another account's settings into it, run `--shared-exclude config.toml` and `--config-from` as two separate commands.
377
+
378
+ Items that hold one account's own state can never be shared: `shared.items` must not contain the entries marked "No" in the table below. A configuration that lists one fails to load and names the item; remove it from `config.json`.
379
+
364
380
  ### What can be shared
365
381
 
366
382
  Based on the Codex source code (openai/codex at `6b4daafd`):
@@ -375,7 +391,7 @@ Based on the Codex source code (openai/codex at `6b4daafd`):
375
391
  | `history.jsonl` | Prompt history | Yes | Reads and writes are file-locked; the histories of the accounts are merged. |
376
392
  | `auth.json`, `secrets/`, `.credentials.json`, `.env` | Credentials | **No** | They are the account. |
377
393
  | `installation_id` | Installation identifier | No | Sent with requests; sharing makes several accounts look like one installation. |
378
- | `*.sqlite` (`state_5.sqlite`, …) | Threads, logs, memories | **No** | `state_5.sqlite` records account IDs. |
394
+ | `*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm` (`state_5.sqlite`, …), `sqlite/` | Threads, logs, memories | **No** | `state_5.sqlite` records account IDs. |
379
395
  | `sessions/`, `archived_sessions/`, `session_index.jsonl` | Session logs | No | The index has only an in-process lock; sessions record the account that created them. |
380
396
  | `models_cache.json`, `cache/` | Caches | Not needed | Keyed by the account; a mismatch is a cache miss. |
381
397
  | `app-server-control/`, `app-server-daemon/`, `packages/`, `tmp/`, `.tmp/`, `log/`, `shell_snapshots/` | Runtime state | No | Per process or per session. |
@@ -438,8 +454,11 @@ Run the installer again; configuration, accounts and launchers are not touched:
438
454
  ```sh
439
455
  curl -fsSL https://raw.githubusercontent.com/jakoes-wu/multi-codex/main/install.sh | sh
440
456
  multi-codex --version
457
+ multi-codex apply # after upgrading from 0.8 or older: rewrites the launchers
441
458
  ```
442
459
 
460
+ 0.9 changed what the launchers contain. Until `multi-codex apply` (or any other write command) rewrites them, `list` shows `launcher stale` and `usage --live` refuses to run; the old launchers keep working.
461
+
443
462
  ## Exit codes
444
463
 
445
464
  | Code | Meaning |
@@ -118,6 +118,7 @@ Without a name, the account is named after the e-mail address in `~/.codex/auth.
118
118
  | Set up all accounts on a new machine | `curl -fsSL https://raw.githubusercontent.com/jakoes-wu/multi-codex/main/install.sh \| sh -s -- --config accounts.json` | [Declarative setup](#declarative-setup-with-apply) |
119
119
  | Get tab completion | `eval "$(multi-codex completion zsh)"` | [Shell completion](#shell-completion) |
120
120
  | See what a command would change | add `--dry-run` | [Commands](#commands) |
121
+ | Rename an account | `multi-codex rename work client-a` (the directory and login stay) | [Renaming accounts](#renaming-accounts) |
121
122
  | Remove an account | `multi-codex remove work` (the directory is kept) | [Commands](#commands) |
122
123
 
123
124
  More questions are answered in the [FAQ](#faq).
@@ -135,8 +136,9 @@ More questions are answered in the [FAQ](#faq).
135
136
  | ---- | ---- |
136
137
  | `multi-codex init [--root DIR] [--bin-dir DIR] [--shared-dir DIR] [--shared-items A,B]` | Create or change global settings. |
137
138
  | `multi-codex migrate-default [NAME] [--source DIR] [--copy] [--keep-backup] [--proxy P] [--skip-process-check] [--accept-relogin]` | Turn the default directory into an account. Without NAME, the e-mail address in its `auth.json` is used. |
138
- | `multi-codex add NAME [--proxy P] [--shared [DIR] \| --no-shared] [--adopt] [--config-from OTHER]` | Add an account, adopt an existing directory, or change its options. `--config-from` copies `config.toml` from another account once. |
139
- | `multi-codex set NAME [--proxy P] [--shared [DIR] \| --no-shared] [--adopt] [--config-from OTHER]` | Change an existing account; same options as `add`, but never creates one. |
139
+ | `multi-codex add NAME [--proxy P] [--shared [DIR] \| --no-shared] [--shared-exclude ITEM] [--shared-include ITEM] [--adopt] [--config-from OTHER]` | Add an account, adopt an existing directory, or change its options. `--config-from` copies `config.toml` from another account once. |
140
+ | `multi-codex set NAME [same options as add]` | Change an existing account; same options as `add`, but never creates one. |
141
+ | `multi-codex rename OLD NEW` | Rename an account and its launcher; the directory, login and links stay. |
140
142
  | `multi-codex login NAME [-- ARGS]` | Run `codex login` with an account's environment; arguments after `--` go to `codex login`. Does not need `~/.local/bin` on `PATH`. |
141
143
  | `multi-codex proxy NAME PORT\|URL\|off\|inherit` | Set an account's proxy. |
142
144
  | `multi-codex remove NAME` | Unregister an account and delete its launcher. **The account directory is kept.** |
@@ -154,7 +156,7 @@ More questions are answered in the [FAQ](#faq).
154
156
  | `multi-codex restore NAME [--skip-process-check] [--accept-relogin]` | Undo `migrate-default`: move the account back to `~/.codex`. |
155
157
  | `multi-codex completion bash\|zsh\|fish` | Print a shell completion script. |
156
158
 
157
- Every write command accepts `--dry-run`. `init`, `add`, `set`, `proxy`, `remove`, `apply`, `bind`, `unbind` and `env` print only the items they change (or `already up to date`); `-v` / `--verbose` also prints unchanged items. For `list`, `-v` means the full table instead. `list`, `usage` and `doctor` never change anything; with `--json` they print a single JSON object on stdout (with a `"version": 1` field) and keep warnings on stderr. Use `--json` in scripts: the table layout is not guaranteed to stay the same.
159
+ Every write command accepts `--dry-run`. `init`, `add`, `set`, `rename`, `proxy`, `remove`, `apply`, `bind`, `unbind` and `env` print only the items they change (or `already up to date`); `-v` / `--verbose` also prints unchanged items. For `list`, `-v` means the full table instead. `list`, `usage` and `doctor` never change anything; with `--json` they print a single JSON object on stdout (with a `"version": 1` field) and keep warnings on stderr. Use `--json` in scripts: the table layout is not guaranteed to stay the same.
158
160
 
159
161
  ### Login and usage
160
162
 
@@ -209,6 +211,14 @@ multi-codex unbind # remove the binding of the curre
209
211
 
210
212
  `multi-codex add new --config-from work` copies `config.toml` from `work` into `new` once; afterwards the two files are independent. An existing `config.toml` with different content is a conflict (nothing is written), and so is copying into an account that shares `config.toml`. The copy includes everything in the file, such as `cli_auth_credentials_store` or absolute paths that point into the other account.
211
213
 
214
+ ### Renaming accounts
215
+
216
+ ```sh
217
+ multi-codex rename work client-a
218
+ ```
219
+
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`.
221
+
212
222
  ### Per-account environment variables
213
223
 
214
224
  ```sh
@@ -218,6 +228,8 @@ multi-codex env work --unset TERM_PROGRAM
218
228
  multi-codex env work --clear
219
229
  ```
220
230
 
231
+ Launchers (and `run`, `login`, `code` and `usage --live`, which use the same environment) clear `CODEX_API_KEY`, `CODEX_ACCESS_TOKEN` and `CODEX_SQLITE_HOME` inherited from your shell, so one exported API key or database location does not leak into every account. An account that needs an API key sets it here, for example `multi-codex env work CODEX_API_KEY=sk-…`; per-account variables are applied after the clearing.
232
+
221
233
  The variables are stored in `config.json` (`accounts.<name>.env`) and written into the launcher. Values are used literally (no `$VAR` expansion). `CODEX_HOME` and the proxy variables are reserved: use `multi-codex proxy` for proxies. A launcher with environment variables is readable only by you (mode 0700), and `list --json` shows only the variable names; still, the values are stored in plain text, so do not put secrets there that need stronger protection. Older versions of multi-codex ignore the `env` field and drop it on their next write; run `multi-codex env NAME --clear` before downgrading.
222
234
 
223
235
  ### VS Code and the desktop app (experimental)
@@ -303,7 +315,7 @@ Progress is recorded in `~/.config/multi-codex/migrate-journal.json`. If the mig
303
315
 
304
316
  Sockets and FIFOs (runtime files such as `ipc.sock`) are not copied in copy mode. On macOS, copy mode does not preserve extended attributes.
305
317
 
306
- If `CODEX_HOME`, `CODEX_SQLITE_HOME`, `CODEX_API_KEY` or `CODEX_ACCESS_TOKEN` is set in your environment, multi-codex warns you: these variables override or bypass per-account isolation.
318
+ If `CODEX_HOME`, `CODEX_SQLITE_HOME`, `CODEX_API_KEY` or `CODEX_ACCESS_TOKEN` is set in your environment, multi-codex warns you: launchers set or clear them, but plain `codex` still uses them.
307
319
 
308
320
  ### Undoing a migration
309
321
 
@@ -337,6 +349,10 @@ multi-codex creates the missing links and remembers which links it created. Turn
337
349
 
338
350
  If you already linked an account to the shared directory by hand, `multi-codex set NAME --shared --adopt` takes those links over without recreating them: from then on, turning sharing off removes them as well. Only links that already point to the matching shared item are adopted.
339
351
 
352
+ One account can opt out of some shared items: `multi-codex set work --shared-exclude skills` removes the `skills` link multi-codex created in `work` (as if sharing were off for that item) and no longer creates it; `--shared-include skills` undoes that. Both options can be repeated, names match `shared.items` exactly, and `list` shows the exclusions as `yes (not: skills)`. To exclude `config.toml` and then copy another account's settings into it, run `--shared-exclude config.toml` and `--config-from` as two separate commands.
353
+
354
+ Items that hold one account's own state can never be shared: `shared.items` must not contain the entries marked "No" in the table below. A configuration that lists one fails to load and names the item; remove it from `config.json`.
355
+
340
356
  ### What can be shared
341
357
 
342
358
  Based on the Codex source code (openai/codex at `6b4daafd`):
@@ -351,7 +367,7 @@ Based on the Codex source code (openai/codex at `6b4daafd`):
351
367
  | `history.jsonl` | Prompt history | Yes | Reads and writes are file-locked; the histories of the accounts are merged. |
352
368
  | `auth.json`, `secrets/`, `.credentials.json`, `.env` | Credentials | **No** | They are the account. |
353
369
  | `installation_id` | Installation identifier | No | Sent with requests; sharing makes several accounts look like one installation. |
354
- | `*.sqlite` (`state_5.sqlite`, …) | Threads, logs, memories | **No** | `state_5.sqlite` records account IDs. |
370
+ | `*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm` (`state_5.sqlite`, …), `sqlite/` | Threads, logs, memories | **No** | `state_5.sqlite` records account IDs. |
355
371
  | `sessions/`, `archived_sessions/`, `session_index.jsonl` | Session logs | No | The index has only an in-process lock; sessions record the account that created them. |
356
372
  | `models_cache.json`, `cache/` | Caches | Not needed | Keyed by the account; a mismatch is a cache miss. |
357
373
  | `app-server-control/`, `app-server-daemon/`, `packages/`, `tmp/`, `.tmp/`, `log/`, `shell_snapshots/` | Runtime state | No | Per process or per session. |
@@ -414,8 +430,11 @@ Run the installer again; configuration, accounts and launchers are not touched:
414
430
  ```sh
415
431
  curl -fsSL https://raw.githubusercontent.com/jakoes-wu/multi-codex/main/install.sh | sh
416
432
  multi-codex --version
433
+ multi-codex apply # after upgrading from 0.8 or older: rewrites the launchers
417
434
  ```
418
435
 
436
+ 0.9 changed what the launchers contain. Until `multi-codex apply` (or any other write command) rewrites them, `list` shows `launcher stale` and `usage --live` refuses to run; the old launchers keep working.
437
+
419
438
  ## Exit codes
420
439
 
421
440
  | Code | Meaning |
@@ -1,3 +1,3 @@
1
1
  """multi-codex:管理多个 Codex CLI 账号目录、启动命令与代理设置。"""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.9.0"
@@ -32,7 +32,27 @@ NO_ORPHANS: FrozenSet[str] = frozenset()
32
32
 
33
33
 
34
34
  def account_dir(config: Config, name: str) -> str:
35
- return os.path.join(expand(config.root), name)
35
+ """账号目录。用账号记录的目录名(rename 后与账号名不同);未登记的名字按名字本身拼。"""
36
+ account = config.find(name)
37
+ return os.path.join(expand(config.root), account.dir_name if account is not None else name)
38
+
39
+
40
+ def dir_conflicts(config: Config) -> List[str]:
41
+ """两个账号会落到同一个目录时的说明文字(规则同 config._check_dir_names)。
42
+
43
+ parse_config 只检查读入的文件;这里检查命令算出的新配置,典型是 rename work job 之后 add work。
44
+ """
45
+ problems = []
46
+ for account in config.accounts.values():
47
+ for other in config.accounts.values():
48
+ if other is account:
49
+ continue
50
+ if account.dir_name.casefold() == other.dir_name.casefold() or \
51
+ account.name.casefold() == other.dir_name.casefold():
52
+ problems.append("account {!r} would use the directory of account {!r}".format(
53
+ account.name, other.name))
54
+ break
55
+ return problems
36
56
 
37
57
 
38
58
  def plan(old: Config, new: Config, *, config_exists: bool = True,
@@ -60,6 +80,9 @@ def plan(old: Config, new: Config, *, config_exists: bool = True,
60
80
  "cannot change root from {} to {} while accounts are registered".format(
61
81
  old_root, new_root)))
62
82
 
83
+ for problem in dir_conflicts(new):
84
+ actions.append(Action(CONFLICT, "config", config_path(), problem))
85
+
63
86
  planned_deletes: Set[str] = set()
64
87
  for account in new.accounts.values():
65
88
  old_account = old.find(account.name)
@@ -70,7 +93,7 @@ def plan(old: Config, new: Config, *, config_exists: bool = True,
70
93
  "account {!r} is registered as {!r}; renaming is not supported".format(
71
94
  account.name, old_account.name)))
72
95
  continue
73
- directory = os.path.join(new_root, account.name)
96
+ directory = os.path.join(new_root, account.dir_name)
74
97
  actions.extend(_plan_account_dir(directory, assumed))
75
98
  actions.extend(_plan_launcher(new_bin, account.name, directory, account.proxy, account.env))
76
99
  if old_account is not None and old_bin != new_bin:
@@ -79,11 +102,15 @@ def plan(old: Config, new: Config, *, config_exists: bool = True,
79
102
  actions.extend(shared.plan_shared(new, account, directory, old.shared_dir,
80
103
  adopt=account.name.casefold() in adopt_accounts))
81
104
 
105
+ new_dirs = {account.dir_name.casefold() for account in new.accounts.values()}
82
106
  for old_account in old.accounts.values():
83
107
  if new.find(old_account.name) is None:
84
108
  actions.extend(_plan_launcher_delete(old_bin, old_account.name, planned_deletes,
85
109
  "account removed"))
86
- actions.extend(shared.plan_remove_links(old_account, os.path.join(old_root, old_account.name),
110
+ # rename:旧名字从配置里消失,但目录由新名字接着用,共享链接必须留着。
111
+ if old_account.dir_name.casefold() in new_dirs:
112
+ continue
113
+ actions.extend(shared.plan_remove_links(old_account, os.path.join(old_root, old_account.dir_name),
87
114
  old.shared_dir))
88
115
 
89
116
  if orphan_scope:
@@ -121,7 +148,7 @@ def plan_config_copy(config: Config, account: Account, content: str, source_name
121
148
  已有不同内容时不覆盖:那可能是用户已经改过的配置。
122
149
  """
123
150
  target = os.path.join(account_dir(config, account.name), "config.toml")
124
- if account.shared and "config.toml" in config.shared_items:
151
+ if account.shared and "config.toml" in config.shared_items and "config.toml" not in account.shared_exclude:
125
152
  return [Action(CONFLICT, "config-file", target,
126
153
  "config.toml is shared for this account and does not need copying")]
127
154
  kind = entry_kind(target)
@@ -33,15 +33,18 @@ def _private_dir(path: str) -> str:
33
33
  return path
34
34
 
35
35
 
36
- def gui_data_dir(config: Config, name: str, kind: str) -> str:
37
- """返回并创建 <root>/.apps/<名>/<kind>(kind 为 vscode 或 desktop),各级目录都只允许本人访问。"""
36
+ def gui_data_dir(config: Config, dir_name: str, kind: str) -> str:
37
+ """返回并创建 <root>/.apps/<账号目录名>/<kind>(kind 为 vscode 或 desktop),各级目录都只允许本人访问。
38
+
39
+ 按目录名而不是账号名存放:rename 后 VS Code 与桌面端的登录状态、窗口与扩展数据都还在原处。
40
+ """
38
41
  apps_root = _private_dir(os.path.join(expand(config.root), ".apps"))
39
- account_root = _private_dir(os.path.join(apps_root, name))
42
+ account_root = _private_dir(os.path.join(apps_root, dir_name))
40
43
  return _private_dir(os.path.join(account_root, kind))
41
44
 
42
45
 
43
- def desktop_log(config: Config, name: str) -> str:
44
- path = os.path.join(expand(config.root), ".apps", name, "desktop.log")
46
+ def desktop_log(config: Config, dir_name: str) -> str:
47
+ path = os.path.join(expand(config.root), ".apps", dir_name, "desktop.log")
45
48
  fd = os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600)
46
49
  os.close(fd)
47
50
  os.chmod(path, 0o600)
@@ -16,8 +16,10 @@ from typing import List, Optional, Tuple
16
16
  from . import (__version__, accounts, apps, binding, completion, doctor, identity, launcher, migrate, platform,
17
17
  shellpath, switch, usage)
18
18
  from .actions import CREATE, DELETE, UNCHANGED, UPDATE, Action, error, hint, info, print_action, warn
19
- from .config import (DEFAULT_SHARED_DIR, DEFAULT_SHARED_ITEMS, Account, Config, ConfigError, is_socks, load_config,
20
- normalize_proxy, parse_config, validate_env_key, validate_env_value, validate_name)
19
+ from .config import (DEFAULT_SHARED_DIR, DEFAULT_SHARED_ITEMS, Account, Config, ConfigError, is_socks,
20
+ is_unshareable, load_config, normalize_proxy, parse_config, unique_items, validate_env_key,
21
+ validate_env_value, validate_name)
22
+ from .config import _valid_item as valid_shared_item # 与配置解析用同一条名称规则
21
23
  from .fsutil import KIND_DIR, KIND_MISSING, display_path, entry_kind, expand
22
24
  from .lock import LockBusyError, WriteLock
23
25
 
@@ -53,6 +55,7 @@ COMMAND_GROUPS = (
53
55
  ("Advanced", (
54
56
  ("env", "list or change an account's extra environment variables"),
55
57
  ("remove", "unregister an account (its directory is kept)"),
58
+ ("rename", "rename an account and its launcher (the directory stays)"),
56
59
  ("restore", "undo migrate-default: move an account back to ~/.codex"),
57
60
  ("apply", "converge all accounts to the configuration"),
58
61
  ("init", "create or update the global settings"),
@@ -128,6 +131,12 @@ def build_parser() -> argparse.ArgumentParser:
128
131
  _add_dry_run(p_proxy)
129
132
  _add_verbose(p_proxy)
130
133
 
134
+ p_rename = sub.add_parser("rename", description=COMMAND_SUMMARY["rename"])
135
+ p_rename.add_argument("old", metavar="OLD")
136
+ p_rename.add_argument("new", metavar="NEW")
137
+ _add_dry_run(p_rename)
138
+ _add_verbose(p_rename)
139
+
131
140
  p_remove = sub.add_parser("remove", description=COMMAND_SUMMARY["remove"])
132
141
  p_remove.add_argument("name")
133
142
  _add_dry_run(p_remove)
@@ -239,6 +248,11 @@ def _add_account_options(parser: argparse.ArgumentParser) -> None:
239
248
  parser.add_argument("--adopt", action="store_true",
240
249
  help="take over existing links that already point to the shared items, "
241
250
  "so that turning sharing off later removes them too")
251
+ # 按账号退出 / 恢复全局共享清单中的某些项;都可重复。退出只删本工具建的链接,与关闭共享一致。
252
+ parser.add_argument("--shared-exclude", action="append", metavar="ITEM",
253
+ help="do not link this shared item into this account (repeatable)")
254
+ parser.add_argument("--shared-include", action="append", metavar="ITEM",
255
+ help="undo --shared-exclude for this item (repeatable)")
242
256
  _add_dry_run(parser)
243
257
  _add_verbose(parser)
244
258
 
@@ -434,6 +448,10 @@ def dispatch(args: argparse.Namespace) -> int:
434
448
  items = [item.strip() for item in args.shared_items.split(",") if item.strip()]
435
449
  if any(item in (".", "..") or "/" in item for item in items):
436
450
  raise UsageError("--shared-items must be plain names without '/'")
451
+ for item in items:
452
+ if is_unshareable(item):
453
+ raise UsageError("--shared-items must not include {!r}: it holds account-specific "
454
+ "state".format(item))
437
455
  new.shared_items = items
438
456
  elif args.command in ("add", "set"):
439
457
  # add 对已登记的账号也是“修改选项”(保持兼容);set 只改已登记的账号,不会新建。
@@ -460,6 +478,9 @@ def dispatch(args: argparse.Namespace) -> int:
460
478
  account.shared = True
461
479
  elif args.no_shared:
462
480
  account.shared = False
481
+ # 放在 --config-from 之前,plan_config_copy 才能看到排除项。注意同一条命令里排除 config.toml 并复制
482
+ # 仍会判冲突:计划按当前文件判断,那时 config.toml 还是链到共享内容的软链;要分两条命令。
483
+ _apply_shared_exclude(account, args)
463
484
  if args.adopt:
464
485
  # 接管只对开启了共享的账号有意义;关闭状态下工具本来就不管这些软链。
465
486
  if not account.shared:
@@ -475,6 +496,11 @@ def dispatch(args: argparse.Namespace) -> int:
475
496
  error(new.not_registered(args.name))
476
497
  return accounts.EXIT_ERROR
477
498
  account.proxy = _checked_proxy(args.value)
499
+ elif args.command == "rename":
500
+ prepared = _rename_config(new, args)
501
+ if isinstance(prepared, int):
502
+ return prepared
503
+ new, orphan_scope = prepared
478
504
  elif args.command == "remove":
479
505
  name = _checked_name(args.name)
480
506
  account = new.find(name)
@@ -537,6 +563,10 @@ def dispatch(args: argparse.Namespace) -> int:
537
563
  info("binding not changed because of the errors above")
538
564
  if args.command in ("add", "set") and code == accounts.EXIT_OK and not args.dry_run:
539
565
  _hint_shared_dir(old, new, args)
566
+ _hint_shared_exclude(new, args)
567
+ if args.command == "rename" and code == accounts.EXIT_OK and not args.dry_run:
568
+ info("renamed {} to {}; directory {} is unchanged".format(
569
+ args.old, args.new, display_path(accounts.account_dir(new, args.new))))
540
570
  # set 不新建账号,不需要“下一步登录”的提示。
541
571
  if args.command == "add" and code == accounts.EXIT_OK and not args.dry_run:
542
572
  _hint_after_setup(new, args.name, check_login=True)
@@ -545,7 +575,68 @@ def dispatch(args: argparse.Namespace) -> int:
545
575
 
546
576
  def _any_account_option(args: argparse.Namespace) -> bool:
547
577
  return (args.proxy is not None or args.shared_to is not None or args.no_shared or args.adopt
548
- or bool(args.config_from))
578
+ or bool(args.config_from) or bool(args.shared_exclude) or bool(args.shared_include))
579
+
580
+
581
+ def _apply_shared_exclude(account: Account, args: argparse.Namespace) -> None:
582
+ """--shared-include 先撤销,--shared-exclude 再追加;同一名称同时出现在两边是用法错误。
583
+
584
+ 只改配置:链接的增删由收敛计划完成(shared.plan_shared 跳过被排除的项,原来的受管链接按关闭共享删除)。
585
+ """
586
+ exclude = args.shared_exclude or []
587
+ include = args.shared_include or []
588
+ for item in exclude + include:
589
+ if not valid_shared_item(item):
590
+ raise UsageError("shared item names must be plain names without '/', got {!r}".format(item))
591
+ both = sorted(set(exclude) & set(include))
592
+ if both:
593
+ raise UsageError("{} given to both --shared-exclude and --shared-include".format(", ".join(both)))
594
+ kept = [item for item in account.shared_exclude if item not in include]
595
+ account.shared_exclude = unique_items(kept + exclude)
596
+
597
+
598
+ def _hint_shared_exclude(config: Config, args: argparse.Namespace) -> None:
599
+ """退出项暂时不起作用的两种情况:名称不在共享清单里(精确匹配,与 plan_shared 相同),或账号没开共享。"""
600
+ exclude = args.shared_exclude or []
601
+ if not exclude:
602
+ return
603
+ account = config.find(args.name)
604
+ for item in exclude:
605
+ if item not in config.shared_items:
606
+ info("note: {} is not in shared.items; the exclusion takes effect only if it is added there".format(
607
+ item))
608
+ if account is not None and not account.shared:
609
+ info("note: sharing is off for {}; the exclusion applies once it is turned on".format(account.name))
610
+
611
+
612
+ def _rename_config(new: Config, args: argparse.Namespace):
613
+ """rename 的新配置:返回 (新配置, orphan_scope),或直接返回退出码。
614
+
615
+ 新账号是旧账号的完整副本(代理、共享、受管链接、环境变量、退出项、目录名),只改名字;
616
+ 目录名保持不变,所以目录、登录与共享链接都不动。旧启动命令由收敛计划按“账号移除”删除,
617
+ accounts.plan 会因目录仍被新名字使用而跳过删除共享链接。
618
+ """
619
+ old_name = _checked_name(args.old)
620
+ new_name = _checked_name(args.new)
621
+ account = new.find(old_name)
622
+ if account is None:
623
+ error(new.not_registered(old_name), phase="rename")
624
+ return accounts.EXIT_ERROR
625
+ if new_name.casefold() == account.name.casefold():
626
+ # 大小写不敏感的文件系统上 codex-Work 与 codex-work 是同一个文件,删旧建新会互相覆盖。
627
+ raise UsageError("only the letter case differs; renaming that way is not supported")
628
+ if new.find(new_name) is not None:
629
+ error("account {!r} already exists".format(new.find(new_name).name), phase="rename")
630
+ return accounts.EXIT_CONFLICT
631
+ renamed = account.copy()
632
+ renamed.name = new_name
633
+ # 按原顺序重建字典,config.json 里账号的顺序不变。
634
+ new.accounts = {(new_name if key == account.name else key): (renamed if key == account.name else value)
635
+ for key, value in new.accounts.items()}
636
+ for path, bound in list(new.bindings.items()):
637
+ if bound.casefold() == account.name.casefold():
638
+ new.bindings[path] = new_name
639
+ return new, frozenset([account.name.casefold()])
549
640
 
550
641
 
551
642
  def _not_registered_for_set(config: Config, name: str) -> str:
@@ -749,10 +840,19 @@ def _load_apply_file(path: str, old: Config) -> Config:
749
840
  except OSError as exc:
750
841
  raise ConfigError("cannot read {}: {}".format(path, exc))
751
842
  new = parse_config(raw, path)
843
+ # parse_config 会给省略的 dir 填默认值,要判断文件里是否显式写了,只能看原始 JSON。
844
+ raw_accounts = json.loads(raw).get("accounts", {})
752
845
  for account in new.accounts.values():
753
846
  _warn_if_socks(account.proxy, account.name)
754
847
  current = old.find(account.name)
755
848
  account.managed_links = list(current.managed_links) if current else []
849
+ if current is not None:
850
+ # 目录名是工具内部状态:改了已登记账号的目录名,就等于让它指向另一个目录。
851
+ written = raw_accounts.get(account.name, {})
852
+ if isinstance(written, dict) and "dir" in written and written["dir"] != current.dir_name:
853
+ warn("'dir' of {!r} in {} is ignored for a registered account; it keeps using {}".format(
854
+ account.name, path, display_path(accounts.account_dir(old, current.name))))
855
+ account.dir_name = current.dir_name
756
856
  # 绑定是本机状态,沿用当前值;文件中已删除的账号,它的绑定一并删除。
757
857
  new.bindings = dict(old.bindings)
758
858
  for name in set(new.bindings.values()):
@@ -829,6 +929,7 @@ def cmd_list(as_json: bool = False, verbose: bool = False) -> int:
829
929
  "dir_status": "ok" if is_dir else "missing-dir",
830
930
  "proxy": account.proxy,
831
931
  "shared": account.shared,
932
+ "shared_exclude": list(account.shared_exclude),
832
933
  "launcher": accounts.launcher_status(config, name),
833
934
  "credentials_store": found.store if found else None,
834
935
  # 只给键名:值里可能有密钥。
@@ -856,7 +957,7 @@ def cmd_list(as_json: bool = False, verbose: bool = False) -> int:
856
957
  rows = [("NAME", "DIR", "PROXY", "SHARED", "LAUNCHER", "LOGIN", "PLAN")]
857
958
  for name, account, directory, is_dir, found in entries:
858
959
  rows.append((name, "ok" if is_dir else "missing-dir", account.proxy,
859
- "yes" if account.shared else "no", accounts.launcher_status(config, name),
960
+ _shared_cell(account), accounts.launcher_status(config, name),
860
961
  identity.display_login(found) if found else "-",
861
962
  (found.plan if found and found.plan else "-")))
862
963
  widths = [max(len(row[index]) for row in rows) for index in range(len(rows[0]))]
@@ -899,7 +1000,7 @@ def _print_list_summary(config: Config, entries, duplicates: List[List[str]]) ->
899
1000
  problems.append("not logged in")
900
1001
  has_problem = has_problem or bool(problems)
901
1002
  rows.append((name, identity.display_login(found) if found else "-", account.proxy,
902
- "yes" if account.shared else "no", cell, ", ".join(problems) or "ok"))
1003
+ _shared_cell(account), cell, ", ".join(problems) or "ok"))
903
1004
  widths = [max(len(row[index]) for row in rows) for index in range(len(rows[0]))]
904
1005
  for row in rows:
905
1006
  print(" ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip())
@@ -911,6 +1012,15 @@ def _print_list_summary(config: Config, entries, duplicates: List[List[str]]) ->
911
1012
  return accounts.EXIT_OK
912
1013
 
913
1014
 
1015
+ def _shared_cell(account: Account) -> str:
1016
+ """SHARED 列:共享开启且有退出项时写出退出的项;没有退出项时与 0.8 相同(yes / no)。"""
1017
+ if not account.shared:
1018
+ return "no"
1019
+ if account.shared_exclude:
1020
+ return "yes (not: {})".format(", ".join(account.shared_exclude))
1021
+ return "yes"
1022
+
1023
+
914
1024
  def _usage_cell(result: usage.UsageResult, now: float) -> str:
915
1025
  """简表的 USAGE 单元格,如 `5h 23%, 7d 41%`;没有快照时为 `-`。
916
1026
 
@@ -1074,7 +1184,7 @@ def cmd_code(args: argparse.Namespace, extra: List[str]) -> int:
1074
1184
  error("VS Code's `code` command was not found{}; install it from VS Code (\"Shell Command: Install "
1075
1185
  "'code' command in PATH\") or pass --bin".format(" at " + args.bin if args.bin else ""))
1076
1186
  return accounts.EXIT_ERROR
1077
- data_dir = apps.gui_data_dir(config, account.name, "vscode")
1187
+ data_dir = apps.gui_data_dir(config, account.dir_name, "vscode")
1078
1188
  if sys.platform == "darwin":
1079
1189
  if len(data_dir) > apps.SOCKET_DIR_WARN_LENGTH:
1080
1190
  warn("{} is long; VS Code's socket path inside it may exceed the 104-byte limit".format(data_dir))
@@ -1107,8 +1217,8 @@ def cmd_app(args: argparse.Namespace) -> int:
1107
1217
  if problem:
1108
1218
  error(problem)
1109
1219
  return accounts.EXIT_ERROR
1110
- data_dir = apps.gui_data_dir(config, account.name, "desktop")
1111
- log = apps.desktop_log(config, account.name)
1220
+ data_dir = apps.gui_data_dir(config, account.dir_name, "desktop")
1221
+ log = apps.desktop_log(config, account.dir_name)
1112
1222
  argv = [apps.open_command(), "-n",
1113
1223
  "--env", "CODEX_HOME=" + accounts.account_dir(config, account.name),
1114
1224
  "--env", "CODEX_ELECTRON_USER_DATA_PATH=" + data_dir,
@@ -13,8 +13,8 @@ import argparse
13
13
  from typing import Dict, List, Tuple
14
14
 
15
15
  # 第一个位置参数是账号名的子命令;usage 的每个位置参数都是账号名。
16
- ACCOUNT_COMMANDS = ("add", "set", "login", "proxy", "remove", "migrate-default", "run", "path", "env", "usage", "use",
17
- "restore", "bind", "code", "app")
16
+ ACCOUNT_COMMANDS = ("add", "set", "rename", "login", "proxy", "remove", "migrate-default", "run", "path", "env",
17
+ "usage", "use", "restore", "bind", "code", "app")
18
18
  MULTI_ACCOUNT_COMMANDS = ("usage",)
19
19
  SHELLS = ("bash", "zsh", "fish")
20
20