feat! Claude Code as a CLI agent now supported. Mix and match: spawn claude code from within claude code, or claude code from within codex.

Stay in codex, plan review and fix complicated bugs, then ask it to spawn claude code and implement the plan.

This uses your current subscription instead of API tokens.
This commit is contained in:
Fahad
2025-10-08 11:12:44 +04:00
parent 23c9b35d52
commit 9ffca53ce5
15 changed files with 441 additions and 12 deletions

View File

@@ -0,0 +1,111 @@
import asyncio
import json
import shutil
from pathlib import Path
import pytest
from clink.agents.base import CLIAgentError
from clink.agents.claude import ClaudeAgent
from clink.models import ResolvedCLIClient, ResolvedCLIRole
class DummyProcess:
def __init__(self, *, stdout: bytes = b"", stderr: bytes = b"", returncode: int = 0):
self._stdout = stdout
self._stderr = stderr
self.returncode = returncode
self.stdin_data: bytes | None = None
async def communicate(self, input_data):
self.stdin_data = input_data
return self._stdout, self._stderr
@pytest.fixture()
def claude_agent():
prompt_path = Path("systemprompts/clink/default.txt").resolve()
role = ResolvedCLIRole(name="default", prompt_path=prompt_path, role_args=[])
client = ResolvedCLIClient(
name="claude",
executable=["claude"],
internal_args=["--print", "--output-format", "json"],
config_args=["--permission-mode", "acceptEdits"],
env={},
timeout_seconds=30,
parser="claude_json",
runner="claude",
roles={"default": role},
output_to_file=None,
working_dir=None,
)
return ClaudeAgent(client), role
async def _run_agent_with_process(monkeypatch, agent, role, process, *, system_prompt="System prompt"):
async def fake_create_subprocess_exec(*_args, **_kwargs):
return process
def fake_which(executable_name):
return f"/usr/bin/{executable_name}"
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
monkeypatch.setattr(shutil, "which", fake_which)
return await agent.run(
role=role,
prompt="Respond with 42",
system_prompt=system_prompt,
files=[],
images=[],
)
@pytest.mark.asyncio
async def test_claude_agent_injects_system_prompt(monkeypatch, claude_agent):
agent, role = claude_agent
stdout_payload = json.dumps(
{
"type": "result",
"subtype": "success",
"is_error": False,
"result": "42",
}
).encode()
process = DummyProcess(stdout=stdout_payload)
result = await _run_agent_with_process(monkeypatch, agent, role, process)
assert "--append-system-prompt" in result.sanitized_command
idx = result.sanitized_command.index("--append-system-prompt")
assert result.sanitized_command[idx + 1] == "System prompt"
assert process.stdin_data.decode().startswith("Respond with 42")
@pytest.mark.asyncio
async def test_claude_agent_recovers_error_payload(monkeypatch, claude_agent):
agent, role = claude_agent
stdout_payload = json.dumps(
{
"type": "result",
"subtype": "success",
"is_error": True,
"result": "API Error",
}
).encode()
process = DummyProcess(stdout=stdout_payload, returncode=2)
result = await _run_agent_with_process(monkeypatch, agent, role, process)
assert result.returncode == 2
assert result.parsed.content == "API Error"
assert result.parsed.metadata["is_error"] is True
@pytest.mark.asyncio
async def test_claude_agent_propagates_unparseable_output(monkeypatch, claude_agent):
agent, role = claude_agent
process = DummyProcess(stdout=b"", returncode=1)
with pytest.raises(CLIAgentError):
await _run_agent_with_process(monkeypatch, agent, role, process)

View File

@@ -0,0 +1,45 @@
"""Tests for the Claude CLI JSON parser."""
import pytest
from clink.parsers.base import ParserError
from clink.parsers.claude import ClaudeJSONParser
def _build_success_payload() -> str:
return (
'{"type":"result","subtype":"success","is_error":false,"duration_ms":1234,'
'"duration_api_ms":1200,"num_turns":1,"result":"42","session_id":"abc","total_cost_usd":0.12,'
'"usage":{"input_tokens":10,"output_tokens":5},'
'"modelUsage":{"claude-sonnet-4-5-20250929":{"inputTokens":10,"outputTokens":5}}}'
)
def test_claude_parser_extracts_result_and_metadata():
parser = ClaudeJSONParser()
stdout = _build_success_payload()
parsed = parser.parse(stdout=stdout, stderr="")
assert parsed.content == "42"
assert parsed.metadata["model_used"] == "claude-sonnet-4-5-20250929"
assert parsed.metadata["usage"]["output_tokens"] == 5
assert parsed.metadata["is_error"] is False
def test_claude_parser_falls_back_to_message():
parser = ClaudeJSONParser()
stdout = '{"type":"result","is_error":true,"message":"API error message"}'
parsed = parser.parse(stdout=stdout, stderr="warning")
assert parsed.content == "API error message"
assert parsed.metadata["is_error"] is True
assert parsed.metadata["stderr"] == "warning"
def test_claude_parser_requires_output():
parser = ClaudeJSONParser()
with pytest.raises(ParserError):
parser.parse(stdout="", stderr="")

View File

@@ -40,3 +40,41 @@ async def test_clink_gemini_single_digit_sum():
if status == "continuation_available":
offer = payload.get("continuation_offer") or {}
assert offer.get("continuation_id"), "Expected continuation metadata when status indicates availability"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_clink_claude_single_digit_sum():
if shutil.which("claude") is None:
pytest.skip("claude CLI is not installed or on PATH")
tool = CLinkTool()
prompt = "Respond with a single digit equal to the sum of 2 + 2. Output only that digit."
results = await tool.execute(
{
"prompt": prompt,
"cli_name": "claude",
"role": "default",
"files": [],
"images": [],
}
)
assert results, "clink tool returned no outputs"
payload = json.loads(results[0].text)
status = payload["status"]
if status == "error":
metadata = payload.get("metadata") or {}
reason = payload.get("content") or metadata.get("message") or "Claude CLI reported an error"
pytest.skip(f"Skipping Claude integration test: {reason}")
assert status in {"success", "continuation_available"}
content = payload.get("content", "").strip()
assert content == "4"
if status == "continuation_available":
offer = payload.get("continuation_offer") or {}
assert offer.get("continuation_id"), "Expected continuation metadata when status indicates availability"