codexloop 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.
Files changed (104) hide show
  1. codexloop/__init__.py +3 -0
  2. codexloop/application/__init__.py +1 -0
  3. codexloop/application/dto.py +41 -0
  4. codexloop/application/ports.py +158 -0
  5. codexloop/application/runner.py +467 -0
  6. codexloop/application/usecases/__init__.py +1 -0
  7. codexloop/application/usecases/doctor.py +37 -0
  8. codexloop/application/usecases/list_threads.py +14 -0
  9. codexloop/application/usecases/preflight.py +10 -0
  10. codexloop/application/usecases/resume_thread.py +11 -0
  11. codexloop/application/usecases/run_control.py +20 -0
  12. codexloop/application/usecases/run_plan.py +11 -0
  13. codexloop/bootstrap.py +461 -0
  14. codexloop/cli/__init__.py +1 -0
  15. codexloop/cli/app.py +94 -0
  16. codexloop/cli/asyncio.py +80 -0
  17. codexloop/cli/commands/__init__.py +1 -0
  18. codexloop/cli/commands/approval_cmd.py +24 -0
  19. codexloop/cli/commands/capacity.py +33 -0
  20. codexloop/cli/commands/cwd_cmd.py +22 -0
  21. codexloop/cli/commands/doctor.py +27 -0
  22. codexloop/cli/commands/effort_cmd.py +24 -0
  23. codexloop/cli/commands/logs.py +15 -0
  24. codexloop/cli/commands/model_cmd.py +22 -0
  25. codexloop/cli/commands/prompt.py +32 -0
  26. codexloop/cli/commands/reset.py +25 -0
  27. codexloop/cli/commands/resume.py +38 -0
  28. codexloop/cli/commands/run.py +50 -0
  29. codexloop/cli/commands/runs.py +13 -0
  30. codexloop/cli/commands/sandbox_cmd.py +24 -0
  31. codexloop/cli/commands/savepoints.py +25 -0
  32. codexloop/cli/commands/snapshot.py +26 -0
  33. codexloop/cli/commands/status.py +15 -0
  34. codexloop/cli/commands/stop.py +21 -0
  35. codexloop/cli/commands/threads.py +15 -0
  36. codexloop/cli/commands/unwind.py +24 -0
  37. codexloop/cli/commands/watch.py +66 -0
  38. codexloop/cli/render.py +39 -0
  39. codexloop/domain/__init__.py +26 -0
  40. codexloop/domain/approval.py +38 -0
  41. codexloop/domain/backoff.py +34 -0
  42. codexloop/domain/budget.py +56 -0
  43. codexloop/domain/capacity.py +81 -0
  44. codexloop/domain/classify.py +98 -0
  45. codexloop/domain/completion.py +130 -0
  46. codexloop/domain/control.py +167 -0
  47. codexloop/domain/error_codes.py +75 -0
  48. codexloop/domain/errors.py +35 -0
  49. codexloop/domain/loop.py +190 -0
  50. codexloop/domain/model_profile.py +30 -0
  51. codexloop/domain/plan.py +38 -0
  52. codexloop/domain/savepoint.py +32 -0
  53. codexloop/domain/savepoint_message.py +56 -0
  54. codexloop/domain/session.py +32 -0
  55. codexloop/domain/signals.py +25 -0
  56. codexloop/domain/waiting.py +120 -0
  57. codexloop/infrastructure/__init__.py +0 -0
  58. codexloop/infrastructure/agent/__init__.py +0 -0
  59. codexloop/infrastructure/agent/argv.py +102 -0
  60. codexloop/infrastructure/agent/events.py +274 -0
  61. codexloop/infrastructure/agent/gateway.py +189 -0
  62. codexloop/infrastructure/agent/probe.py +74 -0
  63. codexloop/infrastructure/agent/process.py +208 -0
  64. codexloop/infrastructure/agent/schema.py +31 -0
  65. codexloop/infrastructure/agent/scripted.py +201 -0
  66. codexloop/infrastructure/agent/translate.py +120 -0
  67. codexloop/infrastructure/api/__init__.py +26 -0
  68. codexloop/infrastructure/api/api_baseline.json +340 -0
  69. codexloop/infrastructure/api/binder.py +170 -0
  70. codexloop/infrastructure/api/gateway.py +142 -0
  71. codexloop/infrastructure/api/introspect.py +248 -0
  72. codexloop/infrastructure/api/json_io.py +26 -0
  73. codexloop/infrastructure/api/params.py +162 -0
  74. codexloop/infrastructure/api/providers.py +70 -0
  75. codexloop/infrastructure/api/registry.py +13 -0
  76. codexloop/infrastructure/appserver/__init__.py +6 -0
  77. codexloop/infrastructure/appserver/client.py +245 -0
  78. codexloop/infrastructure/appserver/gateway.py +437 -0
  79. codexloop/infrastructure/appserver/ratelimits.py +100 -0
  80. codexloop/infrastructure/audit.py +26 -0
  81. codexloop/infrastructure/capacity_probe.py +57 -0
  82. codexloop/infrastructure/clock.py +26 -0
  83. codexloop/infrastructure/config.py +150 -0
  84. codexloop/infrastructure/control.py +89 -0
  85. codexloop/infrastructure/doctor_env.py +239 -0
  86. codexloop/infrastructure/events.py +23 -0
  87. codexloop/infrastructure/git_savepoints.py +176 -0
  88. codexloop/infrastructure/lock.py +88 -0
  89. codexloop/infrastructure/logging.py +124 -0
  90. codexloop/infrastructure/notify.py +27 -0
  91. codexloop/infrastructure/progress.py +14 -0
  92. codexloop/infrastructure/redact.py +52 -0
  93. codexloop/infrastructure/rollout.py +113 -0
  94. codexloop/infrastructure/rundir.py +57 -0
  95. codexloop/infrastructure/snapshot.py +39 -0
  96. codexloop/infrastructure/state.py +32 -0
  97. codexloop/infrastructure/state_bus.py +27 -0
  98. codexloop/infrastructure/stream_ui.py +44 -0
  99. codexloop/py.typed +0 -0
  100. codexloop-0.1.0.dist-info/METADATA +104 -0
  101. codexloop-0.1.0.dist-info/RECORD +104 -0
  102. codexloop-0.1.0.dist-info/WHEEL +4 -0
  103. codexloop-0.1.0.dist-info/entry_points.txt +2 -0
  104. codexloop-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,100 @@
1
+ """Map app-server ``account/rateLimits/read`` results onto ``PlanWindows``.
2
+
3
+ Field names match the exec JSONL rate-limit blob already parsed in
4
+ ``infrastructure.agent.events`` (``primary`` / ``secondary`` / ``plan_type`` /
5
+ ``rate_limit_reached_type``, ``used_percent`` / ``window_minutes`` /
6
+ ``resets_at`` / ``resets_in_seconds``). Helpers are duplicated here so this
7
+ package does not depend on agent internals.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from datetime import UTC, datetime, timedelta
14
+ from typing import Any
15
+
16
+ from codexloop.domain.capacity import PlanWindows, RateLimitWindow
17
+
18
+
19
+ def plan_windows_from_rpc(result: object, *, now: datetime) -> PlanWindows | None:
20
+ """Return ``PlanWindows`` from an RPC ``result`` payload, or ``None``."""
21
+ if not isinstance(result, Mapping):
22
+ return None
23
+ blob = _rate_limits_blob(result)
24
+ if blob is None or not isinstance(blob, Mapping):
25
+ return None
26
+ return PlanWindows(
27
+ primary=_window(blob.get("primary"), now=now),
28
+ secondary=_window(blob.get("secondary"), now=now),
29
+ plan_type=_opt_str(blob.get("plan_type")),
30
+ limit_reached=_opt_str(blob.get("rate_limit_reached_type")),
31
+ )
32
+
33
+
34
+ def _rate_limits_blob(obj: Mapping[str, Any]) -> object:
35
+ if "rate_limits" in obj:
36
+ return obj["rate_limits"]
37
+ payload = obj.get("payload")
38
+ if isinstance(payload, Mapping) and "rate_limits" in payload:
39
+ return payload["rate_limits"]
40
+ if any(key in obj for key in ("primary", "secondary", "plan_type", "rate_limit_reached_type")):
41
+ return obj
42
+ return None
43
+
44
+
45
+ def _window(value: object, *, now: datetime) -> RateLimitWindow | None:
46
+ if not isinstance(value, Mapping):
47
+ return None
48
+ try:
49
+ minutes = _opt_int(value.get("window_minutes"))
50
+ if minutes is None:
51
+ return None
52
+ return RateLimitWindow(
53
+ used_percent=_opt_float(value.get("used_percent")),
54
+ window_minutes=minutes,
55
+ resets_at=_resets_at(value, now=now),
56
+ )
57
+ except (TypeError, ValueError, OverflowError, OSError): # pragma: no cover
58
+ return None
59
+
60
+
61
+ def _resets_at(window: Mapping[str, Any], *, now: datetime) -> datetime | None:
62
+ raw_at = window.get("resets_at")
63
+ if isinstance(raw_at, bool):
64
+ raw_at = None
65
+ if isinstance(raw_at, int | float):
66
+ try:
67
+ return datetime.fromtimestamp(float(raw_at), tz=UTC)
68
+ except (OSError, OverflowError, ValueError):
69
+ return None
70
+ raw_in = window.get("resets_in_seconds")
71
+ if isinstance(raw_in, bool):
72
+ raw_in = None
73
+ if isinstance(raw_in, int | float):
74
+ try:
75
+ return now + timedelta(seconds=float(raw_in))
76
+ except (OverflowError, ValueError): # pragma: no cover
77
+ return None
78
+ return None
79
+
80
+
81
+ def _opt_str(value: object) -> str | None:
82
+ if isinstance(value, str):
83
+ return value
84
+ return None
85
+
86
+
87
+ def _opt_int(value: object) -> int | None:
88
+ if isinstance(value, bool):
89
+ return None
90
+ if isinstance(value, int):
91
+ return value
92
+ if isinstance(value, float) and value.is_integer():
93
+ return int(value)
94
+ return None
95
+
96
+
97
+ def _opt_float(value: object) -> float | None:
98
+ if isinstance(value, bool) or not isinstance(value, int | float):
99
+ return None
100
+ return float(value)
@@ -0,0 +1,26 @@
1
+ """JSONL audit log — the AuditLog port's filesystem adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from codexloop.infrastructure.redact import redact
11
+
12
+
13
+ class JsonlAuditLog:
14
+ def __init__(self, path: Path) -> None:
15
+ self._path = path
16
+ self._path.parent.mkdir(parents=True, exist_ok=True)
17
+
18
+ def append(self, event_type: str, payload: Mapping[str, object]) -> None:
19
+ entry: dict[str, object] = {
20
+ "timestamp": datetime.now(UTC).isoformat(),
21
+ "event_type": event_type,
22
+ **dict(payload),
23
+ }
24
+ safe = redact(entry)
25
+ with self._path.open("a", encoding="utf-8") as handle:
26
+ handle.write(json.dumps(safe, default=str) + "\n")
@@ -0,0 +1,57 @@
1
+ """Layered capacity probe: app-server (B), rollout (C), exec (A)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from inspect import isawaitable
7
+
8
+ from codexloop.application.dto import ProbeResult
9
+ from codexloop.application.ports import CapacityProbe
10
+ from codexloop.domain.capacity import PlanWindows
11
+
12
+ AppServerLimits = Callable[[], Awaitable[PlanWindows | None] | PlanWindows | None]
13
+ RolloutLimits = Callable[[], PlanWindows | None]
14
+
15
+
16
+ class CompositeCapacityProbe:
17
+ """Exec is always authoritative; B and C only enrich the snapshot."""
18
+
19
+ def __init__(
20
+ self,
21
+ exec_probe: CapacityProbe,
22
+ *,
23
+ app_server: AppServerLimits | None = None,
24
+ rollout: RolloutLimits | None = None,
25
+ ) -> None:
26
+ self._exec = exec_probe
27
+ self._app_server = app_server
28
+ self._rollout = rollout
29
+
30
+ async def probe(self) -> ProbeResult:
31
+ snapshot = await self._read_app_server()
32
+ if snapshot is None:
33
+ snapshot = self._read_rollout()
34
+ exec_result = await self._exec.probe()
35
+ return ProbeResult(outcome=exec_result.outcome, snapshot=snapshot or exec_result.snapshot)
36
+
37
+ async def _read_app_server(self) -> PlanWindows | None:
38
+ if self._app_server is None:
39
+ return None
40
+ try:
41
+ result = self._app_server()
42
+ if isawaitable(result):
43
+ return await result
44
+ return result
45
+ except Exception:
46
+ return None
47
+
48
+ def _read_rollout(self) -> PlanWindows | None:
49
+ if self._rollout is None:
50
+ return None
51
+ try:
52
+ return self._rollout()
53
+ except Exception:
54
+ return None
55
+
56
+
57
+ __all__ = ["CompositeCapacityProbe"]
@@ -0,0 +1,26 @@
1
+ """Real Clock and Sleeper adapters. Test doubles live in tests/application/fakes.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+
7
+ import anyio
8
+
9
+ from codexloop.application.ports import Clock
10
+
11
+
12
+ class SystemClock:
13
+ def now(self) -> datetime:
14
+ return datetime.now(UTC)
15
+
16
+
17
+ class AnyioSleeper:
18
+ """Sleep the remaining delta via ``anyio.sleep``; past targets are a no-op."""
19
+
20
+ def __init__(self, clock: Clock | None = None) -> None:
21
+ self._clock: Clock = clock if clock is not None else SystemClock()
22
+
23
+ async def sleep_until(self, when: datetime) -> None:
24
+ delay = (when - self._clock.now()).total_seconds()
25
+ if delay > 0:
26
+ await anyio.sleep(delay)
@@ -0,0 +1,150 @@
1
+ """Configuration precedence: flags > env > project toml > user toml > defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import tomllib
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, fields, replace
10
+ from datetime import timedelta
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from codexloop.domain.errors import ConfigurationError
15
+
16
+ _ENV_PREFIX = "CODEXLOOP_"
17
+ _DURATION = re.compile(
18
+ r"^(?:(?P<days>\d+)d)?(?:(?P<hours>\d+)h)?(?:(?P<minutes>\d+)m)?"
19
+ r"(?:(?P<seconds>\d+(?:\.\d+)?)s)?$"
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class RunnerConfig:
25
+ """Runtime knobs. ``model=None`` leaves model selection to the Codex CLI."""
26
+
27
+ model: str | None = None
28
+ max_turns: int = 100
29
+ json_logs: bool = False
30
+ max_wait: timedelta = timedelta(hours=24)
31
+ add_dirs: tuple[str, ...] = ()
32
+ log_level: str = "INFO"
33
+ log_file: str | None = None
34
+ notify_command: str | None = None
35
+
36
+
37
+ _KNOWN = {f.name for f in fields(RunnerConfig)}
38
+
39
+
40
+ def _as_bool(value: object) -> bool:
41
+ if isinstance(value, bool):
42
+ return value
43
+ if isinstance(value, str):
44
+ lowered = value.strip().lower()
45
+ if lowered in {"1", "true", "yes", "on"}:
46
+ return True
47
+ if lowered in {"0", "false", "no", "off"}:
48
+ return False
49
+ raise ConfigurationError(f"invalid bool {value!r}")
50
+
51
+
52
+ def _as_int(value: object) -> int:
53
+ if isinstance(value, bool):
54
+ raise ConfigurationError(f"invalid int {value!r}")
55
+ if isinstance(value, int):
56
+ return value
57
+ if isinstance(value, str):
58
+ return int(value.strip())
59
+ raise ConfigurationError(f"invalid int {value!r}")
60
+
61
+
62
+ def _as_duration(value: object) -> timedelta:
63
+ if isinstance(value, timedelta):
64
+ return value
65
+ if isinstance(value, bool):
66
+ raise ConfigurationError(f"invalid duration {value!r}")
67
+ if isinstance(value, int | float):
68
+ return timedelta(seconds=float(value))
69
+ if isinstance(value, str):
70
+ text = value.strip()
71
+ try:
72
+ return timedelta(seconds=float(text))
73
+ except ValueError:
74
+ pass
75
+ matched = _DURATION.fullmatch(text)
76
+ if matched is not None and any(matched.groups()):
77
+ days = int(matched.group("days") or 0)
78
+ hours = int(matched.group("hours") or 0)
79
+ minutes = int(matched.group("minutes") or 0)
80
+ seconds = float(matched.group("seconds") or 0)
81
+ return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
82
+ raise ConfigurationError(f"invalid duration {value!r}")
83
+
84
+
85
+ def _as_str_tuple(value: object) -> tuple[str, ...]:
86
+ if isinstance(value, str):
87
+ parts = [part.strip() for part in value.split(",")]
88
+ return tuple(part for part in parts if part)
89
+ if isinstance(value, list | tuple):
90
+ return tuple(str(item) for item in value)
91
+ raise ConfigurationError(f"invalid list {value!r}")
92
+
93
+
94
+ def _coerce_field(name: str, value: object) -> Any:
95
+ if name in {"model", "log_level"}:
96
+ return str(value)
97
+ if name == "max_turns":
98
+ return _as_int(value)
99
+ if name == "json_logs":
100
+ return _as_bool(value)
101
+ if name == "max_wait":
102
+ return _as_duration(value)
103
+ if name == "add_dirs":
104
+ return _as_str_tuple(value)
105
+ if name in {"log_file", "notify_command"}:
106
+ return None if value is None else str(value)
107
+ return value
108
+
109
+
110
+ def _from_file(path: Path) -> dict[str, Any]:
111
+ if not path.is_file():
112
+ return {}
113
+ with path.open("rb") as handle:
114
+ data = tomllib.load(handle)
115
+ return {key: value for key, value in data.items() if key in _KNOWN}
116
+
117
+
118
+ def _from_env(environ: Mapping[str, str]) -> dict[str, Any]:
119
+ overrides: dict[str, Any] = {}
120
+ for name in _KNOWN:
121
+ raw = environ.get(_ENV_PREFIX + name.upper())
122
+ if raw is not None:
123
+ overrides[name] = raw
124
+ return overrides
125
+
126
+
127
+ def load_config(
128
+ *,
129
+ cwd: Path | None = None,
130
+ home: Path | None = None,
131
+ environ: Mapping[str, str] | None = None,
132
+ flags: Mapping[str, object] | None = None,
133
+ ) -> RunnerConfig:
134
+ cwd = Path.cwd() if cwd is None else cwd
135
+ home = Path.home() if home is None else home
136
+ env = os.environ if environ is None else environ
137
+
138
+ merged: dict[str, Any] = {}
139
+ merged.update(_from_file(home / ".config" / "codexloop" / "codexloop.toml"))
140
+ merged.update(_from_file(cwd / "codexloop.toml"))
141
+ merged.update(_from_env(env))
142
+ if flags:
143
+ merged.update({key: value for key, value in flags.items() if value is not None})
144
+
145
+ if not merged:
146
+ return RunnerConfig()
147
+ coerced: dict[str, Any] = {
148
+ key: _coerce_field(key, value) for key, value in merged.items() if key in _KNOWN
149
+ }
150
+ return replace(RunnerConfig(), **coerced)
@@ -0,0 +1,89 @@
1
+ """File-based RunControl — operator commands land in inbox/*.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from codexloop.application.ports import Logger, RunControl
11
+ from codexloop.domain.control import ControlCommand, parse_control
12
+ from codexloop.domain.errors import ConfigurationError
13
+ from codexloop.infrastructure.logging import StructlogAppLogger
14
+
15
+
16
+ class FileRunControl:
17
+ """Poll inbox JSON files; archive successes; quarantine malformed."""
18
+
19
+ def __init__(self, inbox: Path, *, logger: Logger | None = None) -> None:
20
+ self._inbox = inbox
21
+ self._archive = inbox / "archive"
22
+ self._quarantine = inbox / "quarantine"
23
+ self._logger: Logger = logger if logger is not None else StructlogAppLogger()
24
+ self._inbox.mkdir(parents=True, exist_ok=True)
25
+ self._archive.mkdir(parents=True, exist_ok=True)
26
+ self._quarantine.mkdir(parents=True, exist_ok=True)
27
+
28
+ def enqueue(self, command: ControlCommand) -> Path:
29
+ payload = command.to_dict()
30
+ kind = str(payload.get("kind", "command"))
31
+ name = f"{time.time_ns()}-{kind}.json"
32
+ path = self._inbox / name
33
+ path.write_text(json.dumps(payload) + "\n", encoding="utf-8")
34
+ return path
35
+
36
+ def poll(self) -> Sequence[ControlCommand]:
37
+ files = sorted(
38
+ path
39
+ for path in self._inbox.iterdir()
40
+ if path.is_file() and path.suffix == ".json" and path.name.endswith(".json")
41
+ )
42
+ commands: list[ControlCommand] = []
43
+ for path in files:
44
+ try:
45
+ raw = json.loads(path.read_text(encoding="utf-8"))
46
+ if not isinstance(raw, dict):
47
+ raise ConfigurationError("control payload must be an object")
48
+ command = parse_control(raw)
49
+ except (
50
+ OSError,
51
+ json.JSONDecodeError,
52
+ ConfigurationError,
53
+ TypeError,
54
+ ValueError,
55
+ ) as exc:
56
+ self._quarantine_file(path, exc)
57
+ continue
58
+ dest = self._archive / path.name
59
+ path.replace(dest)
60
+ commands.append(command)
61
+ return commands
62
+
63
+ def _quarantine_file(self, path: Path, exc: BaseException) -> None:
64
+ dest = self._quarantine / path.name
65
+ try:
66
+ path.replace(dest)
67
+ except OSError: # pragma: no cover — leave in place if move fails
68
+ dest = path
69
+ self._logger.warning(
70
+ "control.quarantined",
71
+ path=str(dest),
72
+ error=str(exc),
73
+ )
74
+
75
+
76
+ class CompositeRunControl:
77
+ """Merge polls from several RunControl adapters (drain + inbox)."""
78
+
79
+ def __init__(self, *controls: RunControl) -> None:
80
+ self._controls = controls
81
+
82
+ def poll(self) -> Sequence[ControlCommand]:
83
+ commands: list[ControlCommand] = []
84
+ for control in self._controls:
85
+ commands.extend(control.poll())
86
+ return commands
87
+
88
+
89
+ __all__ = ["CompositeRunControl", "FileRunControl"]
@@ -0,0 +1,239 @@
1
+ """Doctor environment adapter — probes ``codex`` CLI, auth, and capacity sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess # nosec B404 — fixed argv to ``codex`` only, never shell=True
9
+ from collections.abc import Callable, Mapping, Sequence
10
+ from pathlib import Path
11
+
12
+ from codexloop.application.usecases.doctor import DoctorCheck, DoctorReport
13
+
14
+ MINIMUM_CODEX_VERSION: tuple[int, int, int] = (0, 40, 0)
15
+ _REQUIRED_EXEC_FLAGS = ("--json", "--ephemeral", "-c")
16
+ _VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
17
+
18
+
19
+ class CodexDoctorEnvironment:
20
+ """Injectable doctor probes over ``codex`` and the local filesystem."""
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ environ: Mapping[str, str] | None = None,
26
+ which: Callable[[str], str | None] | None = None,
27
+ run: Callable[..., subprocess.CompletedProcess[str]] | None = None,
28
+ home: Path | None = None,
29
+ minimum_version: tuple[int, int, int] = MINIMUM_CODEX_VERSION,
30
+ app_server_live: Callable[[], bool] | None = None,
31
+ rollout_live: Callable[[], bool] | None = None,
32
+ mcp_servers: Callable[[], Sequence[str]] | None = None,
33
+ ) -> None:
34
+ self._environ = dict(environ) if environ is not None else dict(os.environ)
35
+ self._which = which if which is not None else shutil.which
36
+ self._run = run if run is not None else _default_run
37
+ self._home = home if home is not None else Path.home()
38
+ self._minimum = minimum_version
39
+ self._app_server_live = app_server_live
40
+ self._rollout_live = rollout_live
41
+ self._mcp_servers = mcp_servers
42
+
43
+ def diagnose(self, *, cwd: Path) -> DoctorReport:
44
+ checks: list[DoctorCheck] = []
45
+ codex = self._which("codex")
46
+ if codex is None:
47
+ checks.append(
48
+ DoctorCheck(
49
+ name="codex-cli",
50
+ passed=False,
51
+ detail="`codex` not found on PATH",
52
+ )
53
+ )
54
+ version_ok = False
55
+ version_text = None
56
+ else:
57
+ version_text = self._codex_version(codex)
58
+ version_ok = self._version_meets_floor(version_text)
59
+ checks.append(
60
+ DoctorCheck(
61
+ name="codex-cli",
62
+ passed=version_ok,
63
+ detail=(
64
+ f"found at {codex} ({version_text or 'version unknown'}); "
65
+ f"minimum {'.'.join(str(p) for p in self._minimum)}"
66
+ ),
67
+ )
68
+ )
69
+
70
+ login_ok = False
71
+ if codex is not None:
72
+ login_ok = self._login_status_ok(codex)
73
+ checks.append(
74
+ DoctorCheck(
75
+ name="login-status",
76
+ passed=login_ok,
77
+ detail="codex login status exit 0" if login_ok else "codex login status failed",
78
+ )
79
+ )
80
+
81
+ flags_ok = False
82
+ if codex is not None:
83
+ flags_ok = self._exec_help_has_flags(codex)
84
+ checks.append(
85
+ DoctorCheck(
86
+ name="exec-flags",
87
+ passed=flags_ok,
88
+ detail=(
89
+ f"required flags present: {', '.join(_REQUIRED_EXEC_FLAGS)}"
90
+ if flags_ok
91
+ else f"missing one of {_REQUIRED_EXEC_FLAGS}"
92
+ ),
93
+ )
94
+ )
95
+
96
+ auth_mode = self._auth_mode()
97
+ checks.append(
98
+ DoctorCheck(
99
+ name="auth-mode",
100
+ passed=auth_mode != "none",
101
+ detail=f"active auth mode: {auth_mode}",
102
+ )
103
+ )
104
+
105
+ strategies = {
106
+ "exec": True,
107
+ "app-server": (
108
+ self._app_server_live()
109
+ if self._app_server_live is not None
110
+ else self._probe_app_server_live()
111
+ ),
112
+ "rollout": self._rollout_live() if self._rollout_live else False,
113
+ }
114
+ checks.append(
115
+ DoctorCheck(
116
+ name="probe-strategies",
117
+ passed=True,
118
+ detail=(
119
+ "live: "
120
+ + ", ".join(name for name, live in strategies.items() if live)
121
+ + (
122
+ "; unavailable: "
123
+ + ", ".join(name for name, live in strategies.items() if not live)
124
+ if any(not live for live in strategies.values())
125
+ else ""
126
+ )
127
+ ).rstrip("; "),
128
+ )
129
+ )
130
+
131
+ mcp = list(self._mcp_servers()) if self._mcp_servers is not None else []
132
+ if mcp:
133
+ checks.append(
134
+ DoctorCheck(
135
+ name="mcp-oauth",
136
+ passed=False,
137
+ detail=(
138
+ f"MCP servers requiring OAuth: {', '.join(mcp)} — "
139
+ "authorize before an unattended run"
140
+ ),
141
+ )
142
+ )
143
+ else:
144
+ checks.append(
145
+ DoctorCheck(name="mcp-oauth", passed=True, detail="no MCP OAuth servers named")
146
+ )
147
+
148
+ is_git = (cwd / ".git").is_dir()
149
+ checks.append(
150
+ DoctorCheck(
151
+ name="working-directory",
152
+ passed=is_git,
153
+ detail=(
154
+ f"{cwd} is a git repository" if is_git else f"{cwd} is NOT a git repository"
155
+ ),
156
+ )
157
+ )
158
+
159
+ return DoctorReport(
160
+ checks=tuple(checks),
161
+ auth_mode=auth_mode,
162
+ probe_strategies=strategies,
163
+ )
164
+
165
+ def _codex_version(self, path: str) -> str | None:
166
+ try:
167
+ result = self._run([path, "--version"], timeout=10)
168
+ except (OSError, subprocess.TimeoutExpired):
169
+ return None
170
+ if result.returncode != 0:
171
+ return None
172
+ text = (result.stdout or result.stderr or "").strip()
173
+ return text or None
174
+
175
+ def _version_meets_floor(self, version_text: str | None) -> bool:
176
+ if version_text is None:
177
+ return False
178
+ match = _VERSION_RE.search(version_text)
179
+ if match is None:
180
+ return False
181
+ parsed = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
182
+ return parsed >= self._minimum
183
+
184
+ def _login_status_ok(self, path: str) -> bool:
185
+ try:
186
+ result = self._run([path, "login", "status"], timeout=10)
187
+ except (OSError, subprocess.TimeoutExpired):
188
+ return False
189
+ return result.returncode == 0
190
+
191
+ def _exec_help_has_flags(self, path: str) -> bool:
192
+ try:
193
+ result = self._run([path, "exec", "--help"], timeout=10)
194
+ except (OSError, subprocess.TimeoutExpired):
195
+ return False
196
+ text = f"{result.stdout}\n{result.stderr}"
197
+ return all(flag in text for flag in _REQUIRED_EXEC_FLAGS)
198
+
199
+ def _auth_mode(self) -> str:
200
+ if self._environ.get("OPENAI_API_KEY") or self._environ.get("CODEX_API_KEY"):
201
+ return "api_key"
202
+ auth = self._home / ".codex" / "auth.json"
203
+ if auth.is_file():
204
+ return "chatgpt_plan"
205
+ return "none"
206
+
207
+ def _probe_app_server_live(self) -> bool:
208
+ """Return True when ``codex app-server --help`` succeeds (cheap live probe)."""
209
+ codex = self._which("codex")
210
+ if codex is None:
211
+ return False
212
+ try:
213
+ result = self._run([codex, "app-server", "--help"], timeout=10)
214
+ except (OSError, subprocess.TimeoutExpired):
215
+ return False
216
+ if result.returncode != 0:
217
+ return False
218
+ text = f"{result.stdout}\n{result.stderr}".lower()
219
+ return "stdio" in text or "app-server" in text
220
+
221
+
222
+ def _default_run(
223
+ argv: Sequence[str],
224
+ *,
225
+ timeout: float = 10,
226
+ ) -> subprocess.CompletedProcess[str]:
227
+ return subprocess.run( # nosec B603
228
+ list(argv),
229
+ capture_output=True,
230
+ text=True,
231
+ timeout=timeout,
232
+ check=False,
233
+ )
234
+
235
+
236
+ __all__ = [
237
+ "CodexDoctorEnvironment",
238
+ "MINIMUM_CODEX_VERSION",
239
+ ]