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.
Files changed (39) hide show
  1. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/PKG-INFO +21 -8
  2. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/README.md +20 -7
  3. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/pyproject.toml +1 -1
  4. codex_switch_cli-0.1.2/src/codex_switch/__init__.py +1 -0
  5. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/cli.py +54 -15
  6. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/doctor.py +8 -8
  7. codex_switch_cli-0.1.2/src/codex_switch/install.py +119 -0
  8. codex_switch_cli-0.1.2/src/codex_switch/probe.py +241 -0
  9. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/wizard.py +11 -1
  10. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/wrapper.py +4 -3
  11. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/PKG-INFO +21 -8
  12. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_doctor.py +24 -5
  13. codex_switch_cli-0.1.2/tests/test_probe.py +160 -0
  14. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_smoke.py +1 -1
  15. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_wizard.py +91 -0
  16. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_wrapper.py +3 -0
  17. codex_switch_cli-0.1.0/src/codex_switch/__init__.py +0 -1
  18. codex_switch_cli-0.1.0/src/codex_switch/install.py +0 -29
  19. codex_switch_cli-0.1.0/src/codex_switch/probe.py +0 -69
  20. codex_switch_cli-0.1.0/tests/test_probe.py +0 -85
  21. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/setup.cfg +0 -0
  22. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/auth.py +0 -0
  23. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/config.py +0 -0
  24. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/instances.py +0 -0
  25. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/models.py +0 -0
  26. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/paths.py +0 -0
  27. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/routing.py +0 -0
  28. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch/runtime.py +0 -0
  29. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/SOURCES.txt +0 -0
  30. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/dependency_links.txt +0 -0
  31. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/entry_points.txt +0 -0
  32. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/requires.txt +0 -0
  33. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/top_level.txt +0 -0
  34. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_auth.py +0 -0
  35. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_config.py +0 -0
  36. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_instances.py +0 -0
  37. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_integration_wrapper.py +0 -0
  38. {codex_switch_cli-0.1.0 → codex_switch_cli-0.1.2}/tests/test_routing.py +0 -0
  39. {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.0
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 install-shim
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 install-shim
53
+ codex-switch init
46
54
  ```
47
55
 
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.
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. `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.
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 install-shim
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 install-shim
38
+ codex-switch init
31
39
  ```
32
40
 
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.
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. `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.
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codex-switch-cli"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Transparent account-aware wrapper for the Codex CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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, shim_dir
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, shim_dir())
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(..., min=1),
79
- real_codex_path: Path = typer.Option(..., exists=True, dir_okay=False),
80
- shared_home: Path = typer.Option(Path.home()),
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
- initialize_app(
84
- real_codex_path=real_codex_path,
85
- instance_count=instance_count,
86
- shared_home=shared_home,
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 (CodexCommandError, FileExistsError, LoginBootstrapAbortedError, ValueError) as exc:
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 {instance_count} account instances")
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
- target = install_shim()
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.paths import shim_dir
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
- 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
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 shim_dir()
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(shim_dir())
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, shim_dir())
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)