coding-cli-runtime 0.2.0__py3-none-any.whl → 0.4.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.
@@ -2,8 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.2.0"
6
-
7
5
  from .auth import AuthResolution, resolve_auth
8
6
  from .codex_cli import CodexExecSpec, build_codex_exec_spec
9
7
  from .contracts import (
@@ -15,19 +13,34 @@ from .contracts import (
15
13
  ErrorCode,
16
14
  )
17
15
  from .failure_classification import FailureClassification, classify_provider_failure
16
+ from .headless import (
17
+ build_claude_headless_core,
18
+ build_codex_headless_core,
19
+ build_copilot_headless_core,
20
+ build_gemini_headless_core,
21
+ )
18
22
  from .provider_contracts import (
19
23
  ApprovalContract,
20
24
  AuthContract,
25
+ DiagnosticsContract,
21
26
  HeadlessContract,
27
+ IoContract,
28
+ OutputContract,
22
29
  PathContract,
23
30
  PromptPayload,
24
31
  PromptTransport,
25
32
  ProviderContract,
26
33
  SandboxContract,
34
+ SessionDiscoveryContract,
35
+ WorkspaceEnvValueSource,
36
+ WorkspaceEnvVar,
27
37
  build_env_overlay,
28
38
  get_provider_contract,
39
+ is_provider_installed,
29
40
  render_prompt,
30
41
  resolve_config_paths,
42
+ resolve_session_search_paths,
43
+ resolve_workspace_env,
31
44
  )
32
45
  from .provider_controls import build_model_id, resolve_provider_model_controls
33
46
  from .provider_specs import (
@@ -69,6 +82,8 @@ from .session_logs import (
69
82
  )
70
83
  from .subprocess_runner import run_cli_command, run_cli_command_sync
71
84
 
85
+ __version__ = "0.4.0"
86
+
72
87
  __all__ = [
73
88
  "ApprovalContract",
74
89
  "AuthContract",
@@ -81,10 +96,13 @@ __all__ = [
81
96
  "ClaudeReasoningPolicy",
82
97
  "CliLaunchSpec",
83
98
  "ControlSpec",
99
+ "DiagnosticsContract",
84
100
  "ErrorCode",
85
101
  "FailureClassification",
86
102
  "HeadlessContract",
103
+ "IoContract",
87
104
  "ModelSpec",
105
+ "OutputContract",
88
106
  "PathContract",
89
107
  "PromptPayload",
90
108
  "PromptTransport",
@@ -92,12 +110,20 @@ __all__ = [
92
110
  "ProviderSpec",
93
111
  "SandboxContract",
94
112
  "SchemaValidationError",
113
+ "SessionDiscoveryContract",
114
+ "WorkspaceEnvVar",
115
+ "WorkspaceEnvValueSource",
95
116
  "InteractiveCliRunResult",
96
117
  "SessionProgressEvent",
97
118
  "SessionRetryDecision",
98
119
  "SessionExecutionTimeoutError",
99
120
  "TranscriptMirrorStrategy",
121
+ "build_claude_headless_core",
122
+ "build_codex_exec_spec",
123
+ "build_codex_headless_core",
124
+ "build_copilot_headless_core",
100
125
  "build_env_overlay",
126
+ "build_gemini_headless_core",
101
127
  "get_claude_default_model",
102
128
  "get_claude_effort_levels",
103
129
  "get_claude_model_candidates",
@@ -110,9 +136,9 @@ __all__ = [
110
136
  "get_gemini_model_options",
111
137
  "get_provider_contract",
112
138
  "get_provider_spec",
139
+ "is_provider_installed",
113
140
  "list_provider_specs",
114
141
  "build_model_id",
115
- "build_codex_exec_spec",
116
142
  "classify_provider_failure",
117
143
  "load_schema",
118
144
  "render_prompt",
@@ -120,6 +146,8 @@ __all__ = [
120
146
  "resolve_claude_reasoning_policy",
121
147
  "resolve_config_paths",
122
148
  "resolve_provider_model_controls",
149
+ "resolve_session_search_paths",
150
+ "resolve_workspace_env",
123
151
  "redact_text",
124
152
  "claude_project_key",
125
153
  "find_claude_session",
@@ -60,18 +60,20 @@ def build_codex_exec_spec(
60
60
  model_controls=model_controls,
61
61
  )
62
62
  reasoning_config_value = json.dumps(effective_reasoning)
63
- cmd_parts: list[str] = [str(codex_bin), "exec"]
63
+
64
+ from .headless import build_codex_headless_core
65
+
66
+ cmd_parts: list[str] = build_codex_headless_core(
67
+ model,
68
+ binary=str(codex_bin),
69
+ sandbox_mode=sandbox if sandbox else None,
70
+ full_auto=full_auto,
71
+ skip_git_repo_check=skip_git_repo_check,
72
+ )
64
73
  if json_output:
65
74
  cmd_parts.append("--json")
66
- if full_auto:
67
- cmd_parts.append("--full-auto")
68
- cmd_parts.extend(["--sandbox", sandbox])
69
- if skip_git_repo_check:
70
- cmd_parts.append("--skip-git-repo-check")
71
75
  cmd_parts.extend(
72
76
  [
73
- "--model",
74
- model,
75
77
  "--config",
76
78
  f"model_reasoning_effort={reasoning_config_value}",
77
79
  "-C",
@@ -0,0 +1,124 @@
1
+ """Per-provider headless launch core helpers.
2
+
3
+ Each helper emits the standard headless launch args for its provider,
4
+ derived from ``ProviderContract.headless``. Consumers append their own
5
+ app-specific tails (tool restrictions, output paths, prompt, etc.).
6
+
7
+ These helpers are the canonical source for headless launch flag assembly
8
+ within ``coding_cli_runtime``. In-repo consumers (feather, codex_cli,
9
+ provider_contracts builder) delegate to them. App-generation provider
10
+ wrappers may still assemble flags directly when their command construction
11
+ is interleaved with consumer-specific logic (reasoning config, output
12
+ format, artifact paths).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .provider_contracts import get_provider_contract
18
+
19
+
20
+ def build_claude_headless_core(
21
+ model: str,
22
+ *,
23
+ binary: str | None = None,
24
+ permission_mode: str | None = None,
25
+ skip_permissions: bool = True,
26
+ ) -> list[str]:
27
+ """Build Claude headless launch core args.
28
+
29
+ Returns args up to and including ``--model``. Does NOT include prompt,
30
+ output format, tool restrictions, or other app-specific flags.
31
+ """
32
+ contract = get_provider_contract("claude")
33
+ h = contract.headless
34
+ cmd: list[str] = [binary or contract.binary]
35
+ cmd.extend(h.activation_args)
36
+ if h.approval.permission_mode_flag:
37
+ mode = permission_mode or h.approval.default_permission_mode
38
+ if mode:
39
+ cmd.extend([h.approval.permission_mode_flag, mode])
40
+ if skip_permissions and h.approval.flag:
41
+ cmd.append(h.approval.flag)
42
+ cmd.extend(["--model", model])
43
+ return cmd
44
+
45
+
46
+ def build_codex_headless_core(
47
+ model: str,
48
+ *,
49
+ binary: str | None = None,
50
+ sandbox_mode: str | None = None,
51
+ full_auto: bool = True,
52
+ skip_git_repo_check: bool = True,
53
+ ) -> list[str]:
54
+ """Build Codex headless launch core args.
55
+
56
+ Returns args including ``exec``, ``--full-auto``, ``--sandbox``,
57
+ ``--skip-git-repo-check``, and ``--model``. Does NOT include
58
+ ``-C``, ``-o``, ``--output-schema``, or reasoning config.
59
+
60
+ Args:
61
+ full_auto: Include ``--full-auto`` (default True).
62
+ skip_git_repo_check: Include ``--skip-git-repo-check`` (default True).
63
+ """
64
+ contract = get_provider_contract("codex")
65
+ h = contract.headless
66
+ cmd: list[str] = [binary or contract.binary]
67
+ cmd.extend(h.activation_args)
68
+ if full_auto and h.noninteractive_mode_flag:
69
+ cmd.append(h.noninteractive_mode_flag)
70
+ if h.sandbox is not None:
71
+ mode = sandbox_mode or h.sandbox.writable_mode
72
+ cmd.extend([h.sandbox.flag, mode])
73
+ if skip_git_repo_check and h.requires_git_repo and h.skip_git_repo_flag:
74
+ cmd.append(h.skip_git_repo_flag)
75
+ cmd.extend(["--model", model])
76
+ return cmd
77
+
78
+
79
+ def build_copilot_headless_core(
80
+ model: str,
81
+ *,
82
+ binary: str | None = None,
83
+ stream: str | None = None,
84
+ ) -> list[str]:
85
+ """Build Copilot headless launch core args.
86
+
87
+ Returns args including activation (``--no-ask-user``,
88
+ ``--no-custom-instructions``), ``--allow-all``, ``--stream``,
89
+ and ``--model``. Does NOT include ``-p``, ``--share``, or
90
+ force-implementation.
91
+ """
92
+ contract = get_provider_contract("copilot")
93
+ h = contract.headless
94
+ cmd: list[str] = [binary or contract.binary]
95
+ cmd.extend(h.activation_args)
96
+ if h.approval.flag:
97
+ cmd.append(h.approval.flag)
98
+ cmd.extend(["--model", model])
99
+ if h.stream_flag:
100
+ stream_value = stream or h.default_stream_mode
101
+ if stream_value:
102
+ cmd.extend([h.stream_flag, stream_value])
103
+ return cmd
104
+
105
+
106
+ def build_gemini_headless_core(
107
+ model: str,
108
+ *,
109
+ binary: str | None = None,
110
+ ) -> list[str]:
111
+ """Build Gemini headless launch core args.
112
+
113
+ Returns args including approval flag (``--yolo``) and ``--model``.
114
+ Does NOT include ``--prompt ""`` activation (that's part of prompt
115
+ transport, handled by ``render_prompt()``).
116
+ """
117
+ contract = get_provider_contract("gemini")
118
+ h = contract.headless
119
+ cmd: list[str] = [binary or contract.binary]
120
+ cmd.extend(h.activation_args)
121
+ if h.approval.flag:
122
+ cmd.append(h.approval.flag)
123
+ cmd.extend(["--model", model])
124
+ return cmd
@@ -5,7 +5,8 @@ config paths, and headless launch conventions. It exposes frozen dataclasses
5
5
  that consumers can read selectively — no obligation to use the full structure.
6
6
 
7
7
  Public stable API:
8
- get_provider_contract, build_env_overlay, resolve_config_paths, render_prompt
8
+ get_provider_contract, build_env_overlay, resolve_config_paths, render_prompt,
9
+ resolve_workspace_env, resolve_session_search_paths, is_provider_installed
9
10
 
10
11
  Internal (not exported from __init__):
11
12
  _build_non_interactive_run
@@ -13,8 +14,10 @@ Internal (not exported from __init__):
13
14
 
14
15
  from __future__ import annotations
15
16
 
17
+ import shutil
16
18
  from dataclasses import dataclass
17
19
  from pathlib import Path
20
+ from typing import Literal, TypeAlias
18
21
 
19
22
  from .contracts import AuthMode
20
23
 
@@ -105,6 +108,51 @@ class HeadlessContract:
105
108
  default_stream_mode: str | None
106
109
 
107
110
 
111
+ @dataclass(frozen=True)
112
+ class OutputContract:
113
+ """How the CLI delivers structured output."""
114
+
115
+ format_flag: str | None
116
+ supported_formats: tuple[str, ...]
117
+ default_format: str | None
118
+ output_path_flag: str | None
119
+ schema_path_flag: str | None
120
+
121
+
122
+ WorkspaceEnvValueSource: TypeAlias = Literal["execution_dir", "workspace_root"]
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class WorkspaceEnvVar:
127
+ """An environment variable expected by the provider CLI."""
128
+
129
+ name: str
130
+ value_source: WorkspaceEnvValueSource
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class IoContract:
135
+ """Provider-specific I/O conventions beyond prompt transport."""
136
+
137
+ file_reference_prefix: str | None
138
+ workspace_env_vars: tuple[WorkspaceEnvVar, ...]
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class SessionDiscoveryContract:
143
+ """Where session logs live and how to find them."""
144
+
145
+ session_roots: tuple[str, ...]
146
+ session_glob: str
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class DiagnosticsContract:
151
+ """Where provider diagnostic logs live."""
152
+
153
+ log_glob: str
154
+
155
+
108
156
  @dataclass(frozen=True)
109
157
  class ProviderContract:
110
158
  """Structured metadata about a provider CLI.
@@ -118,6 +166,10 @@ class ProviderContract:
118
166
  auth: AuthContract
119
167
  paths: PathContract
120
168
  headless: HeadlessContract
169
+ output: OutputContract
170
+ io: IoContract
171
+ session_discovery: SessionDiscoveryContract | None
172
+ diagnostics: DiagnosticsContract | None
121
173
  notes: tuple[str, ...]
122
174
 
123
175
 
@@ -178,6 +230,22 @@ _CLAUDE_CONTRACT = ProviderContract(
178
230
  stream_modes=None,
179
231
  default_stream_mode=None,
180
232
  ),
233
+ output=OutputContract(
234
+ format_flag="--output-format",
235
+ supported_formats=("text", "json", "stream-json"),
236
+ default_format="text",
237
+ output_path_flag=None,
238
+ schema_path_flag=None,
239
+ ),
240
+ io=IoContract(
241
+ file_reference_prefix=None,
242
+ workspace_env_vars=(),
243
+ ),
244
+ session_discovery=SessionDiscoveryContract(
245
+ session_roots=("projects",),
246
+ session_glob="*/conversation.jsonl",
247
+ ),
248
+ diagnostics=None,
181
249
  notes=(),
182
250
  )
183
251
 
@@ -217,6 +285,22 @@ _CODEX_CONTRACT = ProviderContract(
217
285
  stream_modes=None,
218
286
  default_stream_mode=None,
219
287
  ),
288
+ output=OutputContract(
289
+ format_flag=None,
290
+ supported_formats=("json",),
291
+ default_format="json",
292
+ output_path_flag="-o",
293
+ schema_path_flag="--output-schema",
294
+ ),
295
+ io=IoContract(
296
+ file_reference_prefix=None,
297
+ workspace_env_vars=(),
298
+ ),
299
+ session_discovery=SessionDiscoveryContract(
300
+ session_roots=("sessions", "archived_sessions"),
301
+ session_glob="*.jsonl",
302
+ ),
303
+ diagnostics=None,
220
304
  notes=(
221
305
  "codex exec defaults to a read-only sandbox in non-interactive mode; "
222
306
  "use --sandbox danger-full-access for write access.",
@@ -258,9 +342,32 @@ _GEMINI_CONTRACT = ProviderContract(
258
342
  stream_modes=None,
259
343
  default_stream_mode=None,
260
344
  ),
345
+ output=OutputContract(
346
+ format_flag=None,
347
+ supported_formats=(),
348
+ default_format=None,
349
+ output_path_flag=None,
350
+ schema_path_flag=None,
351
+ ),
352
+ io=IoContract(
353
+ file_reference_prefix="@",
354
+ workspace_env_vars=(
355
+ WorkspaceEnvVar(
356
+ name="GEMINI_CLI_IDE_WORKSPACE_PATH",
357
+ value_source="execution_dir",
358
+ ),
359
+ ),
360
+ ),
361
+ session_discovery=SessionDiscoveryContract(
362
+ session_roots=("tmp",),
363
+ session_glob="*/chats/session-*.json",
364
+ ),
365
+ diagnostics=None,
261
366
  notes=(
262
367
  'Gemini requires --prompt "" to activate headless mode; '
263
368
  "the real prompt is delivered on stdin.",
369
+ "Gemini output format is prompt-directed, not CLI-flag-driven.",
370
+ "File references in prompts use @filename syntax.",
264
371
  ),
265
372
  )
266
373
 
@@ -295,6 +402,24 @@ _COPILOT_CONTRACT = ProviderContract(
295
402
  stream_modes=("on", "off"),
296
403
  default_stream_mode="on",
297
404
  ),
405
+ output=OutputContract(
406
+ format_flag=None,
407
+ supported_formats=("markdown",),
408
+ default_format="markdown",
409
+ output_path_flag="--share",
410
+ schema_path_flag=None,
411
+ ),
412
+ io=IoContract(
413
+ file_reference_prefix=None,
414
+ workspace_env_vars=(),
415
+ ),
416
+ session_discovery=SessionDiscoveryContract(
417
+ session_roots=("session-state",),
418
+ session_glob="*/events.jsonl",
419
+ ),
420
+ diagnostics=DiagnosticsContract(
421
+ log_glob="logs/process-*.log",
422
+ ),
298
423
  notes=(
299
424
  "Copilot default auth is CLI login (api_key_env_var is None). "
300
425
  "BYOK is available via COPILOT_PROVIDER_API_KEY.",
@@ -350,6 +475,33 @@ def build_env_overlay(
350
475
  return overlay
351
476
 
352
477
 
478
+ def resolve_workspace_env(
479
+ contract: ProviderContract,
480
+ execution_dir: str | Path,
481
+ *,
482
+ workspace_root: str | Path | None = None,
483
+ ) -> dict[str, str]:
484
+ """Resolve provider workspace env vars from contract metadata."""
485
+ resolved: dict[str, str] = {}
486
+ execution_dir_str = str(Path(execution_dir).expanduser())
487
+ workspace_root_str = None
488
+ if workspace_root is not None:
489
+ workspace_root_str = str(Path(workspace_root).expanduser())
490
+
491
+ for item in contract.io.workspace_env_vars:
492
+ if item.value_source == "execution_dir":
493
+ resolved[item.name] = execution_dir_str
494
+ continue
495
+ if item.value_source == "workspace_root":
496
+ if workspace_root_str is None:
497
+ raise ValueError(f"{item.name} requires workspace_root, but none was provided")
498
+ resolved[item.name] = workspace_root_str
499
+ continue
500
+ raise ValueError(f"Unknown workspace env value source: {item.value_source!r}")
501
+
502
+ return resolved
503
+
504
+
353
505
  def resolve_config_paths(
354
506
  contract: ProviderContract,
355
507
  *,
@@ -366,6 +518,23 @@ def resolve_config_paths(
366
518
  return host, host
367
519
 
368
520
 
521
+ def resolve_session_search_paths(
522
+ contract: ProviderContract,
523
+ *,
524
+ config_dir: str | Path | None = None,
525
+ ) -> tuple[Path, ...]:
526
+ """Expand contract session roots into concrete host paths."""
527
+ discovery = contract.session_discovery
528
+ if discovery is None:
529
+ return ()
530
+ base_dir = (
531
+ Path(config_dir).expanduser()
532
+ if config_dir is not None
533
+ else Path(contract.paths.config_dir).expanduser()
534
+ )
535
+ return tuple(base_dir / root for root in discovery.session_roots)
536
+
537
+
369
538
  def render_prompt(
370
539
  transport: PromptTransport,
371
540
  prompt: str,
@@ -387,6 +556,12 @@ def render_prompt(
387
556
  raise ValueError(f"Unknown prompt delivery mode: {transport.delivery!r}")
388
557
 
389
558
 
559
+ def is_provider_installed(provider_id: str) -> bool:
560
+ """Return whether the provider CLI binary is available on PATH."""
561
+ contract = get_provider_contract(provider_id)
562
+ return shutil.which(contract.binary) is not None
563
+
564
+
390
565
  # ---------------------------------------------------------------------------
391
566
  # Private builder (internal convenience, not public API)
392
567
  # ---------------------------------------------------------------------------
@@ -406,47 +581,53 @@ def _build_non_interactive_run(
406
581
  stream: str | None = None,
407
582
  extra_flags: tuple[str, ...] = (),
408
583
  ) -> NonInteractiveRunSpec:
409
- """Build a non-interactive CLI run spec. Internal convenience."""
410
- contract = get_provider_contract(provider_id)
411
- h = contract.headless
412
-
413
- bin_name = binary or contract.binary
414
- cmd: list[str] = [bin_name]
415
-
416
- # Headless activation (e.g. "--print" for Claude, "exec" for Codex)
417
- cmd.extend(h.activation_args)
418
-
419
- # Non-interactive mode flag (e.g. "--full-auto" for Codex)
420
- if h.noninteractive_mode_flag:
421
- cmd.append(h.noninteractive_mode_flag)
422
-
423
- # Sandbox (Codex)
424
- if h.sandbox is not None:
425
- mode = codex_sandbox_mode or h.sandbox.writable_mode
426
- cmd.extend([h.sandbox.flag, mode])
584
+ """Build a non-interactive CLI run spec. Internal convenience.
427
585
 
428
- # Git repo bypass
429
- if h.requires_git_repo and h.skip_git_repo_flag:
430
- cmd.append(h.skip_git_repo_flag)
431
-
432
- # Approval
433
- if h.approval.flag:
434
- cmd.append(h.approval.flag)
435
-
436
- # Permission mode (Claude)
437
- if h.approval.permission_mode_flag:
438
- mode_value = permission_mode or h.approval.default_permission_mode
439
- if mode_value:
440
- cmd.extend([h.approval.permission_mode_flag, mode_value])
586
+ Delegates headless core arg assembly to ``headless.build_*_headless_core()``
587
+ helpers, which derive flags from ``ProviderContract.headless``.
588
+ """
589
+ from .headless import (
590
+ build_claude_headless_core,
591
+ build_codex_headless_core,
592
+ build_copilot_headless_core,
593
+ build_gemini_headless_core,
594
+ )
441
595
 
442
- # Model
443
- cmd.extend(["--model", model])
596
+ contract = get_provider_contract(provider_id)
597
+ h = contract.headless
598
+ key = provider_id.strip().lower()
444
599
 
445
- # Stream (Copilot)
446
- if h.stream_flag:
447
- stream_value = stream or h.default_stream_mode
448
- if stream_value:
449
- cmd.extend([h.stream_flag, stream_value])
600
+ # Headless core (binary + activation + approval + model + stream)
601
+ if key == "claude":
602
+ cmd = build_claude_headless_core(model, binary=binary, permission_mode=permission_mode)
603
+ elif key == "codex":
604
+ cmd = build_codex_headless_core(model, binary=binary, sandbox_mode=codex_sandbox_mode)
605
+ elif key == "copilot":
606
+ cmd = build_copilot_headless_core(model, binary=binary, stream=stream)
607
+ elif key == "gemini":
608
+ cmd = build_gemini_headless_core(model, binary=binary)
609
+ else:
610
+ # Fallback for unknown providers — generic assembly
611
+ bin_name = binary or contract.binary
612
+ cmd = [bin_name, *h.activation_args]
613
+ if h.noninteractive_mode_flag:
614
+ cmd.append(h.noninteractive_mode_flag)
615
+ if h.sandbox is not None:
616
+ mode = codex_sandbox_mode or h.sandbox.writable_mode
617
+ cmd.extend([h.sandbox.flag, mode])
618
+ if h.requires_git_repo and h.skip_git_repo_flag:
619
+ cmd.append(h.skip_git_repo_flag)
620
+ if h.approval.flag:
621
+ cmd.append(h.approval.flag)
622
+ if h.approval.permission_mode_flag:
623
+ mode_value = permission_mode or h.approval.default_permission_mode
624
+ if mode_value:
625
+ cmd.extend([h.approval.permission_mode_flag, mode_value])
626
+ cmd.extend(["--model", model])
627
+ if h.stream_flag:
628
+ stream_value = stream or h.default_stream_mode
629
+ if stream_value:
630
+ cmd.extend([h.stream_flag, stream_value])
450
631
 
451
632
  # Prompt
452
633
  payload = render_prompt(h.prompt, prompt)
@@ -5,7 +5,11 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import re
8
+ from collections.abc import Callable
8
9
  from pathlib import Path
10
+ from typing import TypeVar
11
+
12
+ _T = TypeVar("_T")
9
13
 
10
14
 
11
15
  def normalize_path_str(path_str: str) -> str:
@@ -15,6 +19,58 @@ def normalize_path_str(path_str: str) -> str:
15
19
  return os.path.normpath(path_str)
16
20
 
17
21
 
22
+ # ---------------------------------------------------------------------------
23
+ # Generic session-directory scanning primitive
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def scan_session_dir(
28
+ directory: Path,
29
+ *,
30
+ glob_pattern: str = "*.jsonl",
31
+ since_ts: float,
32
+ mtime_buffer: float = 15.0,
33
+ extract_fn: Callable[[Path], _T | None],
34
+ max_candidates: int = 200,
35
+ ) -> list[tuple[float, Path, _T]]:
36
+ """Scan a directory for session files, filter by mtime, extract metadata.
37
+
38
+ Returns a list of ``(mtime, path, extracted)`` tuples sorted by mtime
39
+ descending. Provider-specific ranking/selection stays with the caller.
40
+
41
+ Args:
42
+ directory: Directory to scan.
43
+ glob_pattern: Glob pattern for session files (default: ``*.jsonl``).
44
+ since_ts: Only include files with mtime >= ``since_ts - mtime_buffer``.
45
+ mtime_buffer: Seconds of slack before ``since_ts`` (default: 15).
46
+ extract_fn: Called on each candidate path. Return ``None`` to skip.
47
+ max_candidates: Max number of candidates to process after mtime filter.
48
+ """
49
+ if not directory.exists():
50
+ return []
51
+
52
+ candidates: list[tuple[float, Path]] = []
53
+ try:
54
+ for path in directory.rglob(glob_pattern):
55
+ try:
56
+ mtime = path.stat().st_mtime
57
+ except OSError:
58
+ continue
59
+ if mtime >= since_ts - mtime_buffer:
60
+ candidates.append((mtime, path))
61
+ except (OSError, RuntimeError):
62
+ return []
63
+
64
+ candidates.sort(key=lambda item: item[0], reverse=True)
65
+
66
+ results: list[tuple[float, Path, _T]] = []
67
+ for mtime, path in candidates[:max_candidates]:
68
+ extracted = extract_fn(path)
69
+ if extracted is not None:
70
+ results.append((mtime, path, extracted))
71
+ return results
72
+
73
+
18
74
  def codex_session_roots() -> list[Path]:
19
75
  base = Path.home() / ".codex"
20
76
  return [base / "sessions", base / "archived_sessions"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-cli-runtime
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Reusable CLI runtime primitives for provider-backed automation workflows
5
5
  Author-email: LLM Eval maintainers <llm-eval-maintainers@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -40,17 +40,21 @@ code doesn't need provider-specific subprocess handling.
40
40
 
41
41
  **What it does (and why not just `subprocess.run`):**
42
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
43
+ - Run any provider CLI with unified request/result types and timeout enforcement
44
+ - Query the model catalog (with user-override and live-cache fallback)
45
+ - Classify failures as retryable vs fatal per provider
46
+ - Look up provider auth, config dirs, and headless launch flags
47
+ - Build non-interactive launch commands without hardcoding provider flags
48
+ - Find session logs after a run (Codex, Claude)
49
+ - Run long-lived sessions with process-group cleanup and transcript mirroring
50
+ - No Python package dependencies — only requires the provider CLIs themselves
49
51
 
50
52
  ## Installation
51
53
 
52
54
  ```bash
53
55
  pip install coding-cli-runtime
56
+ # or
57
+ uv add coding-cli-runtime
54
58
  ```
55
59
 
56
60
  Requires Python 3.10+.
@@ -65,7 +69,7 @@ from pathlib import Path
65
69
  from coding_cli_runtime import CliRunRequest, run_cli_command
66
70
 
67
71
  request = CliRunRequest(
68
- cmd_parts=("codex", "--model", "o4-mini", "--quiet", "exec", "fix the tests"),
72
+ cmd_parts=("codex", "--model", "gpt-5.4", "--quiet", "exec", "fix the tests"),
69
73
  cwd=Path("/tmp/my-project"),
70
74
  timeout_seconds=120,
71
75
  )
@@ -148,6 +152,44 @@ else:
148
152
  Works for all four providers. Recognizes auth failures, rate limits,
149
153
  network transients, and other provider-specific error patterns.
150
154
 
155
+ ### Common integration tasks
156
+
157
+ #### Check whether a provider CLI is installed
158
+
159
+ ```python
160
+ from coding_cli_runtime import is_provider_installed
161
+
162
+ if not is_provider_installed("claude"):
163
+ raise RuntimeError("Claude Code is not available on PATH")
164
+ ```
165
+
166
+ This is intentionally minimal: it checks whether the provider binary exists on
167
+ PATH. Deeper CLI drift validation belongs in maintainer tooling, not the
168
+ runtime API.
169
+
170
+ #### Resolve workspace env vars and session search paths
171
+
172
+ ```python
173
+ from coding_cli_runtime import (
174
+ get_provider_contract,
175
+ resolve_session_search_paths,
176
+ resolve_workspace_env,
177
+ )
178
+
179
+ gemini = get_provider_contract("gemini")
180
+
181
+ # Derive provider-specific workspace env vars from contract metadata
182
+ env = resolve_workspace_env(gemini, "/tmp/run-dir")
183
+ # {"GEMINI_CLI_IDE_WORKSPACE_PATH": "/tmp/run-dir"}
184
+
185
+ # Expand concrete host paths for session log searches
186
+ paths = resolve_session_search_paths(gemini)
187
+ # (Path.home() / ".gemini" / "tmp",)
188
+ ```
189
+
190
+ Use these helpers when you want the contract facts turned into concrete
191
+ filesystem/env values without rebuilding the same glue logic in each consumer.
192
+
151
193
  ### Look up provider contract metadata
152
194
 
153
195
  ```python
@@ -175,11 +217,75 @@ payload = render_prompt(contract.headless.prompt, "Fix the bug")
175
217
  ```
176
218
 
177
219
  `ProviderContract` is structured as nested sub-contracts
178
- (`AuthContract`, `PathContract`, `HeadlessContract`) so consumers
220
+ (`AuthContract`, `PathContract`, `HeadlessContract`, `OutputContract`,
221
+ `IoContract`, `SessionDiscoveryContract`, `DiagnosticsContract`) so consumers
179
222
  can drill into whichever aspect they need. This is reference metadata,
180
- not a command-construction control plane — consumers keep their own
223
+ not a command-construction control plane — callers keep their own
181
224
  command assembly and adopt contract fields selectively.
182
225
 
226
+ ### Query provider I/O conventions
227
+
228
+ ```python
229
+ from coding_cli_runtime import get_provider_contract
230
+
231
+ gemini = get_provider_contract("gemini")
232
+
233
+ # Workspace env vars with value semantics
234
+ for wev in gemini.io.workspace_env_vars:
235
+ print(f"{wev.name} = {wev.value_source}")
236
+ # GEMINI_CLI_IDE_WORKSPACE_PATH = execution_dir
237
+
238
+ # Session discovery (where session logs live)
239
+ sd = gemini.session_discovery
240
+ print(sd.session_roots) # ("tmp",)
241
+ print(sd.session_glob) # "*/chats/session-*.json"
242
+
243
+ # Output format support
244
+ codex = get_provider_contract("codex")
245
+ print(codex.output.output_path_flag) # "-o"
246
+ print(codex.output.schema_path_flag) # "--output-schema"
247
+
248
+ # Diagnostics (Copilot only)
249
+ copilot = get_provider_contract("copilot")
250
+ if copilot.diagnostics:
251
+ print(copilot.diagnostics.log_glob) # "logs/process-*.log"
252
+ ```
253
+
254
+ `WorkspaceEnvVar.value_source` uses a closed vocabulary:
255
+ `"execution_dir"` or `"workspace_root"`.
256
+
257
+ ### Build headless launch commands
258
+
259
+ ```python
260
+ from coding_cli_runtime import build_claude_headless_core, build_codex_headless_core
261
+
262
+ # Claude: binary + --print + --permission-mode + --dangerously-skip-permissions + --model
263
+ cmd = build_claude_headless_core("claude-sonnet-4-6")
264
+ cmd.extend(["--output-format", "text", "--disallowedTools", "Bash,Task"])
265
+
266
+ # Codex: binary + exec + --full-auto + --sandbox + --skip-git-repo-check + --model
267
+ cmd = build_codex_headless_core("gpt-5.4", sandbox_mode="read-only")
268
+ cmd.extend(["-C", str(workdir)])
269
+ ```
270
+
271
+ Headless core helpers emit the standard flags for non-interactive runs.
272
+ Consumers append app-specific tails (tool restrictions, output paths, etc.).
273
+
274
+ ### Find session logs after a run
275
+
276
+ ```python
277
+ import time
278
+ from coding_cli_runtime import find_codex_session, find_claude_session
279
+
280
+ # Find the most recent Codex session log for a given working directory
281
+ session = find_codex_session("/path/to/project", since_ts=time.time() - 300)
282
+ if session:
283
+ print(f"Session log: {session}") # ~/.codex/sessions/.../conversation.jsonl
284
+ ```
285
+
286
+ Works for Codex and Claude. Scans provider config directories for session
287
+ files matching the working directory and time window.
288
+
183
289
  ## Key types
184
290
 
185
291
  | Type | Purpose |
@@ -188,14 +294,55 @@ command assembly and adopt contract fields selectively.
188
294
  | `CliRunResult` | Result: returncode, stdout/stderr, duration, error code |
189
295
  | `ErrorCode` | `none` · `spawn_failed` · `timed_out` · `non_zero_exit` |
190
296
  | `ProviderSpec` | Provider catalog entry with models, controls, defaults |
191
- | `ProviderContract` | Structured provider CLI metadata (auth, paths, headless launch) |
297
+ | `ProviderContract` | Structured provider CLI metadata (auth, paths, headless, I/O, sessions) |
298
+ | `WorkspaceEnvVar` | Env var with value-source semantics (`execution_dir`, `workspace_root`) |
192
299
  | `FailureClassification` | Classified error with retryable flag and category |
193
300
 
194
- `run_interactive_session()` manages long-running CLI processes with
195
- timeout enforcement, process-group cleanup, transcript mirroring, and
196
- automatic retries. Only `cmd_parts`, `cwd`, `stdin_text`, and `logger` are
197
- required — observability labels like `job_name` and `phase_tag` default to
198
- sensible values so external callers don't need to invent them.
301
+ ### Run long-lived CLI sessions
302
+
303
+ For CLI runs that take minutes (e.g., full app generation), use
304
+ `run_interactive_session()` instead of `run_cli_command()`. It adds:
305
+
306
+ - Process-group cleanup (kills orphaned child processes on timeout)
307
+ - Transcript mirroring (streams CLI output to a file while the process runs)
308
+ - Automatic retries on transient failures
309
+
310
+ ```python
311
+ from coding_cli_runtime import run_interactive_session
312
+
313
+ result = await run_interactive_session(
314
+ cmd_parts=("claude", "--print", "--model", "claude-sonnet-4-6"),
315
+ cwd=workdir,
316
+ stdin_text=prompt,
317
+ logger=logger,
318
+ timeout_seconds=600,
319
+ )
320
+ ```
321
+
322
+ Only `cmd_parts`, `cwd`, `stdin_text`, and `logger` are required.
323
+ Other parameters have sensible defaults.
324
+
325
+ ## API summary
326
+
327
+ The full public API is listed in [`__init__.py`](src/coding_cli_runtime/__init__.py).
328
+ Key function groups:
329
+
330
+ | Group | Functions |
331
+ |-------|-----------|
332
+ | Execution | `run_cli_command`, `run_cli_command_sync`, `run_interactive_session` |
333
+ | Provider metadata | `get_provider_contract`, `get_provider_spec`, `list_provider_specs` |
334
+ | Contract helpers | `build_env_overlay`, `resolve_config_paths`, `render_prompt`, `resolve_auth`, `resolve_workspace_env`, `resolve_session_search_paths` |
335
+ | Headless launch | `build_claude_headless_core`, `build_codex_headless_core`, `build_copilot_headless_core`, `build_gemini_headless_core` |
336
+ | Codex batch | `build_codex_exec_spec` |
337
+ | Failure handling | `classify_provider_failure` |
338
+ | Installation check | `is_provider_installed` |
339
+ | Session logs | `find_codex_session`, `find_claude_session` |
340
+ | Schema | `load_schema`, `validate_payload` |
341
+ | Utilities | `redact_text`, `build_model_id`, `normalize_path_str` |
342
+
343
+ ## Contributing
344
+
345
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and quality checks.
199
346
 
200
347
  ## Prerequisites
201
348
 
@@ -1,12 +1,13 @@
1
- coding_cli_runtime/__init__.py,sha256=bp-A3L3Fh-2cEnAEa-bmWxaCv_7xWhY9XJNBLVwYTc8,3555
1
+ coding_cli_runtime/__init__.py,sha256=06_cwfOA9AMqD6oj5H7_uGj1405leIL6eYc_vCFOREg,4321
2
2
  coding_cli_runtime/auth.py,sha256=XP3TZINazvzKcrdgp-pcJcbG4s220dbVVPjF7ivI-wA,2284
3
- coding_cli_runtime/codex_cli.py,sha256=HFsA7Bd1vW9TWUZSsMANezVGbwTKxuuQZihxu9Hf9U8,2988
3
+ coding_cli_runtime/codex_cli.py,sha256=h26tfb1Kj9LyQV19OvMV-DS4NKvdWrDPXVKGhS8lALI,3002
4
4
  coding_cli_runtime/contracts.py,sha256=teYMPDYCjL6HRwRBucJKetfZKlRnxpG82BrC1Y1OMNg,1764
5
5
  coding_cli_runtime/copilot_reasoning_baseline.json,sha256=hEIsqm03-D8T9Snn_FvbC2RD367fXGziyTK0Ajpxrmk,1649
6
6
  coding_cli_runtime/copilot_reasoning_logs.py,sha256=S2GD0zGgwVXAPe-DyJPKMR5j-EgOGlANnIt315mIWuo,2327
7
7
  coding_cli_runtime/failure_classification.py,sha256=fjGOjtQaBh6Y13gEIS7ystmadcrFKsLAN-fccBuerBs,6027
8
+ coding_cli_runtime/headless.py,sha256=0q0L-crpIRb4A7GCOH2kyoSnyw37USGyFTzP6OOQq9Q,4186
8
9
  coding_cli_runtime/json_io.py,sha256=1RseVXV-uPWRm_-pGIUTAvhALnMAEivCX46zvlVpri0,2665
9
- coding_cli_runtime/provider_contracts.py,sha256=3KQqqCYiq5SC1K-Clki0S6uzoDaIYHiCnUTXV8V8_h0,14686
10
+ coding_cli_runtime/provider_contracts.py,sha256=-UhDva-gAWLTgk1ZFxo2gKNpb5GL6Cba68yJR9YB47w,20630
10
11
  coding_cli_runtime/provider_controls.py,sha256=2rl1XxGYFODu6LRx3bLhptgN5M_T3klx2lqT_gAAkb0,3328
11
12
  coding_cli_runtime/provider_specs.py,sha256=n9cptkPVI5sIBVuJlqE8nuTU7SyJwG8rkUF2enM_KZc,26777
12
13
  coding_cli_runtime/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
@@ -14,12 +15,12 @@ coding_cli_runtime/reasoning.py,sha256=Ggyw1K9Ry4bytzeS-Jy8jmNHVJR891zH_4jRpYAsw
14
15
  coding_cli_runtime/redaction.py,sha256=PALvJoNt7r0E_Dd3N02tCV9RI_0nPfSgoVAeaWxeLAY,559
15
16
  coding_cli_runtime/schema_validation.py,sha256=WZvl2_LkAnuxNMmpS2-vjtLqny034G9xT7wh1BZ1gwM,3929
16
17
  coding_cli_runtime/session_execution.py,sha256=U9oRrz2ORuZJzUf4WK2BS6ubCSLOHe94izg6xmn6d3E,20714
17
- coding_cli_runtime/session_logs.py,sha256=B3B7MB9oe829cRyLyT4GyYySTXDjHOG3TG-WqbNfRzE,3750
18
+ coding_cli_runtime/session_logs.py,sha256=wyHld9yVydWd06jSxFSW7MwzxSbiJVLNzKN4Hh6dUkU,5689
18
19
  coding_cli_runtime/subprocess_runner.py,sha256=WqYMI6ALWFhEUySycfHXEtXuCX3m5x7s1n3Bd0TyPm0,11419
19
20
  coding_cli_runtime/schemas/normalized_run_result.v1.json,sha256=ogVKJbDFAd9dJklmp8SUkdR9L5EX1rdHGj5leJJHXGs,1110
20
21
  coding_cli_runtime/schemas/reasoning_metadata.v1.json,sha256=nQWhqp9-dlzJM18OARDUwAyaA-3-I8rETZYvkgTAnOc,467
21
- coding_cli_runtime-0.2.0.dist-info/licenses/LICENSE,sha256=hVIuaMVAgQkhTh44et0cpDtN3kGOZnKQ2bY1rJJw-MI,1078
22
- coding_cli_runtime-0.2.0.dist-info/METADATA,sha256=XaHVlmvjjdY7gu3M8EjSIgKeET1DK4FdXqOFaf-xymY,7995
23
- coding_cli_runtime-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
24
- coding_cli_runtime-0.2.0.dist-info/top_level.txt,sha256=-tzjii3Qf_GTevxT5M46tITBY02R-K8Ew04hJRHOB2Y,19
25
- coding_cli_runtime-0.2.0.dist-info/RECORD,,
22
+ coding_cli_runtime-0.4.0.dist-info/licenses/LICENSE,sha256=hVIuaMVAgQkhTh44et0cpDtN3kGOZnKQ2bY1rJJw-MI,1078
23
+ coding_cli_runtime-0.4.0.dist-info/METADATA,sha256=wh9K2YDO1-741eS4nRnSwDY4t0LBpLh9eOb9QtgMzgE,13099
24
+ coding_cli_runtime-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
25
+ coding_cli_runtime-0.4.0.dist-info/top_level.txt,sha256=-tzjii3Qf_GTevxT5M46tITBY02R-K8Ew04hJRHOB2Y,19
26
+ coding_cli_runtime-0.4.0.dist-info/RECORD,,