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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
49
clink/agents/claude.py
Normal 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,
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
111
clink/parsers/claude.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user