coding-cli-runtime 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.
- coding_cli_runtime/__init__.py +108 -0
- coding_cli_runtime/auth.py +55 -0
- coding_cli_runtime/codex_cli.py +95 -0
- coding_cli_runtime/contracts.py +72 -0
- coding_cli_runtime/copilot_reasoning_baseline.json +66 -0
- coding_cli_runtime/copilot_reasoning_logs.py +81 -0
- coding_cli_runtime/failure_classification.py +183 -0
- coding_cli_runtime/json_io.py +81 -0
- coding_cli_runtime/provider_controls.py +101 -0
- coding_cli_runtime/provider_specs.py +749 -0
- coding_cli_runtime/py.typed +1 -0
- coding_cli_runtime/reasoning.py +95 -0
- coding_cli_runtime/redaction.py +20 -0
- coding_cli_runtime/schema_validation.py +101 -0
- coding_cli_runtime/schemas/normalized_run_result.v1.json +37 -0
- coding_cli_runtime/schemas/reasoning_metadata.v1.json +14 -0
- coding_cli_runtime/session_execution.py +604 -0
- coding_cli_runtime/session_logs.py +129 -0
- coding_cli_runtime/subprocess_runner.py +346 -0
- coding_cli_runtime-0.1.0.dist-info/METADATA +179 -0
- coding_cli_runtime-0.1.0.dist-info/RECORD +24 -0
- coding_cli_runtime-0.1.0.dist-info/WHEEL +5 -0
- coding_cli_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
- coding_cli_runtime-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Session log discovery helpers shared across provider wrappers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_path_str(path_str: str) -> str:
|
|
12
|
+
try:
|
|
13
|
+
return str(Path(path_str).resolve())
|
|
14
|
+
except OSError:
|
|
15
|
+
return os.path.normpath(path_str)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def codex_session_roots() -> list[Path]:
|
|
19
|
+
base = Path.home() / ".codex"
|
|
20
|
+
return [base / "sessions", base / "archived_sessions"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def claude_project_roots() -> list[Path]:
|
|
24
|
+
return [Path.home() / ".claude" / "projects"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def claude_project_key(workdir: Path) -> str:
|
|
28
|
+
normalized = workdir.as_posix().lstrip("/")
|
|
29
|
+
if not normalized:
|
|
30
|
+
return "-"
|
|
31
|
+
slug = re.sub(r"[^A-Za-z0-9-]", "-", normalized)
|
|
32
|
+
return f"-{slug}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def codex_session_matches(log_path: Path, workdir: str, *, max_lines: int = 50) -> bool:
|
|
36
|
+
try:
|
|
37
|
+
with log_path.open("r", encoding="utf-8") as handle:
|
|
38
|
+
for _ in range(max_lines):
|
|
39
|
+
line = handle.readline()
|
|
40
|
+
if not line:
|
|
41
|
+
break
|
|
42
|
+
try:
|
|
43
|
+
record = json.loads(line)
|
|
44
|
+
except json.JSONDecodeError:
|
|
45
|
+
continue
|
|
46
|
+
payload = record.get("payload")
|
|
47
|
+
if not isinstance(payload, dict):
|
|
48
|
+
continue
|
|
49
|
+
cwd = payload.get("cwd")
|
|
50
|
+
if isinstance(cwd, str) and normalize_path_str(cwd) == workdir:
|
|
51
|
+
return True
|
|
52
|
+
except OSError:
|
|
53
|
+
return False
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_codex_session(
|
|
58
|
+
workdir: str,
|
|
59
|
+
since_ts: float,
|
|
60
|
+
*,
|
|
61
|
+
session_roots: list[Path] | None = None,
|
|
62
|
+
max_scan: int = 200,
|
|
63
|
+
) -> Path | None:
|
|
64
|
+
def safe_mtime(path: Path) -> float | None:
|
|
65
|
+
try:
|
|
66
|
+
return path.stat().st_mtime
|
|
67
|
+
except OSError:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
roots = session_roots if session_roots is not None else codex_session_roots()
|
|
71
|
+
candidates: list[tuple[float, Path]] = []
|
|
72
|
+
for root in roots:
|
|
73
|
+
if not root.exists():
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
for path in root.rglob("*.jsonl"):
|
|
77
|
+
mtime = safe_mtime(path)
|
|
78
|
+
if mtime is None:
|
|
79
|
+
continue
|
|
80
|
+
if mtime >= since_ts - 15:
|
|
81
|
+
candidates.append((mtime, path))
|
|
82
|
+
except (OSError, RuntimeError):
|
|
83
|
+
continue
|
|
84
|
+
if not candidates:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
candidates.sort(key=lambda item: item[0], reverse=True)
|
|
88
|
+
for _, path in candidates[:max_scan]:
|
|
89
|
+
if codex_session_matches(path, workdir):
|
|
90
|
+
return path
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def find_claude_session(
|
|
95
|
+
workdir: str,
|
|
96
|
+
since_ts: float,
|
|
97
|
+
*,
|
|
98
|
+
project_roots: list[Path] | None = None,
|
|
99
|
+
max_scan: int = 200,
|
|
100
|
+
) -> Path | None:
|
|
101
|
+
def safe_mtime(path: Path) -> float | None:
|
|
102
|
+
try:
|
|
103
|
+
return path.stat().st_mtime
|
|
104
|
+
except OSError:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
workdir_path = Path(normalize_path_str(workdir))
|
|
108
|
+
project_key = claude_project_key(workdir_path)
|
|
109
|
+
roots = project_roots if project_roots is not None else claude_project_roots()
|
|
110
|
+
candidates: list[tuple[float, Path]] = []
|
|
111
|
+
for root in roots:
|
|
112
|
+
project_dir = root / project_key
|
|
113
|
+
if not project_dir.exists():
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
for path in project_dir.glob("*.jsonl"):
|
|
117
|
+
mtime = safe_mtime(path)
|
|
118
|
+
if mtime is None:
|
|
119
|
+
continue
|
|
120
|
+
if mtime >= since_ts - 10:
|
|
121
|
+
candidates.append((mtime, path))
|
|
122
|
+
except (OSError, RuntimeError):
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if not candidates:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
candidates.sort(key=lambda item: item[0], reverse=True)
|
|
129
|
+
return candidates[:max_scan][0][1]
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Async subprocess execution wrapper with normalized result shape."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import BinaryIO
|
|
11
|
+
|
|
12
|
+
from .contracts import CliRunRequest, CliRunResult, ErrorCode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _decode_output(payload: bytes | str | None) -> str:
|
|
16
|
+
if payload is None:
|
|
17
|
+
return ""
|
|
18
|
+
if isinstance(payload, str):
|
|
19
|
+
return payload
|
|
20
|
+
return payload.decode("utf-8", errors="replace")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _open_stream_handle(path: Path | None) -> BinaryIO | None:
|
|
24
|
+
if path is None:
|
|
25
|
+
return None
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return path.open("wb")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _stream_pipe(
|
|
31
|
+
pipe: BinaryIO | None,
|
|
32
|
+
chunks: list[bytes],
|
|
33
|
+
stream_handle: BinaryIO | None,
|
|
34
|
+
) -> None:
|
|
35
|
+
if pipe is None:
|
|
36
|
+
return
|
|
37
|
+
while True:
|
|
38
|
+
chunk = pipe.read(4096)
|
|
39
|
+
if not chunk:
|
|
40
|
+
break
|
|
41
|
+
chunks.append(chunk)
|
|
42
|
+
if stream_handle is not None:
|
|
43
|
+
stream_handle.write(chunk)
|
|
44
|
+
stream_handle.flush()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def run_cli_command(request: CliRunRequest) -> CliRunResult:
|
|
48
|
+
started = time.monotonic()
|
|
49
|
+
try:
|
|
50
|
+
proc = await asyncio.create_subprocess_exec(
|
|
51
|
+
*request.cmd_parts,
|
|
52
|
+
stdout=asyncio.subprocess.PIPE,
|
|
53
|
+
stderr=asyncio.subprocess.PIPE,
|
|
54
|
+
stdin=(
|
|
55
|
+
asyncio.subprocess.PIPE
|
|
56
|
+
if request.stdin_text is not None
|
|
57
|
+
else asyncio.subprocess.DEVNULL
|
|
58
|
+
),
|
|
59
|
+
cwd=str(request.cwd),
|
|
60
|
+
env=request.env,
|
|
61
|
+
)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
duration = time.monotonic() - started
|
|
64
|
+
return CliRunResult(
|
|
65
|
+
command=list(request.cmd_parts),
|
|
66
|
+
returncode=None,
|
|
67
|
+
stdout_text="",
|
|
68
|
+
stderr_text="",
|
|
69
|
+
duration_seconds=duration,
|
|
70
|
+
timed_out=False,
|
|
71
|
+
error_code=ErrorCode.SPAWN_FAILED,
|
|
72
|
+
error_message=str(exc),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
stdin_bytes = request.stdin_text.encode("utf-8") if request.stdin_text is not None else None
|
|
76
|
+
try:
|
|
77
|
+
if request.timeout_seconds and request.timeout_seconds > 0:
|
|
78
|
+
stdout, stderr = await asyncio.wait_for(
|
|
79
|
+
proc.communicate(input=stdin_bytes), timeout=request.timeout_seconds
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
stdout, stderr = await proc.communicate(input=stdin_bytes)
|
|
83
|
+
except TimeoutError:
|
|
84
|
+
try:
|
|
85
|
+
proc.kill()
|
|
86
|
+
except ProcessLookupError:
|
|
87
|
+
pass
|
|
88
|
+
await proc.wait()
|
|
89
|
+
duration = time.monotonic() - started
|
|
90
|
+
return CliRunResult(
|
|
91
|
+
command=list(request.cmd_parts),
|
|
92
|
+
returncode=None,
|
|
93
|
+
stdout_text="",
|
|
94
|
+
stderr_text="",
|
|
95
|
+
duration_seconds=duration,
|
|
96
|
+
timed_out=True,
|
|
97
|
+
error_code=ErrorCode.TIMED_OUT,
|
|
98
|
+
error_message=f"process timed out after {request.timeout_seconds}s",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
duration = time.monotonic() - started
|
|
102
|
+
stdout_text = stdout.decode("utf-8", errors="replace")
|
|
103
|
+
stderr_text = stderr.decode("utf-8", errors="replace")
|
|
104
|
+
|
|
105
|
+
if proc.returncode != 0:
|
|
106
|
+
return CliRunResult(
|
|
107
|
+
command=list(request.cmd_parts),
|
|
108
|
+
returncode=proc.returncode,
|
|
109
|
+
stdout_text=stdout_text,
|
|
110
|
+
stderr_text=stderr_text,
|
|
111
|
+
duration_seconds=duration,
|
|
112
|
+
timed_out=False,
|
|
113
|
+
error_code=ErrorCode.NON_ZERO_EXIT,
|
|
114
|
+
error_message=f"process exited with code {proc.returncode}",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return CliRunResult(
|
|
118
|
+
command=list(request.cmd_parts),
|
|
119
|
+
returncode=proc.returncode,
|
|
120
|
+
stdout_text=stdout_text,
|
|
121
|
+
stderr_text=stderr_text,
|
|
122
|
+
duration_seconds=duration,
|
|
123
|
+
timed_out=False,
|
|
124
|
+
error_code=ErrorCode.NONE,
|
|
125
|
+
error_message=None,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_cli_command_sync(request: CliRunRequest) -> CliRunResult:
|
|
130
|
+
started = time.monotonic()
|
|
131
|
+
stdin_bytes = request.stdin_text.encode("utf-8") if request.stdin_text is not None else None
|
|
132
|
+
timeout = (
|
|
133
|
+
request.timeout_seconds if request.timeout_seconds and request.timeout_seconds > 0 else None
|
|
134
|
+
)
|
|
135
|
+
stream_requested = (
|
|
136
|
+
request.stdout_stream_path is not None or request.stderr_stream_path is not None
|
|
137
|
+
)
|
|
138
|
+
if stream_requested:
|
|
139
|
+
return _run_cli_command_sync_streaming(
|
|
140
|
+
request=request,
|
|
141
|
+
started=started,
|
|
142
|
+
stdin_bytes=stdin_bytes,
|
|
143
|
+
timeout=timeout,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
run_kwargs = {
|
|
148
|
+
"cwd": str(request.cwd),
|
|
149
|
+
"env": request.env,
|
|
150
|
+
"input": stdin_bytes,
|
|
151
|
+
"capture_output": True,
|
|
152
|
+
"timeout": timeout,
|
|
153
|
+
"check": False,
|
|
154
|
+
}
|
|
155
|
+
if request.stdin_text is None:
|
|
156
|
+
run_kwargs["stdin"] = subprocess.DEVNULL
|
|
157
|
+
completed = subprocess.run(
|
|
158
|
+
request.cmd_parts,
|
|
159
|
+
**run_kwargs, # type: ignore[call-overload]
|
|
160
|
+
)
|
|
161
|
+
duration = time.monotonic() - started
|
|
162
|
+
except subprocess.TimeoutExpired as exc:
|
|
163
|
+
duration = time.monotonic() - started
|
|
164
|
+
return CliRunResult(
|
|
165
|
+
command=list(request.cmd_parts),
|
|
166
|
+
returncode=None,
|
|
167
|
+
stdout_text=_decode_output(exc.stdout),
|
|
168
|
+
stderr_text=_decode_output(exc.stderr),
|
|
169
|
+
duration_seconds=duration,
|
|
170
|
+
timed_out=True,
|
|
171
|
+
error_code=ErrorCode.TIMED_OUT,
|
|
172
|
+
error_message=f"process timed out after {request.timeout_seconds}s",
|
|
173
|
+
)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
duration = time.monotonic() - started
|
|
176
|
+
return CliRunResult(
|
|
177
|
+
command=list(request.cmd_parts),
|
|
178
|
+
returncode=None,
|
|
179
|
+
stdout_text="",
|
|
180
|
+
stderr_text="",
|
|
181
|
+
duration_seconds=duration,
|
|
182
|
+
timed_out=False,
|
|
183
|
+
error_code=ErrorCode.SPAWN_FAILED,
|
|
184
|
+
error_message=str(exc),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
stdout_text = _decode_output(completed.stdout)
|
|
188
|
+
stderr_text = _decode_output(completed.stderr)
|
|
189
|
+
if completed.returncode != 0:
|
|
190
|
+
return CliRunResult(
|
|
191
|
+
command=list(request.cmd_parts),
|
|
192
|
+
returncode=completed.returncode,
|
|
193
|
+
stdout_text=stdout_text,
|
|
194
|
+
stderr_text=stderr_text,
|
|
195
|
+
duration_seconds=duration,
|
|
196
|
+
timed_out=False,
|
|
197
|
+
error_code=ErrorCode.NON_ZERO_EXIT,
|
|
198
|
+
error_message=f"process exited with code {completed.returncode}",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return CliRunResult(
|
|
202
|
+
command=list(request.cmd_parts),
|
|
203
|
+
returncode=completed.returncode,
|
|
204
|
+
stdout_text=stdout_text,
|
|
205
|
+
stderr_text=stderr_text,
|
|
206
|
+
duration_seconds=duration,
|
|
207
|
+
timed_out=False,
|
|
208
|
+
error_code=ErrorCode.NONE,
|
|
209
|
+
error_message=None,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _run_cli_command_sync_streaming(
|
|
214
|
+
*,
|
|
215
|
+
request: CliRunRequest,
|
|
216
|
+
started: float,
|
|
217
|
+
stdin_bytes: bytes | None,
|
|
218
|
+
timeout: float | None,
|
|
219
|
+
) -> CliRunResult:
|
|
220
|
+
stdout_chunks: list[bytes] = []
|
|
221
|
+
stderr_chunks: list[bytes] = []
|
|
222
|
+
stdout_stream = _open_stream_handle(request.stdout_stream_path)
|
|
223
|
+
stderr_stream = _open_stream_handle(request.stderr_stream_path)
|
|
224
|
+
proc: subprocess.Popen[bytes] | None = None
|
|
225
|
+
stdin_thread: threading.Thread | None = None
|
|
226
|
+
stdout_thread: threading.Thread | None = None
|
|
227
|
+
stderr_thread: threading.Thread | None = None
|
|
228
|
+
try:
|
|
229
|
+
proc = subprocess.Popen(
|
|
230
|
+
request.cmd_parts,
|
|
231
|
+
cwd=str(request.cwd),
|
|
232
|
+
env=request.env,
|
|
233
|
+
stdin=(subprocess.PIPE if request.stdin_text is not None else subprocess.DEVNULL),
|
|
234
|
+
stdout=subprocess.PIPE,
|
|
235
|
+
stderr=subprocess.PIPE,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def _write_stdin() -> None:
|
|
239
|
+
if proc is None or proc.stdin is None:
|
|
240
|
+
return
|
|
241
|
+
try:
|
|
242
|
+
if stdin_bytes is not None:
|
|
243
|
+
proc.stdin.write(stdin_bytes)
|
|
244
|
+
proc.stdin.flush()
|
|
245
|
+
except (BrokenPipeError, OSError):
|
|
246
|
+
pass
|
|
247
|
+
finally:
|
|
248
|
+
try:
|
|
249
|
+
proc.stdin.close()
|
|
250
|
+
except OSError:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
if request.stdin_text is not None:
|
|
254
|
+
stdin_thread = threading.Thread(target=_write_stdin, daemon=True)
|
|
255
|
+
stdin_thread.start()
|
|
256
|
+
stdout_thread = threading.Thread(
|
|
257
|
+
target=_stream_pipe,
|
|
258
|
+
args=(proc.stdout, stdout_chunks, stdout_stream),
|
|
259
|
+
daemon=True,
|
|
260
|
+
)
|
|
261
|
+
stderr_thread = threading.Thread(
|
|
262
|
+
target=_stream_pipe,
|
|
263
|
+
args=(proc.stderr, stderr_chunks, stderr_stream),
|
|
264
|
+
daemon=True,
|
|
265
|
+
)
|
|
266
|
+
stdout_thread.start()
|
|
267
|
+
stderr_thread.start()
|
|
268
|
+
|
|
269
|
+
timed_out = False
|
|
270
|
+
try:
|
|
271
|
+
proc.wait(timeout=timeout)
|
|
272
|
+
except subprocess.TimeoutExpired:
|
|
273
|
+
timed_out = True
|
|
274
|
+
try:
|
|
275
|
+
proc.kill()
|
|
276
|
+
except ProcessLookupError:
|
|
277
|
+
pass
|
|
278
|
+
proc.wait()
|
|
279
|
+
|
|
280
|
+
if stdin_thread is not None:
|
|
281
|
+
stdin_thread.join()
|
|
282
|
+
if stdout_thread is not None:
|
|
283
|
+
stdout_thread.join()
|
|
284
|
+
if stderr_thread is not None:
|
|
285
|
+
stderr_thread.join()
|
|
286
|
+
|
|
287
|
+
duration = time.monotonic() - started
|
|
288
|
+
stdout_text = _decode_output(b"".join(stdout_chunks))
|
|
289
|
+
stderr_text = _decode_output(b"".join(stderr_chunks))
|
|
290
|
+
if timed_out:
|
|
291
|
+
return CliRunResult(
|
|
292
|
+
command=list(request.cmd_parts),
|
|
293
|
+
returncode=None,
|
|
294
|
+
stdout_text=stdout_text,
|
|
295
|
+
stderr_text=stderr_text,
|
|
296
|
+
duration_seconds=duration,
|
|
297
|
+
timed_out=True,
|
|
298
|
+
error_code=ErrorCode.TIMED_OUT,
|
|
299
|
+
error_message=f"process timed out after {request.timeout_seconds}s",
|
|
300
|
+
)
|
|
301
|
+
if proc.returncode != 0:
|
|
302
|
+
return CliRunResult(
|
|
303
|
+
command=list(request.cmd_parts),
|
|
304
|
+
returncode=proc.returncode,
|
|
305
|
+
stdout_text=stdout_text,
|
|
306
|
+
stderr_text=stderr_text,
|
|
307
|
+
duration_seconds=duration,
|
|
308
|
+
timed_out=False,
|
|
309
|
+
error_code=ErrorCode.NON_ZERO_EXIT,
|
|
310
|
+
error_message=f"process exited with code {proc.returncode}",
|
|
311
|
+
)
|
|
312
|
+
return CliRunResult(
|
|
313
|
+
command=list(request.cmd_parts),
|
|
314
|
+
returncode=proc.returncode,
|
|
315
|
+
stdout_text=stdout_text,
|
|
316
|
+
stderr_text=stderr_text,
|
|
317
|
+
duration_seconds=duration,
|
|
318
|
+
timed_out=False,
|
|
319
|
+
error_code=ErrorCode.NONE,
|
|
320
|
+
error_message=None,
|
|
321
|
+
)
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
duration = time.monotonic() - started
|
|
324
|
+
return CliRunResult(
|
|
325
|
+
command=list(request.cmd_parts),
|
|
326
|
+
returncode=None,
|
|
327
|
+
stdout_text=_decode_output(b"".join(stdout_chunks)),
|
|
328
|
+
stderr_text=_decode_output(b"".join(stderr_chunks)),
|
|
329
|
+
duration_seconds=duration,
|
|
330
|
+
timed_out=False,
|
|
331
|
+
error_code=ErrorCode.SPAWN_FAILED,
|
|
332
|
+
error_message=str(exc),
|
|
333
|
+
)
|
|
334
|
+
finally:
|
|
335
|
+
if proc is not None:
|
|
336
|
+
for pipe in (proc.stdin, proc.stdout, proc.stderr):
|
|
337
|
+
if pipe is None:
|
|
338
|
+
continue
|
|
339
|
+
try:
|
|
340
|
+
pipe.close()
|
|
341
|
+
except OSError:
|
|
342
|
+
pass
|
|
343
|
+
if stdout_stream is not None:
|
|
344
|
+
stdout_stream.close()
|
|
345
|
+
if stderr_stream is not None:
|
|
346
|
+
stderr_stream.close()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: coding-cli-runtime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable CLI runtime primitives for provider-backed automation workflows
|
|
5
|
+
Author-email: LLM Eval maintainers <llm-eval-maintainers@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pj-ms/llm-eval/tree/main/packages/coding-cli-runtime
|
|
8
|
+
Project-URL: Repository, https://github.com/pj-ms/llm-eval
|
|
9
|
+
Project-URL: Issues, https://github.com/pj-ms/llm-eval/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/pj-ms/llm-eval/blob/main/packages/coding-cli-runtime/CHANGELOG.md
|
|
11
|
+
Keywords: cli,runtime,llm,automation,schema-validation
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# coding-cli-runtime
|
|
28
|
+
|
|
29
|
+
[](https://pypi.org/project/coding-cli-runtime/)
|
|
30
|
+
[](https://pypi.org/project/coding-cli-runtime/)
|
|
31
|
+
[](https://github.com/pj-ms/llm-eval/actions/workflows/ci.yml)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
34
|
+
A Python library for orchestrating LLM coding agent CLIs — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), and [GitHub Copilot](https://docs.github.com/en/copilot).
|
|
35
|
+
|
|
36
|
+
These CLIs each have different invocation patterns, output formats, error
|
|
37
|
+
shapes, and timeout behaviors. This library normalizes all of that behind
|
|
38
|
+
a common `CliRunRequest` → `CliRunResult` contract, so your automation
|
|
39
|
+
code doesn't need provider-specific subprocess handling.
|
|
40
|
+
|
|
41
|
+
**What it does (and why not just `subprocess.run`):**
|
|
42
|
+
|
|
43
|
+
- Unified request/result types across all four CLIs
|
|
44
|
+
- Timeout enforcement with graceful process termination
|
|
45
|
+
- Provider-aware failure classification (retryable vs fatal)
|
|
46
|
+
- Built-in model catalog with defaults, reasoning levels, and capabilities
|
|
47
|
+
- Interactive session management for long-running generation tasks
|
|
48
|
+
- Zero runtime dependencies
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install coding-cli-runtime
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Python 3.10+.
|
|
57
|
+
|
|
58
|
+
## Examples
|
|
59
|
+
|
|
60
|
+
### Execute a provider CLI
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import asyncio
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
from coding_cli_runtime import CliRunRequest, run_cli_command
|
|
66
|
+
|
|
67
|
+
request = CliRunRequest(
|
|
68
|
+
cmd_parts=("codex", "--model", "o4-mini", "--quiet", "exec", "fix the tests"),
|
|
69
|
+
cwd=Path("/tmp/my-project"),
|
|
70
|
+
timeout_seconds=120,
|
|
71
|
+
)
|
|
72
|
+
result = asyncio.run(run_cli_command(request))
|
|
73
|
+
|
|
74
|
+
print(result.returncode) # 0
|
|
75
|
+
print(result.error_code) # "none"
|
|
76
|
+
print(result.duration_seconds) # 14.2
|
|
77
|
+
print(result.stdout_text[:200])
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Swap `codex` for `claude`, `gemini`, or `copilot` — the request/result
|
|
81
|
+
shape stays the same. A synchronous variant `run_cli_command_sync` is also
|
|
82
|
+
available.
|
|
83
|
+
|
|
84
|
+
### Pick a model from the provider catalog
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from coding_cli_runtime import get_provider_spec
|
|
88
|
+
|
|
89
|
+
codex = get_provider_spec("codex")
|
|
90
|
+
print(codex.default_model) # "gpt-5.3-codex"
|
|
91
|
+
print(codex.model_source) # "codex_cli_cache", "override", or "code"
|
|
92
|
+
|
|
93
|
+
for model in codex.models:
|
|
94
|
+
print(f" {model.name}: {model.description}")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The catalog covers all four providers — each with model names, reasoning
|
|
98
|
+
levels, default settings, and visibility flags.
|
|
99
|
+
|
|
100
|
+
Model lists are resolved with a three-tier fallback:
|
|
101
|
+
|
|
102
|
+
1. **User override** — drop a JSON file at
|
|
103
|
+
`~/.config/coding-cli-runtime/providers/<provider>.json` to use your own
|
|
104
|
+
model list immediately, without waiting for a package update.
|
|
105
|
+
2. **Live CLI cache** — for Codex, the library reads
|
|
106
|
+
`~/.codex/models_cache.json` (auto-refreshed by the Codex CLI) when
|
|
107
|
+
present. Other providers fall through because their CLIs don't expose a
|
|
108
|
+
machine-readable model list.
|
|
109
|
+
3. **Hardcoded fallback** — the model list shipped with the package.
|
|
110
|
+
|
|
111
|
+
Override file format:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"default_model": "claude-sonnet-4-7",
|
|
116
|
+
"models": [
|
|
117
|
+
"claude-sonnet-4-7",
|
|
118
|
+
{
|
|
119
|
+
"name": "claude-opus-5",
|
|
120
|
+
"description": "Latest opus model",
|
|
121
|
+
"controls": [
|
|
122
|
+
{ "name": "effort", "kind": "choice", "choices": ["low", "high"], "default": "low" }
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Set `CODING_CLI_RUNTIME_CONFIG_DIR` to change the config directory
|
|
130
|
+
(default: `~/.config/coding-cli-runtime`).
|
|
131
|
+
|
|
132
|
+
### Decide whether to retry a failed run
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from coding_cli_runtime import classify_provider_failure
|
|
136
|
+
|
|
137
|
+
classification = classify_provider_failure(
|
|
138
|
+
provider="gemini",
|
|
139
|
+
stderr_text="429 Resource exhausted: rate limit exceeded",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if classification.retryable:
|
|
143
|
+
print(f"Retryable ({classification.category}) — will retry")
|
|
144
|
+
else:
|
|
145
|
+
print(f"Fatal ({classification.category}) — giving up")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Works for all four providers. Recognizes auth failures, rate limits,
|
|
149
|
+
network transients, and other provider-specific error patterns.
|
|
150
|
+
|
|
151
|
+
## Key types
|
|
152
|
+
|
|
153
|
+
| Type | Purpose |
|
|
154
|
+
|------|---------|
|
|
155
|
+
| `CliRunRequest` | Command spec: cmd, cwd, env, timeout, stream paths |
|
|
156
|
+
| `CliRunResult` | Result: returncode, stdout/stderr, duration, error code |
|
|
157
|
+
| `ErrorCode` | `none` · `spawn_failed` · `timed_out` · `non_zero_exit` |
|
|
158
|
+
| `ProviderSpec` | Provider catalog entry with models, controls, defaults |
|
|
159
|
+
| `FailureClassification` | Classified error with retryable flag and category |
|
|
160
|
+
|
|
161
|
+
`run_interactive_session()` manages long-running CLI processes with
|
|
162
|
+
timeout enforcement, process-group cleanup, transcript mirroring, and
|
|
163
|
+
automatic retries. Only `cmd_parts`, `cwd`, `stdin_text`, and `logger` are
|
|
164
|
+
required — observability labels like `job_name` and `phase_tag` default to
|
|
165
|
+
sensible values so external callers don't need to invent them.
|
|
166
|
+
|
|
167
|
+
## Prerequisites
|
|
168
|
+
|
|
169
|
+
This package does **not** bundle any CLI binaries or credentials. You must
|
|
170
|
+
install and authenticate the relevant provider CLI yourself before using the
|
|
171
|
+
execution helpers.
|
|
172
|
+
|
|
173
|
+
## Status
|
|
174
|
+
|
|
175
|
+
Pre-1.0. API may change between minor versions.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
coding_cli_runtime/__init__.py,sha256=edCw6YCLaukPFtSjw-EEnLiPiUYnwyKfGl0UhlGxx2M,2979
|
|
2
|
+
coding_cli_runtime/auth.py,sha256=LVQwdODVSLv-MkDBCeB_0J1R8uE3DsL9mYeF4ljil0Y,1644
|
|
3
|
+
coding_cli_runtime/codex_cli.py,sha256=HFsA7Bd1vW9TWUZSsMANezVGbwTKxuuQZihxu9Hf9U8,2988
|
|
4
|
+
coding_cli_runtime/contracts.py,sha256=teYMPDYCjL6HRwRBucJKetfZKlRnxpG82BrC1Y1OMNg,1764
|
|
5
|
+
coding_cli_runtime/copilot_reasoning_baseline.json,sha256=hEIsqm03-D8T9Snn_FvbC2RD367fXGziyTK0Ajpxrmk,1649
|
|
6
|
+
coding_cli_runtime/copilot_reasoning_logs.py,sha256=S2GD0zGgwVXAPe-DyJPKMR5j-EgOGlANnIt315mIWuo,2327
|
|
7
|
+
coding_cli_runtime/failure_classification.py,sha256=fjGOjtQaBh6Y13gEIS7ystmadcrFKsLAN-fccBuerBs,6027
|
|
8
|
+
coding_cli_runtime/json_io.py,sha256=1RseVXV-uPWRm_-pGIUTAvhALnMAEivCX46zvlVpri0,2665
|
|
9
|
+
coding_cli_runtime/provider_controls.py,sha256=2rl1XxGYFODu6LRx3bLhptgN5M_T3klx2lqT_gAAkb0,3328
|
|
10
|
+
coding_cli_runtime/provider_specs.py,sha256=n9cptkPVI5sIBVuJlqE8nuTU7SyJwG8rkUF2enM_KZc,26777
|
|
11
|
+
coding_cli_runtime/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
12
|
+
coding_cli_runtime/reasoning.py,sha256=Ggyw1K9Ry4bytzeS-Jy8jmNHVJR891zH_4jRpYAswoA,3216
|
|
13
|
+
coding_cli_runtime/redaction.py,sha256=PALvJoNt7r0E_Dd3N02tCV9RI_0nPfSgoVAeaWxeLAY,559
|
|
14
|
+
coding_cli_runtime/schema_validation.py,sha256=WZvl2_LkAnuxNMmpS2-vjtLqny034G9xT7wh1BZ1gwM,3929
|
|
15
|
+
coding_cli_runtime/session_execution.py,sha256=U9oRrz2ORuZJzUf4WK2BS6ubCSLOHe94izg6xmn6d3E,20714
|
|
16
|
+
coding_cli_runtime/session_logs.py,sha256=B3B7MB9oe829cRyLyT4GyYySTXDjHOG3TG-WqbNfRzE,3750
|
|
17
|
+
coding_cli_runtime/subprocess_runner.py,sha256=WqYMI6ALWFhEUySycfHXEtXuCX3m5x7s1n3Bd0TyPm0,11419
|
|
18
|
+
coding_cli_runtime/schemas/normalized_run_result.v1.json,sha256=ogVKJbDFAd9dJklmp8SUkdR9L5EX1rdHGj5leJJHXGs,1110
|
|
19
|
+
coding_cli_runtime/schemas/reasoning_metadata.v1.json,sha256=nQWhqp9-dlzJM18OARDUwAyaA-3-I8rETZYvkgTAnOc,467
|
|
20
|
+
coding_cli_runtime-0.1.0.dist-info/licenses/LICENSE,sha256=hVIuaMVAgQkhTh44et0cpDtN3kGOZnKQ2bY1rJJw-MI,1078
|
|
21
|
+
coding_cli_runtime-0.1.0.dist-info/METADATA,sha256=QdR4cw7AAY-6AhqVwjvwEHMbCXYoC3dS-ovuuot_FB0,6465
|
|
22
|
+
coding_cli_runtime-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
23
|
+
coding_cli_runtime-0.1.0.dist-info/top_level.txt,sha256=-tzjii3Qf_GTevxT5M46tITBY02R-K8Ew04hJRHOB2Y,19
|
|
24
|
+
coding_cli_runtime-0.1.0.dist-info/RECORD,,
|