codex-switch-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
codex_switch/auth.py ADDED
@@ -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
+ )
codex_switch/cli.py ADDED
@@ -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()
codex_switch/config.py ADDED
@@ -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")
codex_switch/doctor.py ADDED
@@ -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
codex_switch/models.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class InstanceConfig:
8
+ name: str
9
+ order: int
10
+ home_dir: str
11
+ enabled: bool = True
12
+
13
+ def to_dict(self) -> dict[str, object]:
14
+ return asdict(self)
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class ProbeResult:
19
+ instance_name: str
20
+ order: int
21
+ quota_remaining: int | None
22
+ ok: bool
23
+ reason: str | None = None
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class AppConfig:
28
+ real_codex_path: str
29
+ instances: list[InstanceConfig] = field(default_factory=list)
30
+
31
+ def to_dict(self) -> dict[str, object]:
32
+ return {
33
+ "real_codex_path": self.real_codex_path,
34
+ "instances": [instance.to_dict() for instance in self.instances],
35
+ }
36
+
37
+ @classmethod
38
+ def from_dict(cls, payload: dict[str, object]) -> "AppConfig":
39
+ if not isinstance(payload, dict):
40
+ raise ValueError("config payload must be a mapping")
41
+
42
+ real_codex_path = payload.get("real_codex_path")
43
+ if not isinstance(real_codex_path, str) or not real_codex_path:
44
+ raise ValueError("real_codex_path must be a non-empty string")
45
+
46
+ raw_instances = payload.get("instances", [])
47
+ if not isinstance(raw_instances, list):
48
+ raise ValueError("instances must be a list")
49
+
50
+ instances = []
51
+ for item in raw_instances:
52
+ if not isinstance(item, dict):
53
+ raise ValueError("each instance must be a mapping")
54
+
55
+ name = item.get("name")
56
+ order = item.get("order")
57
+ home_dir = item.get("home_dir")
58
+ enabled = item.get("enabled", True)
59
+
60
+ if not isinstance(name, str) or not name:
61
+ raise ValueError("instance name must be a non-empty string")
62
+ if not isinstance(order, int) or isinstance(order, bool):
63
+ raise ValueError("instance order must be an integer")
64
+ if not isinstance(home_dir, str) or not home_dir:
65
+ raise ValueError("instance home_dir must be a non-empty string")
66
+ if not isinstance(enabled, bool):
67
+ raise ValueError("instance enabled must be a boolean")
68
+
69
+ instances.append(
70
+ InstanceConfig(
71
+ name=name,
72
+ order=order,
73
+ home_dir=home_dir,
74
+ enabled=enabled,
75
+ )
76
+ )
77
+
78
+ return cls(
79
+ real_codex_path=real_codex_path,
80
+ instances=instances,
81
+ )
codex_switch/paths.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ ENV_ROOT = "CODEX_SWITCH_HOME"
8
+
9
+
10
+ def state_root() -> Path:
11
+ override = os.environ.get(ENV_ROOT)
12
+ if override:
13
+ return Path(override).expanduser().resolve()
14
+ return Path.home() / ".codex-switch"
15
+
16
+
17
+ def config_path() -> Path:
18
+ return state_root() / "config.json"
19
+
20
+
21
+ def instances_dir() -> Path:
22
+ return state_root() / "instances"
23
+
24
+
25
+ def logs_dir() -> Path:
26
+ return state_root() / "logs"
27
+
28
+
29
+ def shim_dir() -> Path:
30
+ return state_root() / "bin"
codex_switch/probe.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from codex_switch.models import InstanceConfig, ProbeResult
8
+ from codex_switch.runtime import build_instance_env
9
+
10
+
11
+ QUOTA_PATTERNS = (
12
+ re.compile(r"remaining[^0-9]*(\d+)", re.IGNORECASE),
13
+ re.compile(r"(\d+)[^0-9]*remaining", re.IGNORECASE),
14
+ )
15
+
16
+
17
+ def parse_remaining_quota(output: str) -> int:
18
+ for pattern in QUOTA_PATTERNS:
19
+ match = pattern.search(output)
20
+ if match:
21
+ return int(match.group(1))
22
+ raise ValueError("Unable to parse remaining quota from /status output")
23
+
24
+
25
+ def _failure(instance: InstanceConfig, reason: str) -> ProbeResult:
26
+ return ProbeResult(
27
+ instance_name=instance.name,
28
+ order=instance.order,
29
+ quota_remaining=None,
30
+ ok=False,
31
+ reason=reason,
32
+ )
33
+
34
+
35
+ def probe_instance(real_codex_path: str, instance: InstanceConfig) -> ProbeResult:
36
+ env = build_instance_env(instance.name, Path(instance.home_dir))
37
+ try:
38
+ completed = subprocess.run(
39
+ [real_codex_path, "--no-alt-screen"],
40
+ input="/status\n/exit\n",
41
+ text=True,
42
+ capture_output=True,
43
+ env=env,
44
+ check=False,
45
+ timeout=15,
46
+ )
47
+ except FileNotFoundError as exc:
48
+ return _failure(instance, f"Probe could not launch the real Codex binary: {exc}")
49
+ except subprocess.TimeoutExpired:
50
+ return _failure(instance, "Probe timed out")
51
+
52
+ output = f"{completed.stdout}\n{completed.stderr}"
53
+ if completed.returncode != 0:
54
+ return _failure(
55
+ instance,
56
+ f"Probe exited with exit code {completed.returncode}",
57
+ )
58
+
59
+ try:
60
+ remaining = parse_remaining_quota(output)
61
+ except ValueError as exc:
62
+ return _failure(instance, str(exc))
63
+
64
+ return ProbeResult(
65
+ instance_name=instance.name,
66
+ order=instance.order,
67
+ quota_remaining=remaining,
68
+ ok=True,
69
+ )
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from codex_switch.models import ProbeResult
4
+
5
+
6
+ def select_best_instance(results: list[ProbeResult]) -> ProbeResult:
7
+ candidates = [
8
+ result for result in results if result.ok and result.quota_remaining is not None
9
+ ]
10
+ if not candidates:
11
+ raise RuntimeError("No usable Codex account instances are available")
12
+
13
+ return sorted(
14
+ candidates,
15
+ key=lambda item: (-int(item.quota_remaining), item.order),
16
+ )[0]
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def _is_executable_file(path: Path) -> bool:
8
+ return path.exists() and os.access(path, os.X_OK)
9
+
10
+
11
+ def find_real_codex(wrapper_dir: Path) -> Path:
12
+ path_entries = []
13
+ for entry in os.environ.get("PATH", "").split(os.pathsep):
14
+ if not entry:
15
+ continue
16
+ if Path(entry).resolve() == wrapper_dir.resolve():
17
+ continue
18
+ path_entries.append(entry)
19
+
20
+ for entry in path_entries:
21
+ candidate = Path(entry) / "codex"
22
+ if candidate.exists() and os.access(candidate, os.X_OK):
23
+ return candidate.resolve()
24
+
25
+ raise FileNotFoundError("Unable to locate the real codex binary outside the shim directory")
26
+
27
+
28
+ def resolve_real_codex(stored_path: str, wrapper_dir: Path) -> Path:
29
+ candidate = Path(stored_path).expanduser()
30
+ if _is_executable_file(candidate):
31
+ return candidate.resolve()
32
+ return find_real_codex(wrapper_dir)
33
+
34
+
35
+ def build_instance_env(
36
+ instance_name: str,
37
+ instance_home: Path,
38
+ parent_env: dict[str, str] | None = None,
39
+ ) -> dict[str, str]:
40
+ env = dict(os.environ if parent_env is None else parent_env)
41
+ env["HOME"] = str(instance_home)
42
+ env["XDG_CONFIG_HOME"] = str(instance_home / ".config")
43
+ env["XDG_CACHE_HOME"] = str(instance_home / ".cache")
44
+ env["XDG_STATE_HOME"] = str(instance_home / ".local" / "state")
45
+ env["CODEX_SWITCH_ACTIVE_INSTANCE"] = instance_name
46
+ return env
codex_switch/wizard.py ADDED
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Callable
5
+ from pathlib import Path
6
+
7
+ from codex_switch.auth import LoginBootstrapAbortedError, ensure_instance_logged_in
8
+ from codex_switch.config import save_config
9
+ from codex_switch.instances import create_instances
10
+ from codex_switch.models import AppConfig
11
+ from codex_switch.paths import config_path
12
+
13
+
14
+ def _validate_real_codex_path(real_codex_path: Path) -> None:
15
+ if not real_codex_path.is_file() or not os.access(real_codex_path, os.X_OK):
16
+ raise ValueError(f"{real_codex_path} must point to an executable file")
17
+
18
+
19
+ def prompt_instance_count(
20
+ *,
21
+ input_fn: Callable[[str], str] | None = None,
22
+ output_fn: Callable[[str], None],
23
+ ) -> int:
24
+ if input_fn is None:
25
+ input_fn = input
26
+ while True:
27
+ raw_value = input_fn("How many Codex account instances should be created? ").strip()
28
+ try:
29
+ instance_count = int(raw_value)
30
+ except ValueError:
31
+ output_fn("Please enter a whole number.")
32
+ continue
33
+ if instance_count < 1:
34
+ output_fn("Please enter at least 1 account instance.")
35
+ continue
36
+ return instance_count
37
+
38
+
39
+ def initialize_app(
40
+ real_codex_path: Path,
41
+ instance_count: int,
42
+ shared_home: Path,
43
+ *,
44
+ allow_skip: bool = True,
45
+ input_fn: Callable[[str], str] | None = None,
46
+ output_fn: Callable[[str], None] = print,
47
+ ) -> AppConfig:
48
+ if input_fn is None:
49
+ input_fn = input
50
+ path = config_path()
51
+ if path.exists():
52
+ raise FileExistsError(f"{path} already exists")
53
+
54
+ _validate_real_codex_path(real_codex_path)
55
+ if instance_count < 1:
56
+ raise ValueError("instance_count must be at least 1")
57
+
58
+ instances = create_instances(instance_count=instance_count, shared_home=shared_home)
59
+ for instance in instances:
60
+ authenticated = ensure_instance_logged_in(
61
+ real_codex_path,
62
+ instance,
63
+ allow_skip=allow_skip,
64
+ input_fn=input_fn,
65
+ output_fn=output_fn,
66
+ )
67
+ if not authenticated and not allow_skip:
68
+ raise LoginBootstrapAbortedError(
69
+ f"Login aborted for instance {instance.name}"
70
+ )
71
+
72
+ config = AppConfig(
73
+ real_codex_path=str(real_codex_path),
74
+ instances=instances,
75
+ )
76
+ save_config(config)
77
+ return config
78
+
79
+
80
+ def bootstrap_from_prompt(
81
+ real_codex_path: Path,
82
+ shared_home: Path,
83
+ *,
84
+ input_fn: Callable[[str], str] | None = None,
85
+ output_fn: Callable[[str], None] = print,
86
+ ) -> AppConfig:
87
+ if input_fn is None:
88
+ input_fn = input
89
+ instance_count = prompt_instance_count(
90
+ input_fn=input_fn,
91
+ output_fn=output_fn,
92
+ )
93
+ return initialize_app(
94
+ real_codex_path=real_codex_path,
95
+ instance_count=instance_count,
96
+ shared_home=shared_home,
97
+ allow_skip=True,
98
+ input_fn=input_fn,
99
+ output_fn=output_fn,
100
+ )
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from pathlib import Path
7
+
8
+ from codex_switch.auth import CodexCommandError, LoginBootstrapAbortedError
9
+ from codex_switch.config import (
10
+ ConfigCorruptError,
11
+ ConfigNotInitializedError,
12
+ load_config,
13
+ save_config,
14
+ )
15
+ from codex_switch.models import AppConfig
16
+ from codex_switch.models import ProbeResult
17
+ from codex_switch.probe import probe_instance
18
+ from codex_switch.routing import select_best_instance
19
+ from codex_switch.paths import shim_dir
20
+ from codex_switch.runtime import build_instance_env, find_real_codex, resolve_real_codex
21
+ from codex_switch.wizard import bootstrap_from_prompt
22
+
23
+ MANAGED_COMMANDS = {"login", "logout"}
24
+ REAL_CODEX_ARGV: list[str] | None = None
25
+
26
+
27
+ def probe_all_instances(config) -> list[ProbeResult]:
28
+ instances = [instance for instance in config.instances if instance.enabled]
29
+ with ThreadPoolExecutor(max_workers=max(1, len(instances))) as executor:
30
+ return list(
31
+ executor.map(
32
+ lambda instance: probe_instance(config.real_codex_path, instance),
33
+ instances,
34
+ )
35
+ )
36
+
37
+
38
+ def _fail(message: str) -> int:
39
+ print(f"Error: {message}", file=sys.stderr)
40
+ return 1
41
+
42
+
43
+ def _resolve_instance(config, selected_instance_name: str):
44
+ instance = next(
45
+ (item for item in config.instances if item.name == selected_instance_name),
46
+ None,
47
+ )
48
+ if instance is None:
49
+ raise LookupError(
50
+ f"Selected instance {selected_instance_name!r} is not present in the config"
51
+ )
52
+ return instance
53
+
54
+
55
+ def _refresh_real_codex_path(config: AppConfig, resolved_real_codex: Path) -> None:
56
+ resolved_real_codex_str = str(resolved_real_codex)
57
+ if resolved_real_codex_str == config.real_codex_path:
58
+ return
59
+ save_config(
60
+ AppConfig(
61
+ real_codex_path=resolved_real_codex_str,
62
+ instances=config.instances,
63
+ )
64
+ )
65
+
66
+
67
+ def main(argv: list[str] | None = None) -> int:
68
+ args = list(sys.argv[1:] if argv is None else argv)
69
+ if args and args[0] in MANAGED_COMMANDS:
70
+ return _fail("Use `codex-switch login` or `codex-switch logout` for account management")
71
+
72
+ try:
73
+ config = load_config()
74
+ except ConfigNotInitializedError:
75
+ try:
76
+ real_codex_path = find_real_codex(shim_dir())
77
+ bootstrap_from_prompt(real_codex_path=real_codex_path, shared_home=Path.home())
78
+ config = load_config()
79
+ except (CodexCommandError, LoginBootstrapAbortedError) as exc:
80
+ return _fail(str(exc))
81
+ except FileNotFoundError as exc:
82
+ return _fail(f"Unable to locate the real Codex binary: {exc}")
83
+ except ConfigNotInitializedError:
84
+ return _fail("Codex Switch is not initialized. Run `codex-switch init` first.")
85
+ except ConfigCorruptError:
86
+ return _fail("Codex Switch config is corrupt. Remove it and run `codex-switch init` again.")
87
+
88
+ try:
89
+ resolved_real_codex = resolve_real_codex(config.real_codex_path, shim_dir())
90
+ except FileNotFoundError as exc:
91
+ return _fail(f"Unable to locate the real Codex binary: {exc}")
92
+ _refresh_real_codex_path(config, resolved_real_codex)
93
+
94
+ probe_config = AppConfig(
95
+ real_codex_path=str(resolved_real_codex),
96
+ instances=config.instances,
97
+ )
98
+
99
+ try:
100
+ selected = select_best_instance(probe_all_instances(probe_config))
101
+ except FileNotFoundError as exc:
102
+ return _fail(f"Unable to locate the real Codex binary: {exc}")
103
+ except (RuntimeError, StopIteration) as exc:
104
+ return _fail(str(exc))
105
+
106
+ try:
107
+ instance = _resolve_instance(config, selected.instance_name)
108
+ except LookupError as exc:
109
+ return _fail(str(exc))
110
+
111
+ env = build_instance_env(
112
+ instance_name=instance.name,
113
+ instance_home=Path(instance.home_dir),
114
+ )
115
+
116
+ command = REAL_CODEX_ARGV or [str(resolved_real_codex)]
117
+ try:
118
+ completed = subprocess.run(
119
+ [*command, *args],
120
+ env=env,
121
+ check=False,
122
+ )
123
+ except FileNotFoundError as exc:
124
+ return _fail(f"Unable to launch the real Codex binary: {exc}")
125
+ return completed.returncode
126
+
127
+
128
+ if __name__ == "__main__":
129
+ raise SystemExit(main())
@@ -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,19 @@
1
+ codex_switch/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ codex_switch/auth.py,sha256=kjbFwQeSSRGqDq1jL_mk118MV5Y_pJ4nO9o6G4INPtA,3318
3
+ codex_switch/cli.py,sha256=G3jdcTzpsGXg6AzCWykGvIzDTX9WH81AdC2sc6hxbFQ,4449
4
+ codex_switch/config.py,sha256=IHDrZ73uY9F4AgPwwIzC_j6Y5ENmDN-LrriTn46eGkw,966
5
+ codex_switch/doctor.py,sha256=bAW0UXREXvuEZIIkZf9RIOYQ0qVwVraF6R38T2MvjBk,2295
6
+ codex_switch/install.py,sha256=ATCQc_3amlYeHujiqbVTwobFOEqmYA073Poritw2etU,576
7
+ codex_switch/instances.py,sha256=YmUU3B8CVgR4Vr6CoO1-Sbz94KvUojdCm0Wg7ft6qfA,1344
8
+ codex_switch/models.py,sha256=VM00LBiNQjLgJHIEGwEvWETEXk16qWzd2Gc6AxKzn2k,2539
9
+ codex_switch/paths.py,sha256=0Skqf2sgfrRj3IqVAdV5nId-3K5D78XMF2TiZU2YOVg,538
10
+ codex_switch/probe.py,sha256=bkQ_RXjINjM4OKtdVhZF7dkT0Tgl_rBUXScVvnrlVC8,1972
11
+ codex_switch/routing.py,sha256=MigACRYbUVTY0jftjX7k-T3GqrRXxfgz_fk3VtvS1Gg,482
12
+ codex_switch/runtime.py,sha256=oJOwwI53OJ2NvmaLFganeK_2bbayo8EzfuwkOjuunTg,1474
13
+ codex_switch/wizard.py,sha256=QbQ2Fj7YNB1yBWDPudb26VpbL42DLBMNmEv_0Dv7H7g,2958
14
+ codex_switch/wrapper.py,sha256=tOihDTV2S3UQx3mDPAJLB-cJgPYuVo-ezq4CVfL4kog,4321
15
+ codex_switch_cli-0.1.0.dist-info/METADATA,sha256=Jx0uI_sLYhlL7GxKu3p1cSMH81b7nhRwKIsnqh9RURE,2874
16
+ codex_switch_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
17
+ codex_switch_cli-0.1.0.dist-info/entry_points.txt,sha256=V2iL0Gdubr3dckiIypFYbPim6xb2v_ZWX9ZrzTAKaEY,54
18
+ codex_switch_cli-0.1.0.dist-info/top_level.txt,sha256=uDKtbENNcLFp7wVNex8fabvPco79t9d3UlymrQoLkN0,13
19
+ codex_switch_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codex-switch = codex_switch.cli:app
@@ -0,0 +1 @@
1
+ codex_switch