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

@@ -5,17 +5,19 @@ from __future__ import annotations
from clink.models import ResolvedCLIClient
from .base import AgentOutput, BaseCLIAgent, CLIAgentError
from .claude import ClaudeAgent
from .codex import CodexAgent
from .gemini import GeminiAgent
_AGENTS: dict[str, type[BaseCLIAgent]] = {
"gemini": GeminiAgent,
"codex": CodexAgent,
"claude": ClaudeAgent,
}
def create_agent(client: ResolvedCLIClient) -> BaseCLIAgent:
agent_key = client.name.lower()
agent_key = (client.runner or client.name).lower()
agent_cls = _AGENTS.get(agent_key, BaseCLIAgent)
return agent_cls(client)

View File

@@ -57,6 +57,7 @@ class BaseCLIAgent:
*,
role: ResolvedCLIRole,
prompt: str,
system_prompt: str | None = None,
files: Sequence[str],
images: Sequence[str],
) -> AgentOutput:
@@ -64,7 +65,7 @@ class BaseCLIAgent:
# accepted here only to keep parity with SimpleTool callers.
_ = (files, images)
# The runner simply executes the configured CLI command for the selected role.
command = self._build_command(role=role)
command = self._build_command(role=role, system_prompt=system_prompt)
env = self._build_environment()
# Resolve executable path for cross-platform compatibility (especially Windows)
@@ -189,7 +190,7 @@ class BaseCLIAgent:
output_file_content=output_file_content,
)
def _build_command(self, *, role: ResolvedCLIRole) -> list[str]:
def _build_command(self, *, role: ResolvedCLIRole, system_prompt: str | None) -> list[str]:
base = list(self.client.executable)
base.extend(self.client.internal_args)
base.extend(self.client.config_args)

49
clink/agents/claude.py Normal file
View File

@@ -0,0 +1,49 @@
"""Claude-specific CLI agent hooks."""
from __future__ import annotations
from clink.models import ResolvedCLIRole
from clink.parsers.base import ParserError
from .base import AgentOutput, BaseCLIAgent
class ClaudeAgent(BaseCLIAgent):
"""Claude CLI agent with system-prompt injection support."""
def _build_command(self, *, role: ResolvedCLIRole, system_prompt: str | None) -> list[str]:
command = list(self.client.executable)
command.extend(self.client.internal_args)
command.extend(self.client.config_args)
if system_prompt and "--append-system-prompt" not in self.client.config_args:
command.extend(["--append-system-prompt", system_prompt])
command.extend(role.role_args)
return command
def _recover_from_error(
self,
*,
returncode: int,
stdout: str,
stderr: str,
sanitized_command: list[str],
duration_seconds: float,
output_file_content: str | None,
) -> AgentOutput | None:
try:
parsed = self._parser.parse(stdout, stderr)
except ParserError:
return None
return AgentOutput(
parsed=parsed,
sanitized_command=sanitized_command,
returncode=returncode,
stdout=stdout,
stderr=stderr,
duration_seconds=duration_seconds,
parser_name=self._parser.name,
output_file_content=output_file_content,
)

View File

@@ -39,4 +39,10 @@ INTERNAL_DEFAULTS: dict[str, CLIInternalDefaults] = {
default_role_prompt="systemprompts/clink/default.txt",
runner="codex",
),
"claude": CLIInternalDefaults(
parser="claude_json",
additional_args=["--print", "--output-format", "json"],
default_role_prompt="systemprompts/clink/default.txt",
runner="claude",
),
}

View File

@@ -84,6 +84,7 @@ class ResolvedCLIClient(BaseModel):
env: dict[str, str] = Field(default_factory=dict)
timeout_seconds: int
parser: str
runner: str | None = None
roles: dict[str, ResolvedCLIRole]
output_to_file: OutputCaptureConfig | None = None

View File

@@ -3,12 +3,14 @@
from __future__ import annotations
from .base import BaseParser, ParsedCLIResponse, ParserError
from .claude import ClaudeJSONParser
from .codex import CodexJSONLParser
from .gemini import GeminiJSONParser
_PARSER_CLASSES: dict[str, type[BaseParser]] = {
CodexJSONLParser.name: CodexJSONLParser,
GeminiJSONParser.name: GeminiJSONParser,
ClaudeJSONParser.name: ClaudeJSONParser,
}

