codex-switch-cli 0.1.0__tar.gz → 0.1.2__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 → codex_switch_cli-0.1.2}/PKG-INFO +21 -8
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/README.md +20 -7
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/pyproject.toml +1 -1
- codex_switch_cli-0.1.2/src/codex_switch/__init__.py +1 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/cli.py +54 -15
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/doctor.py +8 -8
- codex_switch_cli-0.1.2/src/codex_switch/install.py +119 -0
- codex_switch_cli-0.1.2/src/codex_switch/probe.py +241 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/wizard.py +11 -1
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/wrapper.py +4 -3
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/PKG-INFO +21 -8
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_doctor.py +24 -5
- codex_switch_cli-0.1.2/tests/test_probe.py +160 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_smoke.py +1 -1
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_wizard.py +91 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_wrapper.py +3 -0
- codex_switch_cli-0.1.0/src/codex_switch/__init__.py +0 -1
- codex_switch_cli-0.1.0/src/codex_switch/install.py +0 -29
- codex_switch_cli-0.1.0/src/codex_switch/probe.py +0 -69
- codex_switch_cli-0.1.0/tests/test_probe.py +0 -85
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/setup.cfg +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/auth.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/config.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/instances.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/models.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/paths.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/routing.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/runtime.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/SOURCES.txt +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/dependency_links.txt +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/entry_points.txt +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/requires.txt +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/top_level.txt +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_auth.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_config.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_instances.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_integration_wrapper.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_routing.py +0 -0
- {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_runtime.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-switch-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Transparent account-aware wrapper for the Codex CLI
|
|
5
5
|
Project-URL: Homepage, https://github.com/ForeverHYX/codex-switch
|
|
6
6
|
Project-URL: Repository, https://github.com/ForeverHYX/codex-switch
|
|
@@ -35,20 +35,30 @@ From PyPI after release:
|
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
python3 -m pip install codex-switch-cli
|
|
38
|
-
codex-switch
|
|
38
|
+
codex-switch init
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
From Homebrew:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
brew tap ForeverHYX/tap
|
|
45
|
+
brew install codex-switch-cli
|
|
46
|
+
codex-switch init
|
|
39
47
|
```
|
|
40
48
|
|
|
41
49
|
From GitHub before the first PyPI release:
|
|
42
50
|
|
|
43
51
|
```bash
|
|
44
52
|
python3 -m pip install git+https://github.com/ForeverHYX/codex-switch.git
|
|
45
|
-
codex-switch
|
|
53
|
+
codex-switch init
|
|
46
54
|
```
|
|
47
55
|
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
`codex-switch init` auto-detects the real `codex` binary, asks how many
|
|
57
|
+
accounts to create, walks you through each upstream login flow, and installs the
|
|
58
|
+
shim so later `codex` commands go through `codex-switch`.
|
|
50
59
|
|
|
51
60
|
```bash
|
|
61
|
+
codex-switch init
|
|
52
62
|
codex
|
|
53
63
|
```
|
|
54
64
|
|
|
@@ -61,9 +71,12 @@ codex exec "make test"
|
|
|
61
71
|
|
|
62
72
|
## How it works
|
|
63
73
|
|
|
64
|
-
The first `codex` run triggers setup
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
The first `codex` run triggers setup if you have already installed the shim.
|
|
75
|
+
`codex-switch init` runs the same interactive flow explicitly and is the
|
|
76
|
+
recommended first step after installation. In both cases, `codex-switch`
|
|
77
|
+
creates one isolated runtime home per account, runs the upstream login flow for
|
|
78
|
+
each account in turn, then stores the real Codex binary path for later
|
|
79
|
+
launches.
|
|
67
80
|
|
|
68
81
|
When you later run `codex`, the shim probes each configured account, skips
|
|
69
82
|
unhealthy or unlogged ones, and picks the one with the most remaining quota.
|
|
@@ -20,20 +20,30 @@ From PyPI after release:
|
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
python3 -m pip install codex-switch-cli
|
|
23
|
-
codex-switch
|
|
23
|
+
codex-switch init
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
From Homebrew:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
brew tap ForeverHYX/tap
|
|
30
|
+
brew install codex-switch-cli
|
|
31
|
+
codex-switch init
|
|
24
32
|
```
|
|
25
33
|
|
|
26
34
|
From GitHub before the first PyPI release:
|
|
27
35
|
|
|
28
36
|
```bash
|
|
29
37
|
python3 -m pip install git+https://github.com/ForeverHYX/codex-switch.git
|
|
30
|
-
codex-switch
|
|
38
|
+
codex-switch init
|
|
31
39
|
```
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
`codex-switch init` auto-detects the real `codex` binary, asks how many
|
|
42
|
+
accounts to create, walks you through each upstream login flow, and installs the
|
|
43
|
+
shim so later `codex` commands go through `codex-switch`.
|
|
35
44
|
|
|
36
45
|
```bash
|
|
46
|
+
codex-switch init
|
|
37
47
|
codex
|
|
38
48
|
```
|
|
39
49
|
|
|
@@ -46,9 +56,12 @@ codex exec "make test"
|
|
|
46
56
|
|
|
47
57
|
## How it works
|
|
48
58
|
|
|
49
|
-
The first `codex` run triggers setup
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
The first `codex` run triggers setup if you have already installed the shim.
|
|
60
|
+
`codex-switch init` runs the same interactive flow explicitly and is the
|
|
61
|
+
recommended first step after installation. In both cases, `codex-switch`
|
|
62
|
+
creates one isolated runtime home per account, runs the upstream login flow for
|
|
63
|
+
each account in turn, then stores the real Codex binary path for later
|
|
64
|
+
launches.
|
|
52
65
|
|
|
53
66
|
When you later run `codex`, the shim probes each configured account, skips
|
|
54
67
|
unhealthy or unlogged ones, and picks the one with the most remaining quota.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
|
@@ -18,11 +18,11 @@ from codex_switch.config import (
|
|
|
18
18
|
save_config,
|
|
19
19
|
)
|
|
20
20
|
from codex_switch.doctor import create_doctor_report
|
|
21
|
-
from codex_switch.install import install_shim, uninstall_shim
|
|
21
|
+
from codex_switch.install import install_shim, runtime_wrapper_dir, uninstall_shim
|
|
22
22
|
from codex_switch.models import AppConfig
|
|
23
|
-
from codex_switch.paths import config_path
|
|
24
|
-
from codex_switch.runtime import resolve_real_codex
|
|
25
|
-
from codex_switch.wizard import initialize_app
|
|
23
|
+
from codex_switch.paths import config_path
|
|
24
|
+
from codex_switch.runtime import find_real_codex, resolve_real_codex
|
|
25
|
+
from codex_switch.wizard import bootstrap_from_prompt, clear_existing_state, initialize_app
|
|
26
26
|
|
|
27
27
|
app = typer.Typer(no_args_is_help=True)
|
|
28
28
|
|
|
@@ -51,7 +51,7 @@ def _load_initialized_config() -> AppConfig:
|
|
|
51
51
|
|
|
52
52
|
def _resolve_real_codex_for_management(config: AppConfig) -> str:
|
|
53
53
|
try:
|
|
54
|
-
resolved = resolve_real_codex(config.real_codex_path,
|
|
54
|
+
resolved = resolve_real_codex(config.real_codex_path, runtime_wrapper_dir())
|
|
55
55
|
except FileNotFoundError as exc:
|
|
56
56
|
_fail(f"Unable to locate the real Codex binary: {exc}")
|
|
57
57
|
|
|
@@ -75,19 +75,55 @@ def _resolve_instance(config: AppConfig, instance_name: str):
|
|
|
75
75
|
|
|
76
76
|
@app.command()
|
|
77
77
|
def init(
|
|
78
|
-
instance_count: int = typer.Option(
|
|
79
|
-
real_codex_path: Path = typer.Option(
|
|
80
|
-
shared_home: Path = typer.Option(
|
|
78
|
+
instance_count: int | None = typer.Option(None, min=1),
|
|
79
|
+
real_codex_path: Path | None = typer.Option(None, exists=True, dir_okay=False),
|
|
80
|
+
shared_home: Path | None = typer.Option(None),
|
|
81
|
+
force: bool = typer.Option(False, "--force", help="Overwrite any existing configuration."),
|
|
81
82
|
) -> None:
|
|
83
|
+
selected_shared_home = Path.home() if shared_home is None else shared_home
|
|
82
84
|
try:
|
|
83
|
-
|
|
84
|
-
real_codex_path
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
resolved_real_codex = (
|
|
86
|
+
real_codex_path
|
|
87
|
+
if real_codex_path is not None
|
|
88
|
+
else find_real_codex(runtime_wrapper_dir())
|
|
87
89
|
)
|
|
88
|
-
except
|
|
90
|
+
except FileNotFoundError as exc:
|
|
91
|
+
_fail(f"Unable to locate the real Codex binary: {exc}")
|
|
92
|
+
|
|
93
|
+
if config_path().exists():
|
|
94
|
+
should_rebuild = force or typer.confirm(
|
|
95
|
+
f"{config_path()} already exists. Overwrite it and rebuild all account instances?"
|
|
96
|
+
)
|
|
97
|
+
if not should_rebuild:
|
|
98
|
+
_fail("Initialization aborted.")
|
|
99
|
+
clear_existing_state()
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
if instance_count is None:
|
|
103
|
+
config = bootstrap_from_prompt(
|
|
104
|
+
real_codex_path=resolved_real_codex,
|
|
105
|
+
shared_home=selected_shared_home,
|
|
106
|
+
input_fn=input,
|
|
107
|
+
output_fn=typer.echo,
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
config = initialize_app(
|
|
111
|
+
real_codex_path=resolved_real_codex,
|
|
112
|
+
instance_count=instance_count,
|
|
113
|
+
shared_home=selected_shared_home,
|
|
114
|
+
input_fn=input,
|
|
115
|
+
output_fn=typer.echo,
|
|
116
|
+
)
|
|
117
|
+
shim_target = install_shim()
|
|
118
|
+
except (
|
|
119
|
+
CodexCommandError,
|
|
120
|
+
FileExistsError,
|
|
121
|
+
LoginBootstrapAbortedError,
|
|
122
|
+
ValueError,
|
|
123
|
+
) as exc:
|
|
89
124
|
_fail(str(exc))
|
|
90
|
-
typer.echo(f"Initialized {
|
|
125
|
+
typer.echo(f"Initialized {len(config.instances)} account instances")
|
|
126
|
+
typer.echo(f"Installed codex shim at {shim_target}")
|
|
91
127
|
|
|
92
128
|
|
|
93
129
|
@app.command("list")
|
|
@@ -143,7 +179,10 @@ def doctor() -> None:
|
|
|
143
179
|
|
|
144
180
|
@app.command("install-shim")
|
|
145
181
|
def install_shim_command() -> None:
|
|
146
|
-
|
|
182
|
+
try:
|
|
183
|
+
target = install_shim()
|
|
184
|
+
except FileExistsError as exc:
|
|
185
|
+
_fail(str(exc))
|
|
147
186
|
typer.echo(f"Installed codex shim at {target}")
|
|
148
187
|
|
|
149
188
|
|
|
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from codex_switch.config import ConfigCorruptError, ConfigNotInitializedError, load_config
|
|
8
|
-
from codex_switch.
|
|
8
|
+
from codex_switch.install import active_shim_path, runtime_wrapper_dir
|
|
9
9
|
from codex_switch.probe import probe_instance
|
|
10
10
|
from codex_switch.runtime import resolve_real_codex
|
|
11
11
|
|
|
@@ -24,16 +24,16 @@ class DoctorReport:
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def shim_precedes_path(wrapper_dir: Path | None = None) -> bool:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return
|
|
32
|
-
return
|
|
27
|
+
active = active_shim_path(os.environ.get("PATH", ""))
|
|
28
|
+
if active is None:
|
|
29
|
+
return False
|
|
30
|
+
if wrapper_dir is None:
|
|
31
|
+
return True
|
|
32
|
+
return active.parent.resolve() == wrapper_dir.resolve()
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def create_doctor_report(wrapper_dir: Path | None = None) -> DoctorReport:
|
|
36
|
-
actual_wrapper_dir = wrapper_dir or
|
|
36
|
+
actual_wrapper_dir = wrapper_dir or runtime_wrapper_dir()
|
|
37
37
|
shim_ok = shim_precedes_path(actual_wrapper_dir)
|
|
38
38
|
|
|
39
39
|
try:
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from codex_switch.paths import shim_dir
|
|
9
|
+
|
|
10
|
+
ENV_SHIM_DIR = "CODEX_SWITCH_SHIM_DIR"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _path_entries(path_env: str | None = None) -> list[Path]:
|
|
14
|
+
return [
|
|
15
|
+
Path(entry).expanduser().resolve()
|
|
16
|
+
for entry in (os.environ.get("PATH", "") if path_env is None else path_env).split(os.pathsep)
|
|
17
|
+
if entry
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_user_python_bin(path: Path, home_dir: Path) -> bool:
|
|
22
|
+
try:
|
|
23
|
+
relative = path.relative_to(home_dir)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return False
|
|
26
|
+
return (
|
|
27
|
+
len(relative.parts) == 4
|
|
28
|
+
and relative.parts[0] == "Library"
|
|
29
|
+
and relative.parts[1] == "Python"
|
|
30
|
+
and relative.parts[3] == "bin"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_preferred_install_dir(path: Path, home_dir: Path) -> bool:
|
|
35
|
+
return path in {
|
|
36
|
+
(home_dir / ".local" / "bin").resolve(),
|
|
37
|
+
(home_dir / "bin").resolve(),
|
|
38
|
+
} or _is_user_python_bin(path, home_dir)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def preferred_shim_dir(path_env: str | None = None) -> Path:
|
|
42
|
+
home_dir = Path.home().expanduser().resolve()
|
|
43
|
+
for entry in _path_entries(path_env):
|
|
44
|
+
if _is_preferred_install_dir(entry, home_dir):
|
|
45
|
+
return entry
|
|
46
|
+
return shim_dir()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def shim_path(install_dir: Path | None = None) -> Path:
|
|
50
|
+
return (preferred_shim_dir() if install_dir is None else install_dir) / "codex"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def legacy_shim_path() -> Path:
|
|
54
|
+
return shim_dir() / "codex"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_codex_switch_shim(path: Path) -> bool:
|
|
58
|
+
try:
|
|
59
|
+
return "codex_switch.wrapper" in path.read_text()
|
|
60
|
+
except OSError:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def active_shim_path(path_env: str | None = None) -> Path | None:
|
|
65
|
+
resolved = shutil.which("codex", path=path_env)
|
|
66
|
+
if resolved is None:
|
|
67
|
+
return None
|
|
68
|
+
candidate = Path(resolved).expanduser().resolve()
|
|
69
|
+
if not is_codex_switch_shim(candidate):
|
|
70
|
+
return None
|
|
71
|
+
return candidate
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def runtime_wrapper_dir() -> Path:
|
|
75
|
+
override = os.environ.get(ENV_SHIM_DIR)
|
|
76
|
+
if override:
|
|
77
|
+
return Path(override).expanduser().resolve()
|
|
78
|
+
active = active_shim_path()
|
|
79
|
+
if active is not None:
|
|
80
|
+
return active.parent
|
|
81
|
+
return preferred_shim_dir()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _write_shim(target: Path) -> None:
|
|
85
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
target.write_text(
|
|
87
|
+
"#!/bin/sh\n"
|
|
88
|
+
f'CODEX_SWITCH_SHIM_DIR="{target.parent}" '
|
|
89
|
+
f'exec "{sys.executable}" -m codex_switch.wrapper "$@"\n'
|
|
90
|
+
)
|
|
91
|
+
os.chmod(target, 0o755)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def install_shim() -> Path:
|
|
95
|
+
target = shim_path()
|
|
96
|
+
if target.exists() and not is_codex_switch_shim(target):
|
|
97
|
+
raise FileExistsError(f"{target} already exists and is not a codex-switch shim")
|
|
98
|
+
_write_shim(target)
|
|
99
|
+
|
|
100
|
+
legacy = legacy_shim_path()
|
|
101
|
+
if legacy != target and legacy.exists() and is_codex_switch_shim(legacy):
|
|
102
|
+
legacy.unlink()
|
|
103
|
+
return target
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def uninstall_shim() -> Path:
|
|
107
|
+
target = shim_path()
|
|
108
|
+
candidates = [target, legacy_shim_path(), active_shim_path()]
|
|
109
|
+
seen: set[Path] = set()
|
|
110
|
+
for candidate in candidates:
|
|
111
|
+
if candidate is None:
|
|
112
|
+
continue
|
|
113
|
+
resolved = candidate.resolve()
|
|
114
|
+
if resolved in seen:
|
|
115
|
+
continue
|
|
116
|
+
seen.add(resolved)
|
|
117
|
+
if candidate.exists() and is_codex_switch_shim(candidate):
|
|
118
|
+
candidate.unlink()
|
|
119
|
+
return target
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pty
|
|
5
|
+
import re
|
|
6
|
+
import select
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from codex_switch.auth import CodexCommandError, login_status
|
|
12
|
+
from codex_switch.models import InstanceConfig, ProbeResult
|
|
13
|
+
from codex_switch.runtime import build_instance_env
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
QUOTA_PATTERNS = (
|
|
17
|
+
re.compile(r"remaining[^0-9]*(\d+)", re.IGNORECASE),
|
|
18
|
+
re.compile(r"(\d+)[^0-9]*remaining", re.IGNORECASE),
|
|
19
|
+
)
|
|
20
|
+
READY_PATTERNS = (
|
|
21
|
+
"OpenAI Codex",
|
|
22
|
+
"tab to queue message",
|
|
23
|
+
"context left",
|
|
24
|
+
"Codex ready",
|
|
25
|
+
"BOOTED",
|
|
26
|
+
)
|
|
27
|
+
ANSI_ESCAPE_RE = re.compile(
|
|
28
|
+
r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b[@-_]"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_remaining_quota(output: str) -> int:
|
|
33
|
+
for pattern in QUOTA_PATTERNS:
|
|
34
|
+
match = pattern.search(output)
|
|
35
|
+
if match:
|
|
36
|
+
return int(match.group(1))
|
|
37
|
+
raise ValueError("Unable to parse remaining quota from /status output")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _failure(instance: InstanceConfig, reason: str) -> ProbeResult:
|
|
41
|
+
return ProbeResult(
|
|
42
|
+
instance_name=instance.name,
|
|
43
|
+
order=instance.order,
|
|
44
|
+
quota_remaining=None,
|
|
45
|
+
ok=False,
|
|
46
|
+
reason=reason,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sanitize_terminal_output(raw_output: bytes) -> str:
|
|
51
|
+
text = raw_output.decode("utf-8", errors="ignore").replace("\r", "\n")
|
|
52
|
+
text = ANSI_ESCAPE_RE.sub("", text)
|
|
53
|
+
text = text.replace("\b", "")
|
|
54
|
+
return text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _trusted_project_override(path: Path) -> str:
|
|
58
|
+
escaped = str(path).replace("\\", "\\\\").replace('"', '\\"')
|
|
59
|
+
return f'projects."{escaped}".trust_level="trusted"'
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _looks_ready(output: str) -> bool:
|
|
63
|
+
return any(pattern in output for pattern in READY_PATTERNS)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _fallback_logged_in_result(
|
|
67
|
+
real_codex_path: str,
|
|
68
|
+
instance: InstanceConfig,
|
|
69
|
+
reason: str,
|
|
70
|
+
) -> ProbeResult | None:
|
|
71
|
+
try:
|
|
72
|
+
status = login_status(real_codex_path, instance)
|
|
73
|
+
except CodexCommandError:
|
|
74
|
+
return None
|
|
75
|
+
if not status.logged_in:
|
|
76
|
+
return None
|
|
77
|
+
return ProbeResult(
|
|
78
|
+
instance_name=instance.name,
|
|
79
|
+
order=instance.order,
|
|
80
|
+
quota_remaining=0,
|
|
81
|
+
ok=True,
|
|
82
|
+
reason=f"{reason}. Falling back to login-only availability.",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _run_status_probe(
|
|
87
|
+
real_codex_path: str,
|
|
88
|
+
instance: InstanceConfig,
|
|
89
|
+
*,
|
|
90
|
+
timeout: int = 6,
|
|
91
|
+
) -> tuple[int, str]:
|
|
92
|
+
instance_home = Path(instance.home_dir)
|
|
93
|
+
instance_home.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
env = build_instance_env(instance.name, instance_home)
|
|
95
|
+
command = [
|
|
96
|
+
real_codex_path,
|
|
97
|
+
"-C",
|
|
98
|
+
str(instance_home),
|
|
99
|
+
"-c",
|
|
100
|
+
_trusted_project_override(instance_home),
|
|
101
|
+
"--no-alt-screen",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
master_fd, slave_fd = pty.openpty()
|
|
105
|
+
process: subprocess.Popen[bytes] | None = None
|
|
106
|
+
sent_status = False
|
|
107
|
+
sent_exit = False
|
|
108
|
+
sent_trust = False
|
|
109
|
+
status_sent_at: float | None = None
|
|
110
|
+
exit_sent_at: float | None = None
|
|
111
|
+
output = bytearray()
|
|
112
|
+
deadline = time.monotonic() + timeout
|
|
113
|
+
startup_deadline = time.monotonic() + min(3.0, timeout / 2)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
process = subprocess.Popen(
|
|
117
|
+
command,
|
|
118
|
+
stdin=slave_fd,
|
|
119
|
+
stdout=slave_fd,
|
|
120
|
+
stderr=slave_fd,
|
|
121
|
+
env=env,
|
|
122
|
+
cwd=instance_home,
|
|
123
|
+
close_fds=True,
|
|
124
|
+
)
|
|
125
|
+
finally:
|
|
126
|
+
os.close(slave_fd)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
while True:
|
|
130
|
+
now = time.monotonic()
|
|
131
|
+
if now >= deadline:
|
|
132
|
+
raise subprocess.TimeoutExpired(cmd=command, timeout=timeout)
|
|
133
|
+
|
|
134
|
+
wait_for = min(0.2, deadline - now)
|
|
135
|
+
ready, _, _ = select.select([master_fd], [], [], wait_for)
|
|
136
|
+
if ready:
|
|
137
|
+
try:
|
|
138
|
+
chunk = os.read(master_fd, 4096)
|
|
139
|
+
except OSError:
|
|
140
|
+
chunk = b""
|
|
141
|
+
if chunk:
|
|
142
|
+
output.extend(chunk)
|
|
143
|
+
|
|
144
|
+
cleaned_output = _sanitize_terminal_output(output)
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
not sent_trust
|
|
148
|
+
and "Do you trust the contents of this directory?" in cleaned_output
|
|
149
|
+
):
|
|
150
|
+
os.write(master_fd, b"1\n")
|
|
151
|
+
sent_trust = True
|
|
152
|
+
|
|
153
|
+
if not sent_status and (
|
|
154
|
+
_looks_ready(cleaned_output) or time.monotonic() >= startup_deadline
|
|
155
|
+
):
|
|
156
|
+
os.write(master_fd, b"/status\n")
|
|
157
|
+
sent_status = True
|
|
158
|
+
status_sent_at = time.monotonic()
|
|
159
|
+
|
|
160
|
+
if sent_status and not sent_exit:
|
|
161
|
+
try:
|
|
162
|
+
parse_remaining_quota(cleaned_output)
|
|
163
|
+
except ValueError:
|
|
164
|
+
if status_sent_at is not None and time.monotonic() - status_sent_at > 2:
|
|
165
|
+
os.write(master_fd, b"/exit\n")
|
|
166
|
+
sent_exit = True
|
|
167
|
+
exit_sent_at = time.monotonic()
|
|
168
|
+
else:
|
|
169
|
+
os.write(master_fd, b"/exit\n")
|
|
170
|
+
sent_exit = True
|
|
171
|
+
exit_sent_at = time.monotonic()
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
sent_exit
|
|
175
|
+
and exit_sent_at is not None
|
|
176
|
+
and time.monotonic() - exit_sent_at > 1
|
|
177
|
+
and process.poll() is None
|
|
178
|
+
):
|
|
179
|
+
process.terminate()
|
|
180
|
+
|
|
181
|
+
poll_result = process.poll()
|
|
182
|
+
if poll_result is not None:
|
|
183
|
+
while True:
|
|
184
|
+
ready, _, _ = select.select([master_fd], [], [], 0)
|
|
185
|
+
if not ready:
|
|
186
|
+
break
|
|
187
|
+
try:
|
|
188
|
+
chunk = os.read(master_fd, 4096)
|
|
189
|
+
except OSError:
|
|
190
|
+
break
|
|
191
|
+
if not chunk:
|
|
192
|
+
break
|
|
193
|
+
output.extend(chunk)
|
|
194
|
+
return poll_result, _sanitize_terminal_output(output)
|
|
195
|
+
except subprocess.TimeoutExpired:
|
|
196
|
+
process.kill()
|
|
197
|
+
process.wait(timeout=1)
|
|
198
|
+
raise
|
|
199
|
+
finally:
|
|
200
|
+
os.close(master_fd)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def probe_instance(real_codex_path: str, instance: InstanceConfig) -> ProbeResult:
|
|
204
|
+
try:
|
|
205
|
+
returncode, output = _run_status_probe(real_codex_path, instance)
|
|
206
|
+
except FileNotFoundError as exc:
|
|
207
|
+
return _failure(instance, f"Probe could not launch the real Codex binary: {exc}")
|
|
208
|
+
except subprocess.TimeoutExpired:
|
|
209
|
+
fallback = _fallback_logged_in_result(
|
|
210
|
+
real_codex_path,
|
|
211
|
+
instance,
|
|
212
|
+
"Probe timed out",
|
|
213
|
+
)
|
|
214
|
+
if fallback is not None:
|
|
215
|
+
return fallback
|
|
216
|
+
return _failure(instance, "Probe timed out")
|
|
217
|
+
|
|
218
|
+
if returncode != 0:
|
|
219
|
+
fallback = _fallback_logged_in_result(
|
|
220
|
+
real_codex_path,
|
|
221
|
+
instance,
|
|
222
|
+
f"Probe exited with exit code {returncode}",
|
|
223
|
+
)
|
|
224
|
+
if fallback is not None:
|
|
225
|
+
return fallback
|
|
226
|
+
return _failure(instance, f"Probe exited with exit code {returncode}")
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
remaining = parse_remaining_quota(output)
|
|
230
|
+
except ValueError as exc:
|
|
231
|
+
fallback = _fallback_logged_in_result(real_codex_path, instance, str(exc))
|
|
232
|
+
if fallback is not None:
|
|
233
|
+
return fallback
|
|
234
|
+
return _failure(instance, str(exc))
|
|
235
|
+
|
|
236
|
+
return ProbeResult(
|
|
237
|
+
instance_name=instance.name,
|
|
238
|
+
order=instance.order,
|
|
239
|
+
quota_remaining=remaining,
|
|
240
|
+
ok=True,
|
|
241
|
+
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import shutil
|
|
4
5
|
from typing import Callable
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
@@ -8,7 +9,7 @@ from codex_switch.auth import LoginBootstrapAbortedError, ensure_instance_logged
|
|
|
8
9
|
from codex_switch.config import save_config
|
|
9
10
|
from codex_switch.instances import create_instances
|
|
10
11
|
from codex_switch.models import AppConfig
|
|
11
|
-
from codex_switch.paths import config_path
|
|
12
|
+
from codex_switch.paths import config_path, instances_dir
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def _validate_real_codex_path(real_codex_path: Path) -> None:
|
|
@@ -77,6 +78,15 @@ def initialize_app(
|
|
|
77
78
|
return config
|
|
78
79
|
|
|
79
80
|
|
|
81
|
+
def clear_existing_state() -> None:
|
|
82
|
+
path = config_path()
|
|
83
|
+
if path.exists():
|
|
84
|
+
path.unlink()
|
|
85
|
+
target_instances_dir = instances_dir()
|
|
86
|
+
if target_instances_dir.exists():
|
|
87
|
+
shutil.rmtree(target_instances_dir)
|
|
88
|
+
|
|
89
|
+
|
|
80
90
|
def bootstrap_from_prompt(
|
|
81
91
|
real_codex_path: Path,
|
|
82
92
|
shared_home: Path,
|
|
@@ -12,11 +12,12 @@ from codex_switch.config import (
|
|
|
12
12
|
load_config,
|
|
13
13
|
save_config,
|
|
14
14
|
)
|
|
15
|
+
from codex_switch.install import runtime_wrapper_dir
|
|
15
16
|
from codex_switch.models import AppConfig
|
|
16
17
|
from codex_switch.models import ProbeResult
|
|
18
|
+
from codex_switch.paths import shim_dir
|
|
17
19
|
from codex_switch.probe import probe_instance
|
|
18
20
|
from codex_switch.routing import select_best_instance
|
|
19
|
-
from codex_switch.paths import shim_dir
|
|
20
21
|
from codex_switch.runtime import build_instance_env, find_real_codex, resolve_real_codex
|
|
21
22
|
from codex_switch.wizard import bootstrap_from_prompt
|
|
22
23
|
|
|
@@ -73,7 +74,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
73
74
|
config = load_config()
|
|
74
75
|
except ConfigNotInitializedError:
|
|
75
76
|
try:
|
|
76
|
-
real_codex_path = find_real_codex(
|
|
77
|
+
real_codex_path = find_real_codex(runtime_wrapper_dir())
|
|
77
78
|
bootstrap_from_prompt(real_codex_path=real_codex_path, shared_home=Path.home())
|
|
78
79
|
config = load_config()
|
|
79
80
|
except (CodexCommandError, LoginBootstrapAbortedError) as exc:
|
|
@@ -86,7 +87,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
86
87
|
return _fail("Codex Switch config is corrupt. Remove it and run `codex-switch init` again.")
|
|
87
88
|
|
|
88
89
|
try:
|
|
89
|
-
resolved_real_codex = resolve_real_codex(config.real_codex_path,
|
|
90
|
+
resolved_real_codex = resolve_real_codex(config.real_codex_path, runtime_wrapper_dir())
|
|
90
91
|
except FileNotFoundError as exc:
|
|
91
92
|
return _fail(f"Unable to locate the real Codex binary: {exc}")
|
|
92
93
|
_refresh_real_codex_path(config, resolved_real_codex)
|