Skip to content

Commit a2ccb48

Browse files
committed
feat!: Huge update - Link another CLI (such as gemini directly from with Claude Code / Codex). #208
Zen now allows you to define `roles` for an external CLI and delegate work to another CLI via the new `clink` tool (short for `CLI + Link`). Gemini, for instance, offers 1000 free requests a day - this means you can save on tokens and your weekly limits within Claude Code by delegating work to another entirely capable CLI agent! Define your own system prompts as `roles` and make another CLI do anything you'd like. Like the current tool you're connected to, the other CLI has complete access to your files and the current context. This also works incredibly well with Zen's `conversation continuity`.
1 parent 0d46976 commit a2ccb48

21 files changed

+1387
-0
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
[zen_web.webm](https://github.com/user-attachments/assets/851e3911-7f06-47c0-a4ab-a2601236697c)
44

5+
---
6+
7+
### Now with **`clink`** – Connect Your CLIs Together
8+
9+
Need Gemini (or another CLI agent) to join the conversation directly? The new [`clink`](docs/tools/clink.md) tool lets you **connect one CLI to another** ([gemini](https://github.com/google-gemini/gemini-cli) supported for now) so they can **plan, review, debate, and collaborate** inside the same context.
10+
Point `clink` at a CLI like Gemini with role presets (e.g., `planner`, `codereviewer`, `default`) and that agent will handle **web searches, file inspection, and documentation lookups** as a first-class participant in your workflow.
11+
12+
---
13+
514
<div align="center">
615

716
### Your CLI + Multiple Models = Your AI Dev Team
@@ -157,6 +166,7 @@ cd zen-mcp-server
157166
"Use zen to analyze this code for security issues with gemini pro"
158167
"Debug this error with o3 and then get flash to suggest optimizations"
159168
"Plan the migration strategy with zen, get consensus from multiple models"
169+
"clink with cli_name=\"gemini\" role=\"planner\" to draft a phased rollout plan"
160170
```
161171

162172
👉 **[Complete Setup Guide](docs/getting-started.md)** with detailed installation, configuration for Gemini / Codex / Qwen, and troubleshooting
@@ -171,6 +181,7 @@ Zen activates any provider that has credentials in your `.env`. See `.env.exampl
171181
> **Note:** Each tool comes with its own multi-step workflow, parameters, and descriptions that consume valuable context window space even when not in use. To optimize performance, some tools are disabled by default. See [Tool Configuration](#tool-configuration) below to enable them.
172182
173183
**Collaboration & Planning** *(Enabled by default)*
184+
- **[`clink`](docs/tools/clink.md)** - Bridge requests to external AI CLIs (Gemini planner, codereviewer, etc.)
174185
- **[`chat`](docs/tools/chat.md)** - Brainstorm ideas, get second opinions, validate approaches
175186
- **[`thinkdeep`](docs/tools/thinkdeep.md)** - Extended reasoning, edge case analysis, alternative perspectives
176187
- **[`planner`](docs/tools/planner.md)** - Break down complex projects into structured, actionable plans

clink/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Public helpers for clink components."""
2+
3+
from __future__ import annotations
4+
5+
from .registry import ClinkRegistry, get_registry
6+
7+
__all__ = ["ClinkRegistry", "get_registry"]

clink/agents/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Agent factory for clink CLI integrations."""
2+
3+
from __future__ import annotations
4+
5+
from clink.models import ResolvedCLIClient
6+
7+
from .base import AgentOutput, BaseCLIAgent, CLIAgentError
8+
from .gemini import GeminiAgent
9+
10+
_AGENTS: dict[str, type[BaseCLIAgent]] = {
11+
"gemini": GeminiAgent,
12+
}
13+
14+
15+
def create_agent(client: ResolvedCLIClient) -> BaseCLIAgent:
16+
agent_key = client.name.lower()
17+
agent_cls = _AGENTS.get(agent_key, BaseCLIAgent)
18+
return agent_cls(client)
19+
20+
21+
__all__ = [
22+
"AgentOutput",
23+
"BaseCLIAgent",
24+
"CLIAgentError",
25+
"create_agent",
26+
]

clink/agents/base.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""Execute configured CLI agents for the clink tool and parse output."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import logging
7+
import os
8+
import shlex
9+
import tempfile
10+
import time
11+
from collections.abc import Sequence
12+
from dataclasses import dataclass
13+
from pathlib import Path
14+
15+
from clink.constants import DEFAULT_STREAM_LIMIT
16+
from clink.models import ResolvedCLIClient, ResolvedCLIRole
17+
from clink.parsers import BaseParser, ParsedCLIResponse, ParserError, get_parser
18+
19+
logger = logging.getLogger("clink.agent")
20+
21+
22+
@dataclass
23+
class AgentOutput:
24+
"""Container returned by CLI agents after successful execution."""
25+
26+
parsed: ParsedCLIResponse
27+
sanitized_command: list[str]
28+
returncode: int
29+
stdout: str
30+
stderr: str
31+
duration_seconds: float
32+
parser_name: str
33+
output_file_content: str | None = None
34+
35+
36+
class CLIAgentError(RuntimeError):
37+
"""Raised when a CLI agent fails (non-zero exit, timeout, parse errors)."""
38+
39+
def __init__(self, message: str, *, returncode: int | None = None, stdout: str = "", stderr: str = "") -> None:
40+
super().__init__(message)
41+
self.returncode = returncode
42+
self.stdout = stdout
43+
self.stderr = stderr
44+
45+
46+
class BaseCLIAgent:
47+
"""Execute a configured CLI command and parse its output."""
48+
49+
def __init__(self, client: ResolvedCLIClient):
50+
self.client = client
51+
self._parser: BaseParser = get_parser(client.parser)
52+
self._logger = logging.getLogger(f"clink.runner.{client.name}")
53+
54+
async def run(
55+
self,
56+
*,
57+
role: ResolvedCLIRole,
58+
prompt: str,
59+
files: Sequence[str],
60+
images: Sequence[str],
61+
) -> AgentOutput:
62+
# Files and images are already embedded into the prompt by the tool; they are
63+
# accepted here only to keep parity with SimpleTool callers.
64+
_ = (files, images)
65+
# The runner simply executes the configured CLI command for the selected role.
66+
command = self._build_command(role=role)
67+
env = self._build_environment()
68+
sanitized_command = list(command)
69+
70+
cwd = str(self.client.working_dir) if self.client.working_dir else None
71+
limit = DEFAULT_STREAM_LIMIT
72+
73+
stdout_text = ""
74+
stderr_text = ""
75+
output_file_content: str | None = None
76+
start_time = time.monotonic()
77+
78+
output_file_path: Path | None = None
79+
command_with_output_flag = list(command)
80+
81+
if self.client.output_to_file:
82+
fd, tmp_path = tempfile.mkstemp(prefix="clink-", suffix=".json")
83+
os.close(fd)
84+
output_file_path = Path(tmp_path)
85+
flag_template = self.client.output_to_file.flag_template
86+
try:
87+
rendered_flag = flag_template.format(path=str(output_file_path))
88+
except KeyError as exc: # pragma: no cover - defensive
89+
raise CLIAgentError(f"Invalid output flag template '{flag_template}': missing placeholder {exc}")
90+
command_with_output_flag.extend(shlex.split(rendered_flag))
91+
sanitized_command = list(command_with_output_flag)
92+
93+
self._logger.debug("Executing CLI command: %s", " ".join(sanitized_command))
94+
if cwd:
95+
self._logger.debug("Working directory: %s", cwd)
96+
97+
try:
98+
process = await asyncio.create_subprocess_exec(
99+
*command_with_output_flag,
100+
stdin=asyncio.subprocess.PIPE,
101+
stdout=asyncio.subprocess.PIPE,
102+
stderr=asyncio.subprocess.PIPE,
103+
cwd=cwd,
104+
limit=limit,
105+
env=env,
106+
)
107+
except FileNotFoundError as exc:
108+
raise CLIAgentError(f"Executable not found for CLI '{self.client.name}': {exc}") from exc
109+
110+
try:
111+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
112+
process.communicate(prompt.encode("utf-8")),
113+
timeout=self.client.timeout_seconds,
114+
)
115+
except asyncio.TimeoutError as exc:
116+
process.kill()
117+
await process.communicate()
118+
raise CLIAgentError(
119+
f"CLI '{self.client.name}' timed out after {self.client.timeout_seconds} seconds",
120+
returncode=None,
121+
) from exc
122+
123+
duration = time.monotonic() - start_time
124+
return_code = process.returncode
125+
stdout_text = stdout_bytes.decode("utf-8", errors="replace")
126+
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
127+
128+
if output_file_path and output_file_path.exists():
129+
output_file_content = output_file_path.read_text(encoding="utf-8", errors="replace")
130+
if self.client.output_to_file and self.client.output_to_file.cleanup:
131+
try:
132+
output_file_path.unlink()
133+
except OSError: # pragma: no cover - best effort cleanup
134+
pass
135+
136+
if output_file_content and not stdout_text.strip():
137+
stdout_text = output_file_content
138+
139+
if return_code != 0:
140+
raise CLIAgentError(
141+
f"CLI '{self.client.name}' exited with status {return_code}",
142+
returncode=return_code,
143+
stdout=stdout_text,
144+
stderr=stderr_text,
145+
)
146+
147+
try:
148+
parsed = self._parser.parse(stdout_text, stderr_text)
149+
except ParserError as exc:
150+
raise CLIAgentError(
151+
f"Failed to parse output from CLI '{self.client.name}': {exc}",
152+
returncode=return_code,
153+
stdout=stdout_text,
154+
stderr=stderr_text,
155+
) from exc
156+
157+
return AgentOutput(
158+
parsed=parsed,
159+
sanitized_command=sanitized_command,
160+
returncode=return_code,
161+
stdout=stdout_text,
162+
stderr=stderr_text,
163+
duration_seconds=duration,
164+
parser_name=self._parser.name,
165+
output_file_content=output_file_content,
166+
)
167+
168+
def _build_command(self, *, role: ResolvedCLIRole) -> list[str]:
169+
base = list(self.client.executable)
170+
base.extend(self.client.internal_args)
171+
base.extend(self.client.config_args)
172+
base.extend(role.role_args)
173+
174+
return base
175+
176+
def _build_environment(self) -> dict[str, str]:
177+
env = os.environ.copy()
178+
env.update(self.client.env)
179+
return env

clink/agents/gemini.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Gemini-specific CLI agent hooks."""
2+
3+
from __future__ import annotations
4+
5+
from clink.models import ResolvedCLIClient
6+
7+
from .base import BaseCLIAgent
8+
9+
10+
class GeminiAgent(BaseCLIAgent):
11+
"""Placeholder for Gemini-specific behaviour."""
12+
13+
def __init__(self, client: ResolvedCLIClient):
14+
super().__init__(client)

clink/constants.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Internal defaults and constants for clink."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
from pathlib import Path
7+
8+
DEFAULT_TIMEOUT_SECONDS = 1800
9+
DEFAULT_STREAM_LIMIT = 10 * 1024 * 1024 # 10MB per stream
10+
11+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
12+
BUILTIN_PROMPTS_DIR = PROJECT_ROOT / "systemprompts" / "clink"
13+
CONFIG_DIR = PROJECT_ROOT / "conf" / "cli_clients"
14+
USER_CONFIG_DIR = Path.home() / ".zen" / "cli_clients"
15+
16+
17+
@dataclass(frozen=True)
18+
class CLIInternalDefaults:
19+
"""Internal defaults applied to a CLI client during registry load."""
20+
21+
parser: str
22+
additional_args: list[str] = field(default_factory=list)
23+
env: dict[str, str] = field(default_factory=dict)
24+
default_role_prompt: str | None = None
25+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS
26+
runner: str | None = None
27+
28+
29+
INTERNAL_DEFAULTS: dict[str, CLIInternalDefaults] = {
30+
"gemini": CLIInternalDefaults(
31+
parser="gemini_json",
32+
additional_args=["-o", "json"],
33+
default_role_prompt="systemprompts/clink/gemini_default.txt",
34+
runner="gemini",
35+
),
36+
}

clink/models.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Pydantic models for clink configuration and runtime structures."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import Any
7+
8+
from pydantic import BaseModel, Field, PositiveInt, field_validator
9+
10+
11+
class OutputCaptureConfig(BaseModel):
12+
"""Optional configuration for CLIs that write output to disk."""
13+
14+
flag_template: str = Field(..., description="Template used to inject the output path, e.g. '--output {path}'.")
15+
cleanup: bool = Field(
16+
default=True,
17+
description="Whether the temporary file should be removed after reading.",
18+
)
19+
20+
21+
class CLIRoleConfig(BaseModel):
22+
"""Role-specific configuration loaded from JSON manifests."""
23+
24+
prompt_path: str | None = Field(
25+
default=None,
26+
description="Path to the prompt file that seeds this role.",
27+
)
28+
role_args: list[str] = Field(default_factory=list)
29+
description: str | None = Field(default=None)
30+
31+
@field_validator("role_args", mode="before")
32+
@classmethod
33+
def _ensure_list(cls, value: Any) -> list[str]:
34+
if value is None:
35+
return []
36+
if isinstance(value, list):
37+
return [str(item) for item in value]
38+
if isinstance(value, str):
39+
return [value]
40+
raise TypeError("role_args must be a list of strings or a single string")
41+
42+
43+
class CLIClientConfig(BaseModel):
44+
"""Raw CLI client configuration before internal defaults are applied."""
45+
46+
name: str
47+
command: str | None = None
48+
working_dir: str | None = None
49+
additional_args: list[str] = Field(default_factory=list)
50+
env: dict[str, str] = Field(default_factory=dict)
51+
timeout_seconds: PositiveInt | None = Field(default=None)
52+
roles: dict[str, CLIRoleConfig] = Field(default_factory=dict)
53+
output_to_file: OutputCaptureConfig | None = None
54+
55+
@field_validator("additional_args", mode="before")
56+
@classmethod
57+
def _ensure_args_list(cls, value: Any) -> list[str]:
58+
if value is None:
59+
return []
60+
if isinstance(value, list):
61+
return [str(item) for item in value]
62+
if isinstance(value, str):
63+
return [value]
64+
raise TypeError("additional_args must be a list of strings or a single string")
65+
66+
67+
class ResolvedCLIRole(BaseModel):
68+
"""Runtime representation of a CLI role with resolved prompt path."""
69+
70+
name: str
71+
prompt_path: Path
72+
role_args: list[str] = Field(default_factory=list)
73+
description: str | None = None
74+
75+
76+
class ResolvedCLIClient(BaseModel):
77+
"""Runtime configuration after merging defaults and validating paths."""
78+
79+
name: str
80+
executable: list[str]
81+
working_dir: Path | None
82+
internal_args: list[str] = Field(default_factory=list)
83+
config_args: list[str] = Field(default_factory=list)
84+
env: dict[str, str] = Field(default_factory=dict)
85+
timeout_seconds: int
86+
parser: str
87+
roles: dict[str, ResolvedCLIRole]
88+
output_to_file: OutputCaptureConfig | None = None
89+
90+
def list_roles(self) -> list[str]:
91+
return list(self.roles.keys())
92+
93+
def get_role(self, role_name: str | None) -> ResolvedCLIRole:
94+
key = role_name or "default"
95+
if key not in self.roles:
96+
available = ", ".join(sorted(self.roles.keys()))
97+
raise KeyError(f"Role '{role_name}' not configured for CLI '{self.name}'. Available roles: {available}")
98+
return self.roles[key]

0 commit comments

Comments
 (0)