111
clink/parsers/claude.py Normal file
View File

@@ -0,0 +1,111 @@
"""Parser for Claude CLI JSON output."""
from __future__ import annotations
import json
from typing import Any
from .base import BaseParser, ParsedCLIResponse, ParserError
class ClaudeJSONParser(BaseParser):
"""Parse stdout produced by `claude --output-format json`."""
name = "claude_json"
def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
if not stdout.strip():
raise ParserError("Claude CLI returned empty stdout while JSON output was expected")
try:
payload: dict[str, Any] = json.loads(stdout)
except json.JSONDecodeError as exc: # pragma: no cover - defensive logging
raise ParserError(f"Failed to decode Claude CLI JSON output: {exc}") from exc
metadata = self._build_metadata(payload, stderr)
result = payload.get("result")
content: str = ""
if isinstance(result, str):
content = result.strip()
elif isinstance(result, list):
# Some CLI flows may emit a list of strings; join them conservatively.
joined = [part.strip() for part in result if isinstance(part, str) and part.strip()]
content = "\n".join(joined)
if content:
return ParsedCLIResponse(content=content, metadata=metadata)
message = self._extract_message(payload)
if message:
return ParsedCLIResponse(content=message, metadata=metadata)
stderr_text = stderr.strip()
if stderr_text:
metadata.setdefault("stderr", stderr_text)
return ParsedCLIResponse(
content="Claude CLI returned no textual result. Raw stderr was preserved for troubleshooting.",
metadata=metadata,
)
raise ParserError("Claude CLI response did not contain a textual result")
def _build_metadata(self, payload: dict[str, Any], stderr: str) -> dict[str, Any]:
metadata: dict[str, Any] = {
"raw": payload,
"is_error": bool(payload.get("is_error")),
}
type_field = payload.get("type")
if isinstance(type_field, str):
metadata["type"] = type_field
subtype_field = payload.get("subtype")
if isinstance(subtype_field, str):
metadata["subtype"] = subtype_field
duration_ms = payload.get("duration_ms")
if isinstance(duration_ms, (int, float)):
metadata["duration_ms"] = duration_ms
api_duration = payload.get("duration_api_ms")
if isinstance(api_duration, (int, float)):
metadata["duration_api_ms"] = api_duration
usage = payload.get("usage")
if isinstance(usage, dict):
metadata["usage"] = usage
model_usage = payload.get("modelUsage")
if isinstance(model_usage, dict) and model_usage:
metadata["model_usage"] = model_usage
first_model = next(iter(model_usage.keys()))
metadata["model_used"] = first_model
permission_denials = payload.get("permission_denials")
if isinstance(permission_denials, list) and permission_denials:
metadata["permission_denials"] = permission_denials
session_id = payload.get("session_id")
if isinstance(session_id, str) and session_id:
metadata["session_id"] = session_id
uuid_field = payload.get("uuid")
if isinstance(uuid_field, str) and uuid_field:
metadata["uuid"] = uuid_field
stderr_text = stderr.strip()
if stderr_text:
metadata.setdefault("stderr", stderr_text)
return metadata
def _extract_message(self, payload: dict[str, Any]) -> str | None:
message = payload.get("message")
if isinstance(message, str) and message.strip():
return message.strip()
error_field = payload.get("error")
if isinstance(error_field, dict):
error_message = error_field.get("message")
if isinstance(error_message, str) and error_message.strip():
return error_message.strip()
return None

View File

@@ -149,6 +149,8 @@ class ClinkRegistry:
f"CLI '{raw.name}' must define a parser either in configuration or internal defaults"
)
runner_name = internal_defaults.runner if internal_defaults else None
env = self._merge_env(raw, internal_defaults)
working_dir = self._resolve_optional_path(raw.working_dir, source_path.parent)
roles = self._resolve_roles(raw, internal_defaults, source_path)
@@ -163,6 +165,7 @@ class ClinkRegistry:
env=env,
timeout_seconds=int(timeout_seconds),
parser=parser_name,
runner=runner_name,
roles=roles,
output_to_file=output_to_file,
working_dir=working_dir,