codex-switch-cli 0.1.0__tar.gz

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