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 +5 -0
- arcgentic/adapters/__init__.py +77 -0
- arcgentic/adapters/_local_env.py +125 -0
- arcgentic/adapters/base.py +108 -0
- arcgentic/adapters/claude_code.py +223 -0
- arcgentic/adapters/codex_cli.py +145 -0
- arcgentic/adapters/cursor.py +136 -0
- arcgentic/adapters/inline.py +101 -0
- arcgentic/adapters/vscode_codex.py +143 -0
- arcgentic/audit_check.py +422 -0
- arcgentic/cli.py +396 -0
- arcgentic/hooks/__init__.py +1 -0
- arcgentic/hooks/quality_gate_enforce.py +235 -0
- arcgentic/hooks/round_boundary_lesson_scan.py +79 -0
- arcgentic/skills_impl/__init__.py +1 -0
- arcgentic/skills_impl/codify_lesson.py +118 -0
- arcgentic/skills_impl/cross_session_handoff.py +157 -0
- arcgentic/skills_impl/execute_round.py +695 -0
- arcgentic/skills_impl/plan_round.py +338 -0
- arcgentic/skills_impl/track_refs.py +221 -0
- arcgentic/source_rules.py +134 -0
- arcgentic/utils/__init__.py +2 -0
- arcgentic/utils/pattern_detection.py +229 -0
- arcgentic-0.2.2a3.dist-info/METADATA +71 -0
- arcgentic-0.2.2a3.dist-info/RECORD +27 -0
- arcgentic-0.2.2a3.dist-info/WHEEL +4 -0
- arcgentic-0.2.2a3.dist-info/entry_points.txt +2 -0
arcgentic/__init__.py
ADDED
|
@@ -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)
|