arcgentic 0.2.2a3__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.
arcgentic/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """arcgentic — agentic harness for rigorous round-driven development."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.2-alpha.3"
@@ -0,0 +1,77 @@
1
+ """IDE adapter auto-detection.
2
+
3
+ `detect_adapter()` inspects environment variables, filesystem markers, and
4
+ binary availability to select the appropriate adapter for the current host.
5
+ Falls back to InlineAdapter if no LLM host can be confidently identified.
6
+
7
+ Detection rules (each: env-var checks use Python truthiness — empty-string
8
+ env-var values fall through, which is the intended behavior since empty-string
9
+ markers are semantically equivalent to unset):
10
+ 1. Claude Code — CLAUDE_CODE_SESSION (non-empty) OR ~/.claude/skills dir exists (home-relative)
11
+ 2. Cursor — CURSOR_SESSION (non-empty) OR .cursor/rules dir exists (CWD-relative)
12
+ 3. VSCode+Codex — VSCODE_PID (non-empty) AND `codex` binary on PATH
13
+ 4. Codex CLI — CODEX_SESSION (non-empty) OR `codex` binary on PATH
14
+ 5. Inline — fallback when no LLM host can be identified
15
+
16
+ Public API:
17
+ detect_adapter() -> IDEAdapter
18
+ IDEAdapter (re-export from .base for convenience)
19
+ AgentDispatchResult (re-export from .base)
20
+
21
+ Spec reference: docs/plans/2026-05-13-arcgentic-v0.2.0-spec.md § 3.6
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from pathlib import Path
28
+ from shutil import which
29
+
30
+ from .base import AgentDispatchResult, IDEAdapter
31
+
32
+ __all__ = ["detect_adapter", "IDEAdapter", "AgentDispatchResult"]
33
+
34
+
35
+ def detect_adapter() -> IDEAdapter:
36
+ """Return the IDEAdapter best matching the current runtime environment.
37
+
38
+ Falls back to InlineAdapter if no LLM host can be detected. Callers should
39
+ NOT assume the returned adapter can dispatch agents (InlineAdapter can't
40
+ actually dispatch — it's a degraded fallback for dry-run / headless use).
41
+ """
42
+ # 1. Claude Code
43
+ if os.environ.get("CLAUDE_CODE_SESSION") or _has_dir("~/.claude/skills"):
44
+ from .claude_code import ClaudeCodeAdapter
45
+ return ClaudeCodeAdapter()
46
+
47
+ # rule 2 — Cursor (.cursor/rules is project-local, CWD-relative;
48
+ # detect_adapter() picks up the marker only when called from project root)
49
+ if os.environ.get("CURSOR_SESSION") or _has_dir(".cursor/rules"):
50
+ from .cursor import CursorAdapter
51
+ return CursorAdapter()
52
+
53
+ # 3. VSCode + Codex (must have BOTH a VSCode env marker AND codex binary)
54
+ if os.environ.get("VSCODE_PID") and which("codex"):
55
+ from .vscode_codex import VSCodeCodexAdapter
56
+ return VSCodeCodexAdapter()
57
+
58
+ # 4. Codex CLI standalone (CODEX_SESSION env, OR codex binary without VSCode)
59
+ if os.environ.get("CODEX_SESSION") or which("codex"):
60
+ from .codex_cli import CodexCLIAdapter
61
+ return CodexCLIAdapter()
62
+
63
+ # 5. Fallback
64
+ from .inline import InlineAdapter
65
+ return InlineAdapter()
66
+
67
+
68
+ def _has_dir(path: str) -> bool:
69
+ """Return True if `path` is a directory (after ~-expansion).
70
+
71
+ Paths starting with `~` are home-relative (expand to $HOME).
72
+ Other paths are interpreted relative to the current working directory.
73
+ Used by detect_adapter() for both home-scoped markers (e.g. ~/.claude/skills,
74
+ Claude Code installation) and project-scoped markers (e.g. .cursor/rules,
75
+ Cursor project rules).
76
+ """
77
+ return Path(path).expanduser().is_dir()
@@ -0,0 +1,125 @@
1
+ """Shared local-environment helpers for IDEAdapter implementations.
2
+
3
+ These functions implement filesystem / shell / git operations that are identical
4
+ across all non-canonical IDE adapters (Cursor / VSCode-Codex / Codex CLI / Inline).
5
+ The canonical ClaudeCodeAdapter inlines these directly; a future cleanup task may
6
+ unify it with this module.
7
+
8
+ These are module-level functions (NOT a class) because they have no state — each
9
+ call is a fresh subprocess or filesystem op. Adapters call them as
10
+ `_local_env.read_file(path)` etc.
11
+
12
+ Spec reference: docs/plans/2026-05-13-arcgentic-v0.2.0-spec.md § 3.3–§ 3.5
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import subprocess
18
+ from pathlib import Path
19
+
20
+
21
+ def read_file(path: str) -> str:
22
+ """Read a file and return its text content (utf-8)."""
23
+ return Path(path).read_text(encoding="utf-8")
24
+
25
+
26
+ def write_file(path: str, content: str) -> None:
27
+ """Write `content` to `path` (utf-8); creates or overwrites."""
28
+ Path(path).write_text(content, encoding="utf-8")
29
+
30
+
31
+ def edit_file(path: str, old: str, new: str) -> None:
32
+ """Replace exactly one occurrence of `old` with `new`.
33
+
34
+ Raises ValueError on zero or multi-match (identical contract to
35
+ ClaudeCodeAdapter.edit_file per spec § 3 IDEAdapter Protocol).
36
+ """
37
+ p = Path(path)
38
+ text = p.read_text(encoding="utf-8")
39
+ count = text.count(old)
40
+ if count == 0:
41
+ raise ValueError(f"edit_file: `old` not found in {path}")
42
+ if count > 1:
43
+ raise ValueError(f"edit_file: `old` appears {count} times in {path} (ambiguous)")
44
+ p.write_text(text.replace(old, new, 1), encoding="utf-8")
45
+
46
+
47
+ def shell(command: str, timeout_seconds: int = 120) -> tuple[str, int]:
48
+ """Run a shell command; return (stdout, exit_code).
49
+
50
+ On TimeoutExpired returns ('', 124) — same POSIX timeout convention as
51
+ ClaudeCodeAdapter.shell.
52
+ """
53
+ try:
54
+ result = subprocess.run(
55
+ command,
56
+ shell=True,
57
+ capture_output=True,
58
+ text=True,
59
+ timeout=timeout_seconds,
60
+ check=False,
61
+ )
62
+ return result.stdout, result.returncode
63
+ except subprocess.TimeoutExpired:
64
+ return "", 124
65
+
66
+
67
+ def _run_git(args: list[str], timeout_seconds: int = 60) -> tuple[str, str, int]:
68
+ """Run `git <args>` via list-form subprocess (no shell=True).
69
+
70
+ Returns (stdout, stderr, exit_code). Used by git_diff_staged / git_commit
71
+ which need stderr for error diagnostics — shell() can't return stderr
72
+ without breaking the IDEAdapter Protocol's tuple[str, int] signature.
73
+
74
+ Underscore-prefix signals this is an internal helper; callers should use
75
+ git_diff_staged() and git_commit() (the public surface).
76
+ """
77
+ try:
78
+ result = subprocess.run(
79
+ ["git", *args],
80
+ capture_output=True,
81
+ text=True,
82
+ timeout=timeout_seconds,
83
+ check=False,
84
+ )
85
+ return result.stdout, result.stderr, result.returncode
86
+ except subprocess.TimeoutExpired:
87
+ return "", f"git {args[0] if args else ''} timed out", 124
88
+
89
+
90
+ def git_diff_staged() -> str:
91
+ """Return the output of `git diff --staged`.
92
+
93
+ Raises RuntimeError if git exits non-zero (e.g., not in a git repo).
94
+ """
95
+ stdout, stderr, code = _run_git(["diff", "--staged"])
96
+ if code != 0:
97
+ raise RuntimeError(f"git diff --staged failed (exit {code}): {stderr.strip()}")
98
+ return stdout
99
+
100
+
101
+ def git_commit(message: str, files: list[str] | None = None) -> str:
102
+ """Stage `files` (if provided) then commit; return the new SHA.
103
+
104
+ If `files` is None, commits whatever is already in the index.
105
+ Does NOT use --no-verify / --no-gpg-sign / --amend per Protocol contract.
106
+ """
107
+ if files is not None:
108
+ for f in files:
109
+ _, stderr, code = _run_git(["add", "--", f])
110
+ if code != 0:
111
+ raise RuntimeError(f"git add {f} failed (exit {code}): {stderr.strip()}")
112
+
113
+ _, stderr, code = _run_git(["commit", "-m", message])
114
+ if code != 0:
115
+ raise RuntimeError(f"git commit failed (exit {code}): {stderr.strip()}")
116
+
117
+ stdout, stderr, code = _run_git(["rev-parse", "HEAD"])
118
+ if code != 0:
119
+ raise RuntimeError(f"git rev-parse HEAD failed (exit {code}): {stderr.strip()}")
120
+ return stdout.strip()
121
+
122
+
123
+ def shquote(s: str) -> str:
124
+ """POSIX single-quote escape for safe shell=True interpolation."""
125
+ return "'" + s.replace("'", "'\\''") + "'"
@@ -0,0 +1,108 @@
1
+ """IDE Adapter Protocol — the abstraction surface arcgentic skills/CLI use to talk
2
+ to whatever AI agent platform is hosting them (Claude Code / Cursor / VSCode-Codex /
3
+ Codex CLI / inline fallback).
4
+
5
+ Adding a new platform = implementing this Protocol; arcgentic skills/CLI then work
6
+ unchanged.
7
+
8
+ Anti-contamination invariant (spec § 1.5): adapter methods MUST NOT inject
9
+ `tools=` or `tool_choice=` at the agent level. Those belong one layer down
10
+ in the LLM-client layer.
11
+
12
+ Spec reference: docs/plans/2026-05-13-arcgentic-v0.2.0-spec.md § 3.1
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Literal, Protocol, runtime_checkable
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AgentDispatchResult:
23
+ """Result of dispatching a sub-agent through an IDE adapter.
24
+
25
+ `output` : the agent's stdout / response text
26
+ `exit_code` : 0 = success; non-zero = failure
27
+ `duration_ms` : wall-clock ms from dispatch to result
28
+ `agent_type` : the agent_name that was dispatched (echoed back for trace)
29
+ `error` : optional error message (None on success)
30
+ """
31
+
32
+ output: str
33
+ exit_code: int
34
+ duration_ms: int
35
+ agent_type: str
36
+ error: str | None = None
37
+
38
+
39
+ @runtime_checkable
40
+ class IDEAdapter(Protocol):
41
+ """Adapter for an AI IDE/agent platform.
42
+
43
+ Each platform (claude-code / cursor / vscode-codex / codex-cli / inline)
44
+ implements this Protocol. arcgentic skills + CLI invoke platform-agnostic
45
+ methods; the adapter translates to platform-specific tool calls.
46
+
47
+ Anti-contamination invariant (spec § 1.5): adapter methods MUST NOT inject
48
+ `tools=` or `tool_choice=` at the agent level. Those belong one layer down
49
+ in the LLM-client layer.
50
+ """
51
+
52
+ platform_name: str # "claude-code" / "cursor" / "vscode-codex" / "codex-cli" / "inline"
53
+
54
+ def dispatch_agent(
55
+ self,
56
+ agent_name: str,
57
+ prompt: str,
58
+ timeout_seconds: int = 600,
59
+ isolation: Literal["worktree"] | None = None,
60
+ ) -> AgentDispatchResult:
61
+ """Dispatch a sub-agent.
62
+
63
+ `agent_name` maps to a markdown file at agents/<name>.md.
64
+ `prompt` is the full self-contained brief; agent has zero session context.
65
+ Returns the agent's response wrapped in AgentDispatchResult.
66
+ """
67
+ ...
68
+
69
+ def invoke_skill(self, skill_name: str, args: str = "") -> str:
70
+ """Invoke an arcgentic skill in-process.
71
+
72
+ `skill_name` maps to a markdown file at skills/<name>/SKILL.md.
73
+ `args` is the optional argument string for the skill.
74
+ Returns the skill's textual output.
75
+ """
76
+ ...
77
+
78
+ def read_file(self, path: str) -> str: ...
79
+
80
+ def write_file(self, path: str, content: str) -> None: ...
81
+
82
+ def edit_file(self, path: str, old: str, new: str) -> None:
83
+ """Replace exactly one occurrence of `old` with `new` in file at `path`.
84
+
85
+ Match is exact-string (no regex). Implementations MUST raise an error if
86
+ `old` is not found, or if `old` appears more than once (ambiguous match).
87
+ For multi-occurrence replacement, callers should invoke `edit_file` multiple
88
+ times with disambiguating context, or use a higher-level batch API.
89
+ """
90
+ ...
91
+
92
+ def shell(self, command: str, timeout_seconds: int = 120) -> tuple[str, int]:
93
+ """Run a shell command; return (output, exit_code)."""
94
+ ...
95
+
96
+ def git_diff_staged(self) -> str: ...
97
+
98
+ def git_commit(self, message: str, files: list[str] | None = None) -> str:
99
+ """Commit staged changes; return the commit SHA.
100
+
101
+ If `files` is provided (non-None), stage those files first via `git add <file>...`
102
+ then commit. If `files` is None, commit whatever is currently in the index without
103
+ staging anything (caller has already staged).
104
+
105
+ Implementations must NOT use `--no-verify`, `--no-gpg-sign`, or `--amend` unless
106
+ the adapter explicitly documents otherwise.
107
+ """
108
+ ...
@@ -0,0 +1,223 @@
1
+ """Claude Code adapter — canonical reference IDEAdapter implementation.
2
+
3
+ When arcgentic skills/CLI run inside (or alongside) a Claude Code installation,
4
+ this adapter is what `detect_adapter()` selects.
5
+
6
+ Implementation strategy:
7
+ - `dispatch_agent` + `invoke_skill`: subprocess `claude -p "<wrapped prompt>"` —
8
+ each dispatch is a fresh, stateless Claude Code session (matches spec § 5
9
+ stateless-agent principle). NOTE: consumes Claude Code subscription tokens per
10
+ dispatch; documented cost trade-off.
11
+ - `read_file` / `write_file` / `edit_file`: Python filesystem APIs (no LLM
12
+ mediation needed; spec § 3.2 says these "wrap" Read/Write/Edit but at the
13
+ Python layer that means direct filesystem access).
14
+ - `shell`: subprocess.run with shell=True; timeout enforced.
15
+ - `git_diff_staged` / `git_commit`: subprocess git invocations.
16
+
17
+ Anti-contamination invariant (spec § 1.5) is preserved: dispatch_agent does NOT
18
+ inject tools= or tool_choice=; the prompt string is the only payload sent
19
+ to the spawned Claude Code session.
20
+
21
+ Spec reference: docs/plans/2026-05-13-arcgentic-v0.2.0-spec.md § 3.2
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import shutil
27
+ import subprocess
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Literal
31
+
32
+ from .base import AgentDispatchResult
33
+
34
+
35
+ class ClaudeCodeAdapter:
36
+ """Concrete IDEAdapter for Claude Code."""
37
+
38
+ platform_name = "claude-code"
39
+
40
+ def __init__(self, claude_binary: str = "claude") -> None:
41
+ """Create a ClaudeCodeAdapter.
42
+
43
+ `claude_binary`: name (or path) of the `claude` CLI executable. Default
44
+ "claude" assumes it's on PATH. Tests can inject a stub binary path.
45
+ """
46
+ self._claude_binary = claude_binary
47
+
48
+ # ── LLM-mediated methods ──────────────────────────────────────────────
49
+
50
+ def dispatch_agent(
51
+ self,
52
+ agent_name: str,
53
+ prompt: str,
54
+ timeout_seconds: int = 600,
55
+ isolation: Literal["worktree"] | None = None,
56
+ ) -> AgentDispatchResult:
57
+ """Spawn `claude -p "<wrapped>"` with the agent brief.
58
+
59
+ The wrapped prompt prefixes the brief with "Acting as the {agent_name}
60
+ agent:\\n\\n" to nudge the spawned Claude Code session into the right role.
61
+ Each dispatch is a fresh session; the spawned process inherits no context
62
+ from the calling session.
63
+
64
+ `isolation="worktree"` is currently a NO-OP for Claude Code (the spawned
65
+ session inherits the caller's working directory). Future versions may wrap
66
+ the call in `git worktree add` and clean up after; reserved for forward
67
+ compatibility.
68
+ """
69
+ if not self._has_claude_binary():
70
+ return AgentDispatchResult(
71
+ output="",
72
+ exit_code=127,
73
+ duration_ms=0,
74
+ agent_type=agent_name,
75
+ error=f"`{self._claude_binary}` not found on PATH",
76
+ )
77
+
78
+ wrapped = f"Acting as the {agent_name} agent:\n\n{prompt}"
79
+ start = time.monotonic()
80
+ try:
81
+ result = subprocess.run(
82
+ [self._claude_binary, "-p", wrapped],
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=timeout_seconds,
86
+ check=False,
87
+ )
88
+ duration_ms = int((time.monotonic() - start) * 1000)
89
+ error = result.stderr.strip() if result.returncode != 0 else None
90
+ return AgentDispatchResult(
91
+ output=result.stdout,
92
+ exit_code=result.returncode,
93
+ duration_ms=duration_ms,
94
+ agent_type=agent_name,
95
+ error=error,
96
+ )
97
+ except subprocess.TimeoutExpired:
98
+ duration_ms = int((time.monotonic() - start) * 1000)
99
+ return AgentDispatchResult(
100
+ output="",
101
+ exit_code=124, # POSIX timeout convention
102
+ duration_ms=duration_ms,
103
+ agent_type=agent_name,
104
+ error=f"timeout after {timeout_seconds}s",
105
+ )
106
+
107
+ def invoke_skill(self, skill_name: str, args: str = "") -> str:
108
+ """Invoke a skill by issuing the slash-command via `claude -p`.
109
+
110
+ Wraps as "Invoke /{skill_name} {args}" — Claude Code interprets this as a
111
+ skill invocation. Returns the stdout of the spawned session.
112
+ """
113
+ if not self._has_claude_binary():
114
+ raise RuntimeError(f"`{self._claude_binary}` not found on PATH")
115
+
116
+ prompt = f"Invoke /{skill_name} {args}".strip()
117
+ try:
118
+ result = subprocess.run(
119
+ [self._claude_binary, "-p", prompt],
120
+ capture_output=True,
121
+ text=True,
122
+ timeout=600,
123
+ check=False,
124
+ )
125
+ except subprocess.TimeoutExpired:
126
+ raise RuntimeError(f"/{skill_name} timed out after 600s") from None
127
+
128
+ if result.returncode != 0:
129
+ raise RuntimeError(
130
+ f"`/{skill_name}` exited {result.returncode}: {result.stderr.strip()}"
131
+ )
132
+ return result.stdout
133
+
134
+ # ── Filesystem (no LLM mediation) ─────────────────────────────────────
135
+
136
+ def read_file(self, path: str) -> str:
137
+ return Path(path).read_text(encoding="utf-8")
138
+
139
+ def write_file(self, path: str, content: str) -> None:
140
+ Path(path).write_text(content, encoding="utf-8")
141
+
142
+ def edit_file(self, path: str, old: str, new: str) -> None:
143
+ p = Path(path)
144
+ text = p.read_text(encoding="utf-8")
145
+ count = text.count(old)
146
+ if count == 0:
147
+ raise ValueError(f"edit_file: `old` not found in {path}")
148
+ if count > 1:
149
+ raise ValueError(
150
+ f"edit_file: `old` appears {count} times in {path} (ambiguous)"
151
+ )
152
+ p.write_text(text.replace(old, new, 1), encoding="utf-8")
153
+
154
+ # ── Shell + git ────────────────────────────────────────────────────────
155
+
156
+ def shell(self, command: str, timeout_seconds: int = 120) -> tuple[str, int]:
157
+ try:
158
+ result = subprocess.run(
159
+ command,
160
+ shell=True,
161
+ capture_output=True,
162
+ text=True,
163
+ timeout=timeout_seconds,
164
+ check=False,
165
+ )
166
+ return result.stdout, result.returncode
167
+ except subprocess.TimeoutExpired:
168
+ return "", 124
169
+
170
+ def git_diff_staged(self) -> str:
171
+ stdout, stderr, code = self._run_git(["diff", "--staged"])
172
+ if code != 0:
173
+ raise RuntimeError(f"git diff --staged failed (exit {code}): {stderr.strip()}")
174
+ return stdout
175
+
176
+ def git_commit(self, message: str, files: list[str] | None = None) -> str:
177
+ if files is not None:
178
+ # Stage explicitly listed files first.
179
+ for f in files:
180
+ _, stderr, code = self._run_git(["add", "--", f])
181
+ if code != 0:
182
+ raise RuntimeError(f"git add {f} failed (exit {code}): {stderr.strip()}")
183
+
184
+ # Commit using -m; avoid --no-verify / --no-gpg-sign / --amend per Protocol contract.
185
+ _, stderr, code = self._run_git(["commit", "-m", message])
186
+ if code != 0:
187
+ raise RuntimeError(f"git commit failed (exit {code}): {stderr.strip()}")
188
+
189
+ # Return the new commit SHA.
190
+ stdout, stderr, code = self._run_git(["rev-parse", "HEAD"])
191
+ if code != 0:
192
+ raise RuntimeError(f"git rev-parse HEAD failed (exit {code}): {stderr.strip()}")
193
+ return stdout.strip()
194
+
195
+ # ── Internals ──────────────────────────────────────────────────────────
196
+
197
+ @staticmethod
198
+ def _run_git(args: list[str], timeout_seconds: int = 60) -> tuple[str, str, int]:
199
+ """Run `git <args>` via list-form subprocess (no shell).
200
+
201
+ Returns (stdout, stderr, exit_code). Used by git-specific adapter methods
202
+ that need stderr surfaced for error diagnostics. shell() can't return
203
+ stderr without breaking the IDEAdapter Protocol's tuple[str, int] contract.
204
+ """
205
+ try:
206
+ result = subprocess.run(
207
+ ["git", *args],
208
+ capture_output=True,
209
+ text=True,
210
+ timeout=timeout_seconds,
211
+ check=False,
212
+ )
213
+ return result.stdout, result.stderr, result.returncode
214
+ except subprocess.TimeoutExpired:
215
+ return "", f"git {args[0] if args else ''} timed out", 124
216
+
217
+ def _has_claude_binary(self) -> bool:
218
+ return shutil.which(self._claude_binary) is not None
219
+
220
+ @staticmethod
221
+ def _shquote(s: str) -> str:
222
+ """Minimal shell-quoting (single-quote escape) for safe `shell=True` use."""
223
+ return "'" + s.replace("'", "'\\''") + "'"
@@ -0,0 +1,145 @@
1
+ """Codex CLI adapter (standalone).
2
+
3
+ When arcgentic skills/CLI run outside VSCode but with the standalone Codex CLI
4
+ installed, this adapter is what `detect_adapter()` selects. It is structurally
5
+ identical to VSCodeCodexAdapter — both subprocess to the same `codex` binary
6
+ with the same `agent dispatch` / `skill invoke` wire format. The distinction is
7
+ purely contextual (which environment triggered the adapter selection).
8
+
9
+ Wire format: `codex agent dispatch <agent_name> <prompt>` for agents,
10
+ `codex skill invoke <skill_name> <args>` for skills.
11
+
12
+ The separate class (and separate file) is intentional — adapter-per-platform
13
+ design per spec § 3.5. Future v0.3 may unify common Codex-based logic into a
14
+ shared base, but for P0 we keep them explicit and symmetric.
15
+
16
+ Filesystem / shell / git methods delegate to _local_env shared helpers.
17
+
18
+ Spec reference: docs/plans/2026-05-13-arcgentic-v0.2.0-spec.md § 3.5
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import shutil
24
+ import subprocess
25
+ import time
26
+ from typing import Literal
27
+
28
+ from . import _local_env
29
+ from .base import AgentDispatchResult
30
+
31
+
32
+ class CodexCLIAdapter:
33
+ """Concrete IDEAdapter for standalone Codex CLI."""
34
+
35
+ platform_name = "codex-cli"
36
+
37
+ def __init__(self, codex_binary: str = "codex") -> None:
38
+ """Create a CodexCLIAdapter.
39
+
40
+ `codex_binary`: name (or path) of the codex CLI executable.
41
+ Default "codex" assumes it's on PATH. Tests can inject a stub.
42
+ """
43
+ self._codex_binary = codex_binary
44
+
45
+ # ── LLM-mediated methods ──────────────────────────────────────────────
46
+
47
+ def dispatch_agent(
48
+ self,
49
+ agent_name: str,
50
+ prompt: str,
51
+ timeout_seconds: int = 600,
52
+ isolation: Literal["worktree"] | None = None,
53
+ ) -> AgentDispatchResult:
54
+ """Dispatch a sub-agent via `codex agent dispatch <name> <prompt>`.
55
+
56
+ If codex binary is not on PATH, returns exit_code=127 with an error.
57
+ `isolation` is accepted for API symmetry but has no effect.
58
+ """
59
+ if not shutil.which(self._codex_binary):
60
+ return AgentDispatchResult(
61
+ output="",
62
+ exit_code=127,
63
+ duration_ms=0,
64
+ agent_type=agent_name,
65
+ error=f"`{self._codex_binary}` not found on PATH",
66
+ )
67
+
68
+ start = time.monotonic()
69
+ try:
70
+ result = subprocess.run(
71
+ [self._codex_binary, "agent", "dispatch", agent_name, prompt],
72
+ capture_output=True,
73
+ text=True,
74
+ timeout=timeout_seconds,
75
+ check=False,
76
+ )
77
+ duration_ms = int((time.monotonic() - start) * 1000)
78
+ error = result.stderr.strip() if result.returncode != 0 else None
79
+ return AgentDispatchResult(
80
+ output=result.stdout,
81
+ exit_code=result.returncode,
82
+ duration_ms=duration_ms,
83
+ agent_type=agent_name,
84
+ error=error,
85
+ )
86
+ except subprocess.TimeoutExpired:
87
+ duration_ms = int((time.monotonic() - start) * 1000)
88
+ return AgentDispatchResult(
89
+ output="",
90
+ exit_code=124,
91
+ duration_ms=duration_ms,
92
+ agent_type=agent_name,
93
+ error=f"timeout after {timeout_seconds}s",
94
+ )
95
+
96
+ def invoke_skill(self, skill_name: str, args: str = "") -> str:
97
+ """Invoke a skill via `codex skill invoke <skill_name> [<args>]`.
98
+
99
+ Raises RuntimeError if codex is not on PATH, exits non-zero, or times out.
100
+ When `args` is empty, it is omitted from the subprocess argv entirely —
101
+ passing "" as a positional arg is ambiguous for the codex CLI.
102
+ """
103
+ if not shutil.which(self._codex_binary):
104
+ raise RuntimeError(f"`{self._codex_binary}` not found on PATH")
105
+
106
+ cmd = [self._codex_binary, "skill", "invoke", skill_name]
107
+ if args: # only append args if non-empty
108
+ cmd.append(args)
109
+
110
+ try:
111
+ result = subprocess.run(
112
+ cmd,
113
+ capture_output=True,
114
+ text=True,
115
+ timeout=600,
116
+ check=False,
117
+ )
118
+ except subprocess.TimeoutExpired:
119
+ raise RuntimeError(f"/{skill_name} timed out after 600s") from None
120
+
121
+ if result.returncode != 0:
122
+ raise RuntimeError(
123
+ f"`/{skill_name}` exited {result.returncode}: {result.stderr.strip()}"
124
+ )
125
+ return result.stdout
126
+
127
+ # ── Filesystem / shell / git via shared helpers ────────────────────────
128
+
129
+ def read_file(self, path: str) -> str:
130
+ return _local_env.read_file(path)
131
+
132
+ def write_file(self, path: str, content: str) -> None:
133
+ _local_env.write_file(path, content)
134
+
135
+ def edit_file(self, path: str, old: str, new: str) -> None:
136
+ _local_env.edit_file(path, old, new)
137
+
138
+ def shell(self, command: str, timeout_seconds: int = 120) -> tuple[str, int]:
139
+ return _local_env.shell(command, timeout_seconds)
140
+
141
+ def git_diff_staged(self) -> str:
142
+ return _local_env.git_diff_staged()
143
+
144
+ def git_commit(self, message: str, files: list[str] | None = None) -> str:
145
+ return _local_env.git_commit(message, files)