From 9ffca53ce5ca0ddb79642938cb1cfc4ef6325e2a Mon Sep 17 00:00:00 2001 From: Fahad Date: Wed, 8 Oct 2025 11:12:44 +0400 Subject: [PATCH] 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. --- README.md | 4 +- clink/agents/__init__.py | 4 +- clink/agents/base.py | 5 +- clink/agents/claude.py | 49 +++++++++++++ clink/constants.py | 6 ++ clink/models.py | 1 + clink/parsers/__init__.py | 2 + clink/parsers/claude.py | 111 ++++++++++++++++++++++++++++++ clink/registry.py | 3 + conf/cli_clients/claude.json | 25 +++++++ docs/tools/clink.md | 5 +- tests/test_clink_claude_agent.py | 111 ++++++++++++++++++++++++++++++ tests/test_clink_claude_parser.py | 45 ++++++++++++ tests/test_clink_integration.py | 38 ++++++++++ tools/clink.py | 44 ++++++++++-- 15 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 clink/agents/claude.py create mode 100644 clink/parsers/claude.py create mode 100644 conf/cli_clients/claude.json create mode 100644 tests/test_clink_claude_agent.py create mode 100644 tests/test_clink_claude_parser.py diff --git a/README.md b/README.md index 6d34658..75c29b2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/clink/agents/__init__.py b/clink/agents/__init__.py index 37c96eb..753f926 100644 --- a/clink/agents/__init__.py +++ b/clink/agents/__init__.py @@ -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) diff --git a/clink/agents/base.py b/clink/agents/base.py index 4d821c3..523b688 100644 --- a/clink/agents/base.py +++ b/clink/agents/base.py @@ -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) diff --git a/clink/agents/claude.py b/clink/agents/claude.py new file mode 100644 index 0000000..af3b3a9 --- /dev/null +++ b/clink/agents/claude.py @@ -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, + ) diff --git a/clink/constants.py b/clink/constants.py index bc760e3..3431e86 100644 --- a/clink/constants.py +++ b/clink/constants.py @@ -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", + ), } diff --git a/clink/models.py b/clink/models.py index 4999cf4..ec944fc 100644 --- a/clink/models.py +++ b/clink/models.py @@ -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 diff --git a/clink/parsers/__init__.py b/clink/parsers/__init__.py index df964b8..8642242 100644 --- a/clink/parsers/__init__.py +++ b/clink/parsers/__init__.py @@ -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, } diff --git a/clink/parsers/claude.py b/clink/parsers/claude.py new file mode 100644 index 0000000..37b276a --- /dev/null +++ b/clink/parsers/claude.py @@ -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 diff --git a/clink/registry.py b/clink/registry.py index 73b032b..53ccdb2 100644 --- a/clink/registry.py +++ b/clink/registry.py @@ -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, diff --git a/conf/cli_clients/claude.json b/conf/cli_clients/claude.json new file mode 100644 index 0000000..1bf8c76 --- /dev/null +++ b/conf/cli_clients/claude.json @@ -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": [] + } + } +} diff --git a/docs/tools/clink.md b/docs/tools/clink.md index 3c13ee9..67a577d 100644 --- a/docs/tools/clink.md +++ b/docs/tools/clink.md @@ -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) diff --git a/tests/test_clink_claude_agent.py b/tests/test_clink_claude_agent.py new file mode 100644 index 0000000..5ac8de5 --- /dev/null +++ b/tests/test_clink_claude_agent.py @@ -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) diff --git a/tests/test_clink_claude_parser.py b/tests/test_clink_claude_parser.py new file mode 100644 index 0000000..ac3b196 --- /dev/null +++ b/tests/test_clink_claude_parser.py @@ -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="") diff --git a/tests/test_clink_integration.py b/tests/test_clink_integration.py index a60e800..755f169 100644 --- a/tests/test_clink_integration.py +++ b/tests/test_clink_integration.py @@ -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" diff --git a/tools/clink.py b/tools/clink.py index 94d80b6..4e91a52 100644 --- a/tools/clink.py +++ b/tools/clink.py @@ -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,