codex-switch-cli 0.1.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.
- codex_switch_cli-0.1.0/PKG-INFO +89 -0
- codex_switch_cli-0.1.0/README.md +74 -0
- codex_switch_cli-0.1.0/pyproject.toml +31 -0
- codex_switch_cli-0.1.0/setup.cfg +4 -0
- codex_switch_cli-0.1.0/src/codex_switch/__init__.py +1 -0
- codex_switch_cli-0.1.0/src/codex_switch/auth.py +119 -0
- codex_switch_cli-0.1.0/src/codex_switch/cli.py +157 -0
- codex_switch_cli-0.1.0/src/codex_switch/config.py +39 -0
- codex_switch_cli-0.1.0/src/codex_switch/doctor.py +67 -0
- codex_switch_cli-0.1.0/src/codex_switch/install.py +29 -0
- codex_switch_cli-0.1.0/src/codex_switch/instances.py +42 -0
- codex_switch_cli-0.1.0/src/codex_switch/models.py +81 -0
- codex_switch_cli-0.1.0/src/codex_switch/paths.py +30 -0
- codex_switch_cli-0.1.0/src/codex_switch/probe.py +69 -0
- codex_switch_cli-0.1.0/src/codex_switch/routing.py +16 -0
- codex_switch_cli-0.1.0/src/codex_switch/runtime.py +46 -0
- codex_switch_cli-0.1.0/src/codex_switch/wizard.py +100 -0
- codex_switch_cli-0.1.0/src/codex_switch/wrapper.py +129 -0
- codex_switch_cli-0.1.0/src/codex_switch_cli.egg-info/PKG-INFO +89 -0
- codex_switch_cli-0.1.0/src/codex_switch_cli.egg-info/SOURCES.txt +33 -0
- codex_switch_cli-0.1.0/src/codex_switch_cli.egg-info/dependency_links.txt +1 -0
- codex_switch_cli-0.1.0/src/codex_switch_cli.egg-info/entry_points.txt +2 -0
- codex_switch_cli-0.1.0/src/codex_switch_cli.egg-info/requires.txt +6 -0
- codex_switch_cli-0.1.0/src/codex_switch_cli.egg-info/top_level.txt +1 -0
- codex_switch_cli-0.1.0/tests/test_auth.py +157 -0
- codex_switch_cli-0.1.0/tests/test_config.py +57 -0
- codex_switch_cli-0.1.0/tests/test_doctor.py +96 -0
- codex_switch_cli-0.1.0/tests/test_instances.py +40 -0
- codex_switch_cli-0.1.0/tests/test_integration_wrapper.py +47 -0
- codex_switch_cli-0.1.0/tests/test_probe.py +85 -0
- codex_switch_cli-0.1.0/tests/test_routing.py +38 -0
- codex_switch_cli-0.1.0/tests/test_runtime.py +67 -0
- codex_switch_cli-0.1.0/tests/test_smoke.py +15 -0
- codex_switch_cli-0.1.0/tests/test_wizard.py +136 -0
- codex_switch_cli-0.1.0/tests/test_wrapper.py +212 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-switch-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Transparent account-aware wrapper for the Codex CLI
|
|
5
|
+
Project-URL: Homepage, https://github.com/ForeverHYX/codex-switch
|
|
6
|
+
Project-URL: Repository, https://github.com/ForeverHYX/codex-switch
|
|
7
|
+
Project-URL: Issues, https://github.com/ForeverHYX/codex-switch/issues
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: typer<1.0,>=0.12
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: build<2.0,>=1.2; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
|
14
|
+
Requires-Dist: twine<7.0,>=6.0; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# codex-switch
|
|
17
|
+
|
|
18
|
+
`codex-switch` is a local wrapper for the Codex CLI. It keeps the normal
|
|
19
|
+
`codex` command, but chooses the logged-in account instance with the most
|
|
20
|
+
remaining quota before launching the real CLI.
|
|
21
|
+
|
|
22
|
+
## What it does
|
|
23
|
+
|
|
24
|
+
- installs a PATH-first `codex` shim
|
|
25
|
+
- stores one isolated runtime home per account
|
|
26
|
+
- checks account login state with `codex login status`
|
|
27
|
+
- probes each account's remaining quota with `/status` before launch
|
|
28
|
+
- forwards the original user command unchanged to the selected account
|
|
29
|
+
- keeps repository files, project instructions, and shared skills visible to
|
|
30
|
+
every instance
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
From PyPI after release:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
python3 -m pip install codex-switch-cli
|
|
38
|
+
codex-switch install-shim
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
From GitHub before the first PyPI release:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python3 -m pip install git+https://github.com/ForeverHYX/codex-switch.git
|
|
45
|
+
codex-switch install-shim
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
After that, just run `codex`. The first launch asks how many accounts to set up
|
|
49
|
+
and walks you through logging each one in with the upstream Codex CLI.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
codex
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Once setup is done, day-to-day usage stays the same:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
codex "review this branch"
|
|
59
|
+
codex exec "make test"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
The first `codex` run triggers setup. `codex-switch` creates one isolated
|
|
65
|
+
runtime home per account, runs the upstream login flow for each account in
|
|
66
|
+
turn, then stores the real Codex binary path for later launches.
|
|
67
|
+
|
|
68
|
+
When you later run `codex`, the shim probes each configured account, skips
|
|
69
|
+
unhealthy or unlogged ones, and picks the one with the most remaining quota.
|
|
70
|
+
|
|
71
|
+
## Caveats
|
|
72
|
+
|
|
73
|
+
- it does not switch accounts in the middle of a running Codex session
|
|
74
|
+
- it does not manage upstream Codex upgrades for you
|
|
75
|
+
- it depends on the upstream CLI's login/status output staying readable
|
|
76
|
+
- all accounts still share the same project working directory
|
|
77
|
+
|
|
78
|
+
## Public repository rules
|
|
79
|
+
|
|
80
|
+
- commit source files, tests, and public docs only
|
|
81
|
+
- keep local planning docs out of public pushes
|
|
82
|
+
- keep local-only agent and skill metadata out of public pushes
|
|
83
|
+
|
|
84
|
+
## Publishing
|
|
85
|
+
|
|
86
|
+
This repository is set up to publish the `codex-switch-cli` package to PyPI
|
|
87
|
+
through GitHub Actions trusted publishing. After PyPI trusted publishing is
|
|
88
|
+
configured for this repository, creating a GitHub release tag can build and
|
|
89
|
+
upload the package automatically.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# codex-switch
|
|
2
|
+
|
|
3
|
+
`codex-switch` is a local wrapper for the Codex CLI. It keeps the normal
|
|
4
|
+
`codex` command, but chooses the logged-in account instance with the most
|
|
5
|
+
remaining quota before launching the real CLI.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- installs a PATH-first `codex` shim
|
|
10
|
+
- stores one isolated runtime home per account
|
|
11
|
+
- checks account login state with `codex login status`
|
|
12
|
+
- probes each account's remaining quota with `/status` before launch
|
|
13
|
+
- forwards the original user command unchanged to the selected account
|
|
14
|
+
- keeps repository files, project instructions, and shared skills visible to
|
|
15
|
+
every instance
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
From PyPI after release:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python3 -m pip install codex-switch-cli
|
|
23
|
+
codex-switch install-shim
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
From GitHub before the first PyPI release:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python3 -m pip install git+https://github.com/ForeverHYX/codex-switch.git
|
|
30
|
+
codex-switch install-shim
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
After that, just run `codex`. The first launch asks how many accounts to set up
|
|
34
|
+
and walks you through logging each one in with the upstream Codex CLI.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
codex
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Once setup is done, day-to-day usage stays the same:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
codex "review this branch"
|
|
44
|
+
codex exec "make test"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How it works
|
|
48
|
+
|
|
49
|
+
The first `codex` run triggers setup. `codex-switch` creates one isolated
|
|
50
|
+
runtime home per account, runs the upstream login flow for each account in
|
|
51
|
+
turn, then stores the real Codex binary path for later launches.
|
|
52
|
+
|
|
53
|
+
When you later run `codex`, the shim probes each configured account, skips
|
|
54
|
+
unhealthy or unlogged ones, and picks the one with the most remaining quota.
|
|
55
|
+
|
|
56
|
+
## Caveats
|
|
57
|
+
|
|
58
|
+
- it does not switch accounts in the middle of a running Codex session
|
|
59
|
+
- it does not manage upstream Codex upgrades for you
|
|
60
|
+
- it depends on the upstream CLI's login/status output staying readable
|
|
61
|
+
- all accounts still share the same project working directory
|
|
62
|
+
|
|
63
|
+
## Public repository rules
|
|
64
|
+
|
|
65
|
+
- commit source files, tests, and public docs only
|
|
66
|
+
- keep local planning docs out of public pushes
|
|
67
|
+
- keep local-only agent and skill metadata out of public pushes
|
|
68
|
+
|
|
69
|
+
## Publishing
|
|
70
|
+
|
|
71
|
+
This repository is set up to publish the `codex-switch-cli` package to PyPI
|
|
72
|
+
through GitHub Actions trusted publishing. After PyPI trusted publishing is
|
|
73
|
+
configured for this repository, creating a GitHub release tag can build and
|
|
74
|
+
upload the package automatically.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "codex-switch-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Transparent account-aware wrapper for the Codex CLI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = ["typer>=0.12,<1.0"]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/ForeverHYX/codex-switch"
|
|
15
|
+
Repository = "https://github.com/ForeverHYX/codex-switch"
|
|
16
|
+
Issues = "https://github.com/ForeverHYX/codex-switch/issues"
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["build>=1.2,<2.0", "pytest>=8.0,<9.0", "twine>=6.0,<7.0"]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
codex-switch = "codex_switch.cli:app"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
package-dir = {"" = "src"}
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from codex_switch.models import InstanceConfig
|
|
9
|
+
from codex_switch.runtime import build_instance_env
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class LoginStatus:
|
|
14
|
+
logged_in: bool
|
|
15
|
+
output: str
|
|
16
|
+
returncode: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LoginBootstrapAbortedError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CodexCommandError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _run_codex(
|
|
28
|
+
real_codex_path: str | Path,
|
|
29
|
+
instance: InstanceConfig,
|
|
30
|
+
argv: list[str],
|
|
31
|
+
*,
|
|
32
|
+
capture_output: bool = False,
|
|
33
|
+
) -> subprocess.CompletedProcess[str]:
|
|
34
|
+
try:
|
|
35
|
+
return subprocess.run(
|
|
36
|
+
[str(real_codex_path), *argv],
|
|
37
|
+
env=build_instance_env(instance.name, Path(instance.home_dir)),
|
|
38
|
+
text=True,
|
|
39
|
+
capture_output=capture_output,
|
|
40
|
+
check=False,
|
|
41
|
+
)
|
|
42
|
+
except FileNotFoundError as exc:
|
|
43
|
+
raise CodexCommandError(f"Unable to launch the real Codex binary: {exc}") from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def login_status(real_codex_path: str | Path, instance: InstanceConfig) -> LoginStatus:
|
|
47
|
+
completed = _run_codex(real_codex_path, instance, ["login", "status"], capture_output=True)
|
|
48
|
+
output = "\n".join(
|
|
49
|
+
part for part in (completed.stdout, completed.stderr) if part
|
|
50
|
+
).strip()
|
|
51
|
+
return LoginStatus(
|
|
52
|
+
logged_in=completed.returncode == 0 and "Logged in" in output,
|
|
53
|
+
output=output,
|
|
54
|
+
returncode=completed.returncode,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def login(real_codex_path: str | Path, instance: InstanceConfig) -> subprocess.CompletedProcess[str]:
|
|
59
|
+
return _run_codex(real_codex_path, instance, ["login"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def logout(
|
|
63
|
+
real_codex_path: str | Path, instance: InstanceConfig
|
|
64
|
+
) -> subprocess.CompletedProcess[str]:
|
|
65
|
+
return _run_codex(real_codex_path, instance, ["logout"])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _prompt_failure_resolution(
|
|
69
|
+
instance_name: str,
|
|
70
|
+
*,
|
|
71
|
+
allow_skip: bool,
|
|
72
|
+
input_fn: Callable[[str], str],
|
|
73
|
+
) -> str:
|
|
74
|
+
choices = "retry, skip, or abort" if allow_skip else "retry or abort"
|
|
75
|
+
while True:
|
|
76
|
+
response = input_fn(
|
|
77
|
+
f"{instance_name} login failed. Type {choices}: "
|
|
78
|
+
).strip().lower()
|
|
79
|
+
if response in {"r", "retry"}:
|
|
80
|
+
return "retry"
|
|
81
|
+
if allow_skip and response in {"s", "skip"}:
|
|
82
|
+
return "skip"
|
|
83
|
+
if response in {"a", "abort"}:
|
|
84
|
+
return "abort"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ensure_instance_logged_in(
|
|
88
|
+
real_codex_path: str | Path,
|
|
89
|
+
instance: InstanceConfig,
|
|
90
|
+
*,
|
|
91
|
+
allow_skip: bool,
|
|
92
|
+
input_fn: Callable[[str], str],
|
|
93
|
+
output_fn: Callable[[str], None],
|
|
94
|
+
) -> bool:
|
|
95
|
+
current_status = login_status(real_codex_path, instance)
|
|
96
|
+
if current_status.logged_in:
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
while True:
|
|
100
|
+
login(real_codex_path, instance)
|
|
101
|
+
current_status = login_status(real_codex_path, instance)
|
|
102
|
+
if current_status.logged_in:
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
if current_status.output:
|
|
106
|
+
output_fn(current_status.output)
|
|
107
|
+
|
|
108
|
+
choice = _prompt_failure_resolution(
|
|
109
|
+
instance.name,
|
|
110
|
+
allow_skip=allow_skip,
|
|
111
|
+
input_fn=input_fn,
|
|
112
|
+
)
|
|
113
|
+
if choice == "retry":
|
|
114
|
+
continue
|
|
115
|
+
if choice == "skip":
|
|
116
|
+
return False
|
|
117
|
+
raise LoginBootstrapAbortedError(
|
|
118
|
+
f"Login aborted for instance {instance.name}"
|
|
119
|
+
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from codex_switch.auth import (
|
|
8
|
+
CodexCommandError,
|
|
9
|
+
LoginBootstrapAbortedError,
|
|
10
|
+
ensure_instance_logged_in,
|
|
11
|
+
login_status,
|
|
12
|
+
logout as run_logout,
|
|
13
|
+
)
|
|
14
|
+
from codex_switch.config import (
|
|
15
|
+
ConfigCorruptError,
|
|
16
|
+
ConfigNotInitializedError,
|
|
17
|
+
load_config,
|
|
18
|
+
save_config,
|
|
19
|
+
)
|
|
20
|
+
from codex_switch.doctor import create_doctor_report
|
|
21
|
+
from codex_switch.install import install_shim, uninstall_shim
|
|
22
|
+
from codex_switch.models import AppConfig
|
|
23
|
+
from codex_switch.paths import config_path, shim_dir
|
|
24
|
+
from codex_switch.runtime import resolve_real_codex
|
|
25
|
+
from codex_switch.wizard import initialize_app
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(no_args_is_help=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.callback()
|
|
31
|
+
def main() -> None:
|
|
32
|
+
"""Codex Switch CLI."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fail(message: str) -> None:
|
|
36
|
+
typer.secho(f"Error: {message}", err=True)
|
|
37
|
+
raise typer.Exit(code=1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_initialized_config() -> AppConfig:
|
|
41
|
+
try:
|
|
42
|
+
return load_config()
|
|
43
|
+
except ConfigNotInitializedError:
|
|
44
|
+
_fail("Codex Switch is not initialized. Run `codex-switch init` first.")
|
|
45
|
+
except ConfigCorruptError:
|
|
46
|
+
_fail(
|
|
47
|
+
f"Codex Switch config at {config_path()} is corrupt. "
|
|
48
|
+
"Remove it and run `codex-switch init` again."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _resolve_real_codex_for_management(config: AppConfig) -> str:
|
|
53
|
+
try:
|
|
54
|
+
resolved = resolve_real_codex(config.real_codex_path, shim_dir())
|
|
55
|
+
except FileNotFoundError as exc:
|
|
56
|
+
_fail(f"Unable to locate the real Codex binary: {exc}")
|
|
57
|
+
|
|
58
|
+
resolved_str = str(resolved)
|
|
59
|
+
if resolved_str != config.real_codex_path:
|
|
60
|
+
save_config(
|
|
61
|
+
AppConfig(
|
|
62
|
+
real_codex_path=resolved_str,
|
|
63
|
+
instances=config.instances,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
return resolved_str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_instance(config: AppConfig, instance_name: str):
|
|
70
|
+
instance = next((item for item in config.instances if item.name == instance_name), None)
|
|
71
|
+
if instance is None:
|
|
72
|
+
_fail(f"Instance {instance_name!r} is not present in the config")
|
|
73
|
+
return instance
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def init(
|
|
78
|
+
instance_count: int = typer.Option(..., min=1),
|
|
79
|
+
real_codex_path: Path = typer.Option(..., exists=True, dir_okay=False),
|
|
80
|
+
shared_home: Path = typer.Option(Path.home()),
|
|
81
|
+
) -> None:
|
|
82
|
+
try:
|
|
83
|
+
initialize_app(
|
|
84
|
+
real_codex_path=real_codex_path,
|
|
85
|
+
instance_count=instance_count,
|
|
86
|
+
shared_home=shared_home,
|
|
87
|
+
)
|
|
88
|
+
except (CodexCommandError, FileExistsError, LoginBootstrapAbortedError, ValueError) as exc:
|
|
89
|
+
_fail(str(exc))
|
|
90
|
+
typer.echo(f"Initialized {instance_count} account instances")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("list")
|
|
94
|
+
def list_instances() -> None:
|
|
95
|
+
config = _load_initialized_config()
|
|
96
|
+
for instance in config.instances:
|
|
97
|
+
typer.echo(f"{instance.name}\t{instance.home_dir}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def login(instance_name: str) -> None:
|
|
102
|
+
config = _load_initialized_config()
|
|
103
|
+
instance = _resolve_instance(config, instance_name)
|
|
104
|
+
real_codex_path = _resolve_real_codex_for_management(config)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
authenticated = ensure_instance_logged_in(
|
|
108
|
+
real_codex_path,
|
|
109
|
+
instance,
|
|
110
|
+
allow_skip=False,
|
|
111
|
+
input_fn=input,
|
|
112
|
+
output_fn=typer.echo,
|
|
113
|
+
)
|
|
114
|
+
except (CodexCommandError, LoginBootstrapAbortedError) as exc:
|
|
115
|
+
_fail(str(exc))
|
|
116
|
+
|
|
117
|
+
if not authenticated:
|
|
118
|
+
_fail(f"Instance {instance_name!r} did not complete login")
|
|
119
|
+
typer.echo(f"Logged in {instance_name}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def logout(instance_name: str) -> None:
|
|
124
|
+
config = _load_initialized_config()
|
|
125
|
+
instance = _resolve_instance(config, instance_name)
|
|
126
|
+
real_codex_path = _resolve_real_codex_for_management(config)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
run_logout(real_codex_path, instance)
|
|
130
|
+
status = login_status(real_codex_path, instance)
|
|
131
|
+
except CodexCommandError as exc:
|
|
132
|
+
_fail(str(exc))
|
|
133
|
+
if status.logged_in:
|
|
134
|
+
_fail(f"Instance {instance_name!r} is still logged in after logout")
|
|
135
|
+
typer.echo(f"Logged out {instance_name}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def doctor() -> None:
|
|
140
|
+
report = create_doctor_report()
|
|
141
|
+
typer.echo(report.summary())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command("install-shim")
|
|
145
|
+
def install_shim_command() -> None:
|
|
146
|
+
target = install_shim()
|
|
147
|
+
typer.echo(f"Installed codex shim at {target}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command()
|
|
151
|
+
def uninstall() -> None:
|
|
152
|
+
target = uninstall_shim()
|
|
153
|
+
typer.echo(f"Removed codex shim at {target}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
app()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from codex_switch.models import AppConfig
|
|
6
|
+
from codex_switch.paths import config_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigError(RuntimeError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigNotInitializedError(ConfigError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigCorruptError(ConfigError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_config() -> AppConfig:
|
|
22
|
+
path = config_path()
|
|
23
|
+
try:
|
|
24
|
+
payload = json.loads(path.read_text())
|
|
25
|
+
except FileNotFoundError as exc:
|
|
26
|
+
raise ConfigNotInitializedError(str(path)) from exc
|
|
27
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
28
|
+
raise ConfigCorruptError(str(path)) from exc
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
return AppConfig.from_dict(payload)
|
|
32
|
+
except (TypeError, ValueError, KeyError) as exc:
|
|
33
|
+
raise ConfigCorruptError(str(path)) from exc
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_config(config: AppConfig) -> None:
|
|
37
|
+
path = config_path()
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path.write_text(json.dumps(config.to_dict(), indent=2, sort_keys=True) + "\n")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from codex_switch.config import ConfigCorruptError, ConfigNotInitializedError, load_config
|
|
8
|
+
from codex_switch.paths import shim_dir
|
|
9
|
+
from codex_switch.probe import probe_instance
|
|
10
|
+
from codex_switch.runtime import resolve_real_codex
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class DoctorReport:
|
|
15
|
+
real_codex_found: bool
|
|
16
|
+
shim_precedes_path: bool
|
|
17
|
+
unhealthy_instances: list[str] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
def summary(self) -> str:
|
|
20
|
+
unhealthy = ",".join(self.unhealthy_instances) if self.unhealthy_instances else "none"
|
|
21
|
+
shim = "ok" if self.shim_precedes_path else "missing"
|
|
22
|
+
real = "ok" if self.real_codex_found else "missing"
|
|
23
|
+
return f"real-codex={real} shim={shim} unhealthy={unhealthy}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def shim_precedes_path(wrapper_dir: Path | None = None) -> bool:
|
|
27
|
+
expected = (wrapper_dir or shim_dir()).resolve()
|
|
28
|
+
for entry in os.environ.get("PATH", "").split(os.pathsep):
|
|
29
|
+
if not entry:
|
|
30
|
+
continue
|
|
31
|
+
return Path(entry).expanduser().resolve() == expected
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_doctor_report(wrapper_dir: Path | None = None) -> DoctorReport:
|
|
36
|
+
actual_wrapper_dir = wrapper_dir or shim_dir()
|
|
37
|
+
shim_ok = shim_precedes_path(actual_wrapper_dir)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
config = load_config()
|
|
41
|
+
except (ConfigNotInitializedError, ConfigCorruptError):
|
|
42
|
+
return DoctorReport(real_codex_found=False, shim_precedes_path=shim_ok)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
real_codex_path = resolve_real_codex(config.real_codex_path, actual_wrapper_dir)
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
unhealthy_instances = [instance.name for instance in config.instances if instance.enabled]
|
|
48
|
+
return DoctorReport(
|
|
49
|
+
real_codex_found=False,
|
|
50
|
+
shim_precedes_path=shim_ok,
|
|
51
|
+
unhealthy_instances=unhealthy_instances,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
unhealthy_instances = [
|
|
55
|
+
result.instance_name
|
|
56
|
+
for result in (
|
|
57
|
+
probe_instance(str(real_codex_path), instance)
|
|
58
|
+
for instance in config.instances
|
|
59
|
+
if instance.enabled
|
|
60
|
+
)
|
|
61
|
+
if not result.ok
|
|
62
|
+
]
|
|
63
|
+
return DoctorReport(
|
|
64
|
+
real_codex_found=True,
|
|
65
|
+
shim_precedes_path=shim_ok,
|
|
66
|
+
unhealthy_instances=unhealthy_instances,
|
|
67
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from codex_switch.paths import shim_dir
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def shim_path() -> Path:
|
|
11
|
+
return shim_dir() / "codex"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def install_shim() -> Path:
|
|
15
|
+
target = shim_path()
|
|
16
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
target.write_text(
|
|
18
|
+
"#!/bin/sh\n"
|
|
19
|
+
f'exec "{sys.executable}" -m codex_switch.wrapper "$@"\n'
|
|
20
|
+
)
|
|
21
|
+
os.chmod(target, 0o755)
|
|
22
|
+
return target
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def uninstall_shim() -> Path:
|
|
26
|
+
target = shim_path()
|
|
27
|
+
if target.exists():
|
|
28
|
+
target.unlink()
|
|
29
|
+
return target
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from codex_switch.models import InstanceConfig
|
|
7
|
+
from codex_switch.paths import instances_dir
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_shared_codex_paths(instance_home: Path, shared_home: Path) -> None:
|
|
11
|
+
for relative in (
|
|
12
|
+
Path(".codex") / "skills",
|
|
13
|
+
Path(".codex") / "superpowers",
|
|
14
|
+
):
|
|
15
|
+
source = shared_home / relative
|
|
16
|
+
if not source.exists():
|
|
17
|
+
continue
|
|
18
|
+
target = instance_home / relative
|
|
19
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
if target.is_symlink() or target.exists():
|
|
21
|
+
if target.is_dir() and not target.is_symlink():
|
|
22
|
+
shutil.rmtree(target)
|
|
23
|
+
else:
|
|
24
|
+
target.unlink()
|
|
25
|
+
target.symlink_to(source)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_instances(instance_count: int, shared_home: Path) -> list[InstanceConfig]:
|
|
29
|
+
instances: list[InstanceConfig] = []
|
|
30
|
+
for index in range(1, instance_count + 1):
|
|
31
|
+
name = f"acct-{index:03d}"
|
|
32
|
+
home_dir = instances_dir() / name / "home"
|
|
33
|
+
home_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
ensure_shared_codex_paths(home_dir, shared_home)
|
|
35
|
+
instances.append(
|
|
36
|
+
InstanceConfig(
|
|
37
|
+
name=name,
|
|
38
|
+
order=index,
|
|
39
|
+
home_dir=str(home_dir),
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
return instances
|