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

@@ -22,8 +22,8 @@ Gemini · OpenAI · Anthropic · Grok · Azure · Ollama · OpenRouter · DIAL
The new **[`clink`](docs/tools/clink.md)** (CLI + Link) tool connects external AI CLIs directly into your workflow:
- **Connect external CLIs** like [Gemini CLI](https://github.com/google-gemini/gemini-cli) and [Codex CLI](https://github.com/openai/codex) directly into your workflow
- **Codex Subagents** - Launch isolated Codex instances from _within_ Codex itself! Offload heavy tasks (code reviews, bug hunting) to fresh contexts while your main session's context window remains unpolluted. Each subagent returns only final results.
- **Connect external CLIs** like [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex CLI](https://github.com/openai/codex), and [Claude Code](https://www.anthropic.com/claude-code) directly into your workflow
- **CLI Subagents** - Launch isolated CLI instances from _within_ your current CLI! Claude Code can spawn Codex subagents, Codex can spawn Gemini CLI subagents, etc. Offload heavy tasks (code reviews, bug hunting) to fresh contexts while your main session's context window remains unpolluted. Each subagent returns only final results.
- **Context Isolation** - Run separate investigations without polluting your primary workspace
- **Role Specialization** - Spawn `planner`, `codereviewer`, or custom role agents with specialized system prompts
- **Full CLI Capabilities** - Web search, file inspection, MCP tool access, latest documentation lookups

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,

View File

@@ -0,0 +1,25 @@
{
"name": "claude",
"command": "claude",
"additional_args": [
"--permission-mode",
"acceptEdits",
"--model",
"sonnet"
],
"env": {},
"roles": {
"default": {
"prompt_path": "systemprompts/clink/default.txt",
"role_args": []
},
"planner": {
"prompt_path": "systemprompts/clink/default_planner.txt",
"role_args": []
},
"codereviewer": {
"prompt_path": "systemprompts/clink/default_codereviewer.txt",
"role_args": []
}
}
}

View File

@@ -23,6 +23,8 @@ The subagent:
- Returns **only the final security report** (not intermediate steps)
- Your main session stays **laser-focused** on debugging
**Works with any supported CLI**: Codex can spawn Codex / Claude Code / Gemini CLI subagents, or mix and match between different CLIs.
---
### Cross-CLI Orchestration
@@ -76,7 +78,7 @@ You can make your own custom roles in `conf/cli_clients/` or tweak any of the sh
## Tool Parameters
- `prompt`: Your question or task for the external CLI (required)
- `cli_name`: Which CLI to use - `gemini` (default), or add your own in `conf/cli_clients/`
- `cli_name`: Which CLI to use - `gemini` (default), `claude`, `codex`, or add your own in `conf/cli_clients/`
- `role`: Preset role - `default`, `planner`, `codereviewer` (default: `default`)
- `files`: Optional file paths for context (references only, CLI opens files itself)
- `images`: Optional image paths for visual context
@@ -159,6 +161,7 @@ Each preset points to role-specific prompts in `systemprompts/clink/`. Duplicate
Ensure the relevant CLI is installed and configured:
- [Claude Code](https://www.anthropic.com/claude-code)
- [Gemini CLI](https://github.com/google-gemini/gemini-cli)
- [Codex CLI](https://docs.sourcegraph.com/codex)

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"

View File

@@ -188,15 +188,29 @@ class CLinkTool(SimpleTool):
self._model_context = arguments.get("_model_context")
system_prompt_text = role_config.prompt_path.read_text(encoding="utf-8")
include_system_prompt = not self._use_external_system_prompt(client_config)
try:
prompt_text = await self._prepare_prompt_for_role(request, role_config)
prompt_text = await self._prepare_prompt_for_role(
request,
role_config,
system_prompt=system_prompt_text,
include_system_prompt=include_system_prompt,
)
except Exception as exc:
logger.exception("Failed to prepare clink prompt")
return [self._error_response(f"Failed to prepare prompt: {exc}")]
agent = create_agent(client_config)
try:
result = await agent.run(role=role_config, prompt=prompt_text, files=files, images=images)
result = await agent.run(
role=role_config,
prompt=prompt_text,
system_prompt=system_prompt_text if system_prompt_text.strip() else None,
files=files,
images=images,
)
except CLIAgentError as exc:
metadata = self._build_error_metadata(client_config, exc)
error_output = ToolOutput(
@@ -249,11 +263,25 @@ class CLinkTool(SimpleTool):
async def prepare_prompt(self, request) -> str:
client_config = self._registry.get_client(request.cli_name)
role_config = client_config.get_role(request.role)
return await self._prepare_prompt_for_role(request, role_config)
system_prompt_text = role_config.prompt_path.read_text(encoding="utf-8")
include_system_prompt = not self._use_external_system_prompt(client_config)
return await self._prepare_prompt_for_role(
request,
role_config,
system_prompt=system_prompt_text,
include_system_prompt=include_system_prompt,
)
async def _prepare_prompt_for_role(self, request: CLinkRequest, role: ResolvedCLIRole) -> str:
async def _prepare_prompt_for_role(
self,
request: CLinkRequest,
role: ResolvedCLIRole,
*,
system_prompt: str,
include_system_prompt: bool,
) -> str:
"""Load the role prompt and assemble the final user message."""
self._active_system_prompt = role.prompt_path.read_text(encoding="utf-8")
self._active_system_prompt = system_prompt
try:
user_content = self.handle_prompt_file_with_fallback(request).strip()
guidance = self._agent_capabilities_guidance()
@@ -261,7 +289,7 @@ class CLinkTool(SimpleTool):
sections: list[str] = []
active_prompt = self.get_system_prompt().strip()
if active_prompt:
if include_system_prompt and active_prompt:
sections.append(active_prompt)
sections.append(guidance)
sections.append("=== USER REQUEST ===\n" + user_content)
@@ -272,6 +300,10 @@ class CLinkTool(SimpleTool):
finally:
self._active_system_prompt = ""
def _use_external_system_prompt(self, client: ResolvedCLIClient) -> bool:
runner_name = (client.runner or client.name).lower()
return runner_name == "claude"
def _build_success_metadata(
self,
client: ResolvedCLIClient,