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.
- codex_switch/__init__.py +1 -0
- codex_switch/auth.py +119 -0
- codex_switch/cli.py +157 -0
- codex_switch/config.py +39 -0
- codex_switch/doctor.py +67 -0
- codex_switch/install.py +29 -0
- codex_switch/instances.py +42 -0
- codex_switch/models.py +81 -0
- codex_switch/paths.py +30 -0
- codex_switch/probe.py +69 -0
- codex_switch/routing.py +16 -0
- codex_switch/runtime.py +46 -0
- codex_switch/wizard.py +100 -0
- codex_switch/wrapper.py +129 -0
- codex_switch_cli-0.1.0.dist-info/METADATA +89 -0
- codex_switch_cli-0.1.0.dist-info/RECORD +19 -0
- codex_switch_cli-0.1.0.dist-info/WHEEL +5 -0
- codex_switch_cli-0.1.0.dist-info/entry_points.txt +2 -0
- codex_switch_cli-0.1.0.dist-info/top_level.txt +1 -0
codex_switch/__init__.py
ADDED
|
@@ -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
|
+
)
|
codex_switch/install.py
ADDED
|
@@ -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
|
+
)
|
codex_switch/routing.py
ADDED
|
@@ -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]
|
codex_switch/runtime.py
ADDED
|
@@ -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
|
+
)
|
codex_switch/wrapper.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
codex_switch
|