codex-switch-cli 0.1.2__tar.gz → 0.1.3__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 (38) hide show
  1. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/PKG-INFO +2 -1
  2. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/README.md +1 -0
  3. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/pyproject.toml +1 -1
  4. codex_switch_cli-0.1.3/src/codex_switch/__init__.py +1 -0
  5. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/cli.py +67 -1
  6. codex_switch_cli-0.1.3/src/codex_switch/rate_limits.py +280 -0
  7. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/PKG-INFO +2 -1
  8. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/SOURCES.txt +2 -0
  9. codex_switch_cli-0.1.3/tests/test_rate_limits.py +124 -0
  10. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_smoke.py +1 -1
  11. codex_switch_cli-0.1.2/src/codex_switch/__init__.py +0 -1
  12. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/setup.cfg +0 -0
  13. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/auth.py +0 -0
  14. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/config.py +0 -0
  15. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/doctor.py +0 -0
  16. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/install.py +0 -0
  17. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/instances.py +0 -0
  18. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/models.py +0 -0
  19. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/paths.py +0 -0
  20. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/probe.py +0 -0
  21. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/routing.py +0 -0
  22. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/runtime.py +0 -0
  23. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/wizard.py +0 -0
  24. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/wrapper.py +0 -0
  25. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/dependency_links.txt +0 -0
  26. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/entry_points.txt +0 -0
  27. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/requires.txt +0 -0
  28. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/top_level.txt +0 -0
  29. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_auth.py +0 -0
  30. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_config.py +0 -0
  31. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_doctor.py +0 -0
  32. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_instances.py +0 -0
  33. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_integration_wrapper.py +0 -0
  34. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_probe.py +0 -0
  35. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_routing.py +0 -0
  36. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_runtime.py +0 -0
  37. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_wizard.py +0 -0
  38. {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_wrapper.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-switch-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Transparent account-aware wrapper for the Codex CLI
5
5
  Project-URL: Homepage, https://github.com/ForeverHYX/codex-switch
6
6
  Project-URL: Repository, https://github.com/ForeverHYX/codex-switch
@@ -67,6 +67,7 @@ Once setup is done, day-to-day usage stays the same:
67
67
  ```bash
68
68
  codex "review this branch"
69
69
  codex exec "make test"
70
+ codex-switch list
70
71
  ```
71
72
 
72
73
  ## How it works
@@ -52,6 +52,7 @@ Once setup is done, day-to-day usage stays the same:
52
52
  ```bash
53
53
  codex "review this branch"
54
54
  codex exec "make test"
55
+ codex-switch list
55
56
  ```
56
57
 
57
58
  ## How it works
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codex-switch-cli"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Transparent account-aware wrapper for the Codex CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.3"
@@ -21,6 +21,13 @@ from codex_switch.doctor import create_doctor_report
21
21
  from codex_switch.install import install_shim, runtime_wrapper_dir, uninstall_shim
22
22
  from codex_switch.models import AppConfig
23
23
  from codex_switch.paths import config_path
24
+ from codex_switch.rate_limits import (
25
+ FIVE_HOUR_WINDOW_MINS,
26
+ SEVEN_DAY_WINDOW_MINS,
27
+ format_reset_timestamp,
28
+ read_instance_rate_limits,
29
+ select_window_for_duration,
30
+ )
24
31
  from codex_switch.runtime import find_real_codex, resolve_real_codex
25
32
  from codex_switch.wizard import bootstrap_from_prompt, clear_existing_state, initialize_app
26
33
 
@@ -73,6 +80,22 @@ def _resolve_instance(config: AppConfig, instance_name: str):
73
80
  return instance
74
81
 
75
82
 
83
+ def _render_table(headers: list[str], rows: list[list[str]]) -> list[str]:
84
+ widths = [len(header) for header in headers]
85
+ for row in rows:
86
+ for index, cell in enumerate(row):
87
+ widths[index] = max(widths[index], len(cell))
88
+
89
+ rendered = [
90
+ " ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
91
+ ]
92
+ for row in rows:
93
+ rendered.append(
94
+ " ".join(cell.ljust(widths[index]) for index, cell in enumerate(row))
95
+ )
96
+ return rendered
97
+
98
+
76
99
  @app.command()
77
100
  def init(
78
101
  instance_count: int | None = typer.Option(None, min=1),
@@ -129,8 +152,51 @@ def init(
129
152
  @app.command("list")
130
153
  def list_instances() -> None:
131
154
  config = _load_initialized_config()
155
+ real_codex_path = _resolve_real_codex_for_management(config)
156
+
157
+ rows: list[list[str]] = []
132
158
  for instance in config.instances:
133
- typer.echo(f"{instance.name}\t{instance.home_dir}")
159
+ result = read_instance_rate_limits(real_codex_path, instance)
160
+ if not result.ok or result.snapshot is None:
161
+ rows.append(
162
+ [
163
+ instance.name,
164
+ "unavailable",
165
+ "-",
166
+ "unavailable",
167
+ "-",
168
+ result.reason or "Unavailable",
169
+ ]
170
+ )
171
+ continue
172
+
173
+ five_hour = select_window_for_duration(
174
+ result.snapshot,
175
+ FIVE_HOUR_WINDOW_MINS,
176
+ fallback="primary",
177
+ )
178
+ seven_day = select_window_for_duration(
179
+ result.snapshot,
180
+ SEVEN_DAY_WINDOW_MINS,
181
+ fallback="secondary",
182
+ )
183
+
184
+ rows.append(
185
+ [
186
+ instance.name,
187
+ f"{five_hour.remaining_percent}%" if five_hour is not None else "-",
188
+ format_reset_timestamp(five_hour.resets_at) if five_hour is not None else "-",
189
+ f"{seven_day.remaining_percent}%" if seven_day is not None else "-",
190
+ format_reset_timestamp(seven_day.resets_at) if seven_day is not None else "-",
191
+ result.snapshot.plan_type or "ok",
192
+ ]
193
+ )
194
+
195
+ for line in _render_table(
196
+ ["INSTANCE", "5H REMAINING", "5H RESET", "7D REMAINING", "7D RESET", "STATUS"],
197
+ rows,
198
+ ):
199
+ typer.echo(line)
134
200
 
135
201
 
136
202
  @app.command()
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import select
5
+ import subprocess
6
+ import time
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from codex_switch.auth import CodexCommandError
12
+ from codex_switch.models import InstanceConfig
13
+ from codex_switch.runtime import build_instance_env
14
+
15
+
16
+ INITIALIZE_REQUEST_ID = 1
17
+ RATE_LIMITS_REQUEST_ID = 2
18
+ FIVE_HOUR_WINDOW_MINS = 300
19
+ SEVEN_DAY_WINDOW_MINS = 10080
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class RateLimitWindow:
24
+ used_percent: int
25
+ window_duration_mins: int | None = None
26
+ resets_at: int | None = None
27
+
28
+ @property
29
+ def remaining_percent(self) -> int:
30
+ return max(0, 100 - self.used_percent)
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class RateLimitSnapshot:
35
+ limit_id: str | None
36
+ limit_name: str | None
37
+ plan_type: str | None
38
+ primary: RateLimitWindow | None = None
39
+ secondary: RateLimitWindow | None = None
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class InstanceRateLimitResult:
44
+ instance_name: str
45
+ ok: bool
46
+ snapshot: RateLimitSnapshot | None = None
47
+ reason: str | None = None
48
+
49
+
50
+ def _start_app_server(
51
+ real_codex_path: str | Path,
52
+ instance: InstanceConfig,
53
+ ) -> subprocess.Popen[str]:
54
+ instance_home = Path(instance.home_dir)
55
+ instance_home.mkdir(parents=True, exist_ok=True)
56
+ try:
57
+ return subprocess.Popen(
58
+ [str(real_codex_path), "app-server"],
59
+ stdin=subprocess.PIPE,
60
+ stdout=subprocess.PIPE,
61
+ stderr=subprocess.PIPE,
62
+ text=True,
63
+ env=build_instance_env(instance.name, instance_home),
64
+ cwd=instance_home,
65
+ )
66
+ except FileNotFoundError as exc:
67
+ raise CodexCommandError(f"Unable to launch the real Codex binary: {exc}") from exc
68
+
69
+
70
+ def _write_request(
71
+ process: subprocess.Popen[str],
72
+ request_id: int,
73
+ method: str,
74
+ params: object,
75
+ ) -> None:
76
+ if process.stdin is None:
77
+ raise CodexCommandError("Codex app-server stdin is unavailable")
78
+ process.stdin.write(
79
+ json.dumps(
80
+ {
81
+ "jsonrpc": "2.0",
82
+ "id": request_id,
83
+ "method": method,
84
+ "params": params,
85
+ }
86
+ )
87
+ + "\n"
88
+ )
89
+ process.stdin.flush()
90
+
91
+
92
+ def _read_response(
93
+ process: subprocess.Popen[str],
94
+ request_id: int,
95
+ *,
96
+ timeout: float,
97
+ ) -> dict[str, object]:
98
+ if process.stdout is None:
99
+ raise CodexCommandError("Codex app-server stdout is unavailable")
100
+
101
+ deadline = time.monotonic() + timeout
102
+ stdout_fd = process.stdout.fileno()
103
+
104
+ while True:
105
+ remaining = deadline - time.monotonic()
106
+ if remaining <= 0:
107
+ raise subprocess.TimeoutExpired(cmd=process.args, timeout=timeout)
108
+
109
+ ready, _, _ = select.select([stdout_fd], [], [], remaining)
110
+ if not ready:
111
+ raise subprocess.TimeoutExpired(cmd=process.args, timeout=timeout)
112
+
113
+ line = process.stdout.readline()
114
+ if not line:
115
+ stderr_output = ""
116
+ if process.stderr is not None:
117
+ stderr_output = process.stderr.read().strip()
118
+ message = "Codex app-server exited before returning a response"
119
+ if stderr_output:
120
+ message = f"{message}: {stderr_output}"
121
+ raise CodexCommandError(message)
122
+
123
+ payload = json.loads(line)
124
+ if payload.get("id") != request_id:
125
+ continue
126
+ return payload
127
+
128
+
129
+ def _extract_result(payload: dict[str, object], method: str) -> dict[str, object]:
130
+ error = payload.get("error")
131
+ if isinstance(error, dict):
132
+ message = error.get("message")
133
+ if isinstance(message, str) and message:
134
+ raise CodexCommandError(f"{method} failed: {message}")
135
+ raise CodexCommandError(f"{method} failed")
136
+
137
+ result = payload.get("result")
138
+ if not isinstance(result, dict):
139
+ raise CodexCommandError(f"{method} returned an invalid payload")
140
+ return result
141
+
142
+
143
+ def _parse_window(payload: object) -> RateLimitWindow | None:
144
+ if payload is None:
145
+ return None
146
+ if not isinstance(payload, dict):
147
+ raise CodexCommandError("Rate limit window payload is malformed")
148
+
149
+ used_percent = payload.get("usedPercent")
150
+ if not isinstance(used_percent, int) or isinstance(used_percent, bool):
151
+ raise CodexCommandError("Rate limit window is missing usedPercent")
152
+
153
+ window_duration_mins = payload.get("windowDurationMins")
154
+ if window_duration_mins is not None and (
155
+ not isinstance(window_duration_mins, int) or isinstance(window_duration_mins, bool)
156
+ ):
157
+ raise CodexCommandError("Rate limit window has an invalid windowDurationMins")
158
+
159
+ resets_at = payload.get("resetsAt")
160
+ if resets_at is not None and (not isinstance(resets_at, int) or isinstance(resets_at, bool)):
161
+ raise CodexCommandError("Rate limit window has an invalid resetsAt")
162
+
163
+ return RateLimitWindow(
164
+ used_percent=used_percent,
165
+ window_duration_mins=window_duration_mins,
166
+ resets_at=resets_at,
167
+ )
168
+
169
+
170
+ def _parse_snapshot(payload: dict[str, object]) -> RateLimitSnapshot:
171
+ return RateLimitSnapshot(
172
+ limit_id=payload.get("limitId") if isinstance(payload.get("limitId"), str) else None,
173
+ limit_name=payload.get("limitName") if isinstance(payload.get("limitName"), str) else None,
174
+ plan_type=payload.get("planType") if isinstance(payload.get("planType"), str) else None,
175
+ primary=_parse_window(payload.get("primary")),
176
+ secondary=_parse_window(payload.get("secondary")),
177
+ )
178
+
179
+
180
+ def _select_snapshot(result_payload: dict[str, object]) -> dict[str, object]:
181
+ by_limit = result_payload.get("rateLimitsByLimitId")
182
+ if isinstance(by_limit, dict):
183
+ codex_snapshot = by_limit.get("codex")
184
+ if isinstance(codex_snapshot, dict):
185
+ return codex_snapshot
186
+
187
+ rate_limits = result_payload.get("rateLimits")
188
+ if isinstance(rate_limits, dict):
189
+ return rate_limits
190
+
191
+ raise CodexCommandError("account/rateLimits/read returned no rate limit snapshot")
192
+
193
+
194
+ def read_rate_limits(
195
+ real_codex_path: str | Path,
196
+ instance: InstanceConfig,
197
+ *,
198
+ timeout: float = 8,
199
+ ) -> RateLimitSnapshot:
200
+ process = _start_app_server(real_codex_path, instance)
201
+ try:
202
+ _write_request(
203
+ process,
204
+ INITIALIZE_REQUEST_ID,
205
+ "initialize",
206
+ {
207
+ "clientInfo": {
208
+ "name": "codex-switch",
209
+ "version": "0.1.3",
210
+ },
211
+ "capabilities": {
212
+ "experimentalApi": True,
213
+ },
214
+ },
215
+ )
216
+ init_payload = _read_response(
217
+ process, INITIALIZE_REQUEST_ID, timeout=max(1.0, timeout / 2)
218
+ )
219
+ _extract_result(init_payload, "initialize")
220
+
221
+ _write_request(process, RATE_LIMITS_REQUEST_ID, "account/rateLimits/read", None)
222
+ rate_limit_payload = _read_response(
223
+ process, RATE_LIMITS_REQUEST_ID, timeout=max(1.0, timeout / 2)
224
+ )
225
+ result = _extract_result(rate_limit_payload, "account/rateLimits/read")
226
+ return _parse_snapshot(_select_snapshot(result))
227
+ except subprocess.TimeoutExpired as exc:
228
+ raise CodexCommandError("Timed out while reading account rate limits") from exc
229
+ finally:
230
+ if process.stdin is not None and not process.stdin.closed:
231
+ process.stdin.close()
232
+ try:
233
+ process.terminate()
234
+ process.wait(timeout=1)
235
+ except subprocess.TimeoutExpired:
236
+ process.kill()
237
+ process.wait(timeout=1)
238
+
239
+
240
+ def read_instance_rate_limits(
241
+ real_codex_path: str | Path,
242
+ instance: InstanceConfig,
243
+ ) -> InstanceRateLimitResult:
244
+ try:
245
+ snapshot = read_rate_limits(real_codex_path, instance)
246
+ except CodexCommandError as exc:
247
+ return InstanceRateLimitResult(
248
+ instance_name=instance.name,
249
+ ok=False,
250
+ reason=str(exc),
251
+ )
252
+ return InstanceRateLimitResult(
253
+ instance_name=instance.name,
254
+ ok=True,
255
+ snapshot=snapshot,
256
+ )
257
+
258
+
259
+ def select_window_for_duration(
260
+ snapshot: RateLimitSnapshot,
261
+ duration_mins: int,
262
+ *,
263
+ fallback: str | None = None,
264
+ ) -> RateLimitWindow | None:
265
+ candidates = [snapshot.primary, snapshot.secondary]
266
+ for window in candidates:
267
+ if window is not None and window.window_duration_mins == duration_mins:
268
+ return window
269
+
270
+ if fallback == "primary":
271
+ return snapshot.primary
272
+ if fallback == "secondary":
273
+ return snapshot.secondary
274
+ return None
275
+
276
+
277
+ def format_reset_timestamp(timestamp: int | None) -> str:
278
+ if timestamp is None:
279
+ return "-"
280
+ return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-switch-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Transparent account-aware wrapper for the Codex CLI
5
5
  Project-URL: Homepage, https://github.com/ForeverHYX/codex-switch
6
6
  Project-URL: Repository, https://github.com/ForeverHYX/codex-switch
@@ -67,6 +67,7 @@ Once setup is done, day-to-day usage stays the same:
67
67
  ```bash
68
68
  codex "review this branch"
69
69
  codex exec "make test"
70
+ codex-switch list
70
71
  ```
71
72
 
72
73
  ## How it works
@@ -10,6 +10,7 @@ src/codex_switch/instances.py
10
10
  src/codex_switch/models.py
11
11
  src/codex_switch/paths.py
12
12
  src/codex_switch/probe.py
13
+ src/codex_switch/rate_limits.py
13
14
  src/codex_switch/routing.py
14
15
  src/codex_switch/runtime.py
15
16
  src/codex_switch/wizard.py
@@ -26,6 +27,7 @@ tests/test_doctor.py
26
27
  tests/test_instances.py
27
28
  tests/test_integration_wrapper.py
28
29
  tests/test_probe.py
30
+ tests/test_rate_limits.py
29
31
  tests/test_routing.py
30
32
  tests/test_runtime.py
31
33
  tests/test_smoke.py
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from typer.testing import CliRunner
9
+
10
+ from codex_switch.cli import app
11
+ from codex_switch.models import AppConfig, InstanceConfig
12
+
13
+
14
+ def _make_launcher(tmp_path: Path, fake_codex: Path) -> Path:
15
+ launcher = tmp_path / "codex"
16
+ launcher.write_text(
17
+ "#!/bin/sh\n"
18
+ f'exec "{sys.executable}" "{fake_codex}" "$@"\n'
19
+ )
20
+ os.chmod(launcher, 0o755)
21
+ return launcher
22
+
23
+
24
+ def test_read_rate_limits_returns_structured_windows(tmp_path, fake_codex_path: Path) -> None:
25
+ from codex_switch.rate_limits import read_rate_limits
26
+
27
+ launcher = _make_launcher(tmp_path, fake_codex_path)
28
+ home = tmp_path / "acct-001"
29
+ home.mkdir()
30
+ (home / "rate-limits.json").write_text(
31
+ json.dumps(
32
+ {
33
+ "rateLimits": {
34
+ "limitId": "codex",
35
+ "limitName": None,
36
+ "planType": "plus",
37
+ "primary": {
38
+ "usedPercent": 64,
39
+ "windowDurationMins": 300,
40
+ "resetsAt": 1_775_461_397,
41
+ },
42
+ "secondary": {
43
+ "usedPercent": 29,
44
+ "windowDurationMins": 10080,
45
+ "resetsAt": 1_776_001_304,
46
+ },
47
+ }
48
+ }
49
+ )
50
+ )
51
+ instance = InstanceConfig(name="acct-001", order=1, home_dir=str(home))
52
+
53
+ snapshot = read_rate_limits(str(launcher), instance)
54
+
55
+ assert snapshot.plan_type == "plus"
56
+ assert snapshot.primary is not None
57
+ assert snapshot.primary.used_percent == 64
58
+ assert snapshot.primary.window_duration_mins == 300
59
+ assert snapshot.secondary is not None
60
+ assert snapshot.secondary.used_percent == 29
61
+ assert snapshot.secondary.window_duration_mins == 10080
62
+
63
+
64
+ def test_list_command_displays_live_rate_limits(tmp_path, monkeypatch) -> None:
65
+ from codex_switch.rate_limits import (
66
+ InstanceRateLimitResult,
67
+ RateLimitSnapshot,
68
+ RateLimitWindow,
69
+ )
70
+
71
+ config = AppConfig(
72
+ real_codex_path="/usr/local/bin/codex",
73
+ instances=[
74
+ InstanceConfig(name="acct-001", order=1, home_dir=str(tmp_path / "acct-001")),
75
+ InstanceConfig(name="acct-002", order=2, home_dir=str(tmp_path / "acct-002")),
76
+ ],
77
+ )
78
+
79
+ def fake_read(real_codex_path: str, instance: InstanceConfig) -> InstanceRateLimitResult:
80
+ if instance.name == "acct-001":
81
+ return InstanceRateLimitResult(
82
+ instance_name=instance.name,
83
+ ok=True,
84
+ snapshot=RateLimitSnapshot(
85
+ limit_id="codex",
86
+ limit_name=None,
87
+ plan_type="plus",
88
+ primary=RateLimitWindow(
89
+ used_percent=64,
90
+ window_duration_mins=300,
91
+ resets_at=1_775_461_397,
92
+ ),
93
+ secondary=RateLimitWindow(
94
+ used_percent=29,
95
+ window_duration_mins=10080,
96
+ resets_at=1_776_001_304,
97
+ ),
98
+ ),
99
+ )
100
+ return InstanceRateLimitResult(
101
+ instance_name=instance.name,
102
+ ok=False,
103
+ reason="Not logged in",
104
+ )
105
+
106
+ monkeypatch.setattr("codex_switch.cli._load_initialized_config", lambda: config)
107
+ monkeypatch.setattr(
108
+ "codex_switch.cli._resolve_real_codex_for_management",
109
+ lambda config: config.real_codex_path,
110
+ )
111
+ monkeypatch.setattr("codex_switch.cli.read_instance_rate_limits", fake_read)
112
+
113
+ result = CliRunner().invoke(app, ["list"])
114
+
115
+ assert result.exit_code == 0
116
+ assert "INSTANCE" in result.stdout
117
+ assert "5H REMAINING" in result.stdout
118
+ assert "7D REMAINING" in result.stdout
119
+ assert "acct-001" in result.stdout
120
+ assert "36%" in result.stdout
121
+ assert "71%" in result.stdout
122
+ assert "acct-002" in result.stdout
123
+ assert "unavailable" in result.stdout
124
+
@@ -5,7 +5,7 @@ from codex_switch.cli import app
5
5
 
6
6
 
7
7
  def test_package_exposes_version() -> None:
8
- assert __version__ == "0.1.2"
8
+ assert __version__ == "0.1.3"
9
9
 
10
10
 
11
11
  def test_cli_app_displays_help() -> None:
@@ -1 +0,0 @@
1
- __version__ = "0.1.2"