feat!: Huge update - Link another CLI (such as gemini directly from with Claude Code / Codex). https://github.com/BeehiveInnovations/zen-mcp-server/issues/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`.
This commit is contained in:
7
clink/__init__.py
Normal file
7
clink/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Public helpers for clink components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .registry import ClinkRegistry, get_registry
|
||||
|
||||
__all__ = ["ClinkRegistry", "get_registry"]
|
||||
26
clink/agents/__init__.py
Normal file
26
clink/agents/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Agent factory for clink CLI integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from clink.models import ResolvedCLIClient
|
||||
|
||||
from .base import AgentOutput, BaseCLIAgent, CLIAgentError
|
||||
from .gemini import GeminiAgent
|
||||
|
||||
_AGENTS: dict[str, type[BaseCLIAgent]] = {
|
||||
"gemini": GeminiAgent,
|
||||
}
|
||||
|
||||
|
||||
def create_agent(client: ResolvedCLIClient) -> BaseCLIAgent:
|
||||
agent_key = client.name.lower()
|
||||
agent_cls = _AGENTS.get(agent_key, BaseCLIAgent)
|
||||
return agent_cls(client)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentOutput",
|
||||
"BaseCLIAgent",
|
||||
"CLIAgentError",
|
||||
"create_agent",
|
||||
]
|
||||
179
clink/agents/base.py
Normal file
179
clink/agents/base.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Execute configured CLI agents for the clink tool and parse output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from clink.constants import DEFAULT_STREAM_LIMIT
|
||||
from clink.models import ResolvedCLIClient, ResolvedCLIRole
|
||||
from clink.parsers import BaseParser, ParsedCLIResponse, ParserError, get_parser
|
||||
|
||||
logger = logging.getLogger("clink.agent")
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentOutput:
|
||||
"""Container returned by CLI agents after successful execution."""
|
||||
|
||||
parsed: ParsedCLIResponse
|
||||
sanitized_command: list[str]
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_seconds: float
|
||||
parser_name: str
|
||||
output_file_content: str | None = None
|
||||
|
||||
|
||||
class CLIAgentError(RuntimeError):
|
||||
"""Raised when a CLI agent fails (non-zero exit, timeout, parse errors)."""
|
||||
|
||||
def __init__(self, message: str, *, returncode: int | None = None, stdout: str = "", stderr: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
class BaseCLIAgent:
|
||||
"""Execute a configured CLI command and parse its output."""
|
||||
|
||||
def __init__(self, client: ResolvedCLIClient):
|
||||
self.client = client
|
||||
self._parser: BaseParser = get_parser(client.parser)
|
||||
self._logger = logging.getLogger(f"clink.runner.{client.name}")
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
role: ResolvedCLIRole,
|
||||
prompt: str,
|
||||
files: Sequence[str],
|
||||
images: Sequence[str],
|
||||
) -> AgentOutput:
|
||||
# Files and images are already embedded into the prompt by the tool; they are
|
||||
# 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)
|
||||
env = self._build_environment()
|
||||
sanitized_command = list(command)
|
||||
|
||||
cwd = str(self.client.working_dir) if self.client.working_dir else None
|
||||
limit = DEFAULT_STREAM_LIMIT
|
||||
|
||||
stdout_text = ""
|
||||
stderr_text = ""
|
||||
output_file_content: str | None = None
|
||||
start_time = time.monotonic()
|
||||
|
||||
output_file_path: Path | None = None
|
||||
command_with_output_flag = list(command)
|
||||
|
||||
if self.client.output_to_file:
|
||||
fd, tmp_path = tempfile.mkstemp(prefix="clink-", suffix=".json")
|
||||
os.close(fd)
|
||||
output_file_path = Path(tmp_path)
|
||||
flag_template = self.client.output_to_file.flag_template
|
||||
try:
|
||||
rendered_flag = flag_template.format(path=str(output_file_path))
|
||||
except KeyError as exc: # pragma: no cover - defensive
|
||||
raise CLIAgentError(f"Invalid output flag template '{flag_template}': missing placeholder {exc}")
|
||||
command_with_output_flag.extend(shlex.split(rendered_flag))
|
||||
sanitized_command = list(command_with_output_flag)
|
||||
|
||||
self._logger.debug("Executing CLI command: %s", " ".join(sanitized_command))
|
||||
if cwd:
|
||||
self._logger.debug("Working directory: %s", cwd)
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command_with_output_flag,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
limit=limit,
|
||||
env=env,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise CLIAgentError(f"Executable not found for CLI '{self.client.name}': {exc}") from exc
|
||||
|
||||
try:
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||
process.communicate(prompt.encode("utf-8")),
|
||||
timeout=self.client.timeout_seconds,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
process.kill()
|
||||
await process.communicate()
|
||||
raise CLIAgentError(
|
||||
f"CLI '{self.client.name}' timed out after {self.client.timeout_seconds} seconds",
|
||||
returncode=None,
|
||||
) from exc
|
||||
|
||||
duration = time.monotonic() - start_time
|
||||
return_code = process.returncode
|
||||
stdout_text = stdout_bytes.decode("utf-8", errors="replace")
|
||||
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
if output_file_path and output_file_path.exists():
|
||||
output_file_content = output_file_path.read_text(encoding="utf-8", errors="replace")
|
||||
if self.client.output_to_file and self.client.output_to_file.cleanup:
|
||||
try:
|
||||
output_file_path.unlink()
|
||||
except OSError: # pragma: no cover - best effort cleanup
|
||||
pass
|
||||
|
||||
if output_file_content and not stdout_text.strip():
|
||||
stdout_text = output_file_content
|
||||
|
||||
if return_code != 0:
|
||||
raise CLIAgentError(
|
||||
f"CLI '{self.client.name}' exited with status {return_code}",
|
||||
returncode=return_code,
|
||||
stdout=stdout_text,
|
||||
stderr=stderr_text,
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = self._parser.parse(stdout_text, stderr_text)
|
||||
except ParserError as exc:
|
||||
raise CLIAgentError(
|
||||
f"Failed to parse output from CLI '{self.client.name}': {exc}",
|
||||
returncode=return_code,
|
||||
stdout=stdout_text,
|
||||
stderr=stderr_text,
|
||||
) from exc
|
||||
|
||||
return AgentOutput(
|
||||
parsed=parsed,
|
||||
sanitized_command=sanitized_command,
|
||||
returncode=return_code,
|
||||
stdout=stdout_text,
|
||||
stderr=stderr_text,
|
||||
duration_seconds=duration,
|
||||
parser_name=self._parser.name,
|
||||
output_file_content=output_file_content,
|
||||
)
|
||||
|
||||
def _build_command(self, *, role: ResolvedCLIRole) -> list[str]:
|
||||
base = list(self.client.executable)
|
||||
base.extend(self.client.internal_args)
|
||||
base.extend(self.client.config_args)
|
||||
base.extend(role.role_args)
|
||||
|
||||
return base
|
||||
|
||||
def _build_environment(self) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.update(self.client.env)
|
||||
return env
|
||||
14
clink/agents/gemini.py
Normal file
14
clink/agents/gemini.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Gemini-specific CLI agent hooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from clink.models import ResolvedCLIClient
|
||||
|
||||
from .base import BaseCLIAgent
|
||||
|
||||
|
||||
class GeminiAgent(BaseCLIAgent):
|
||||
"""Placeholder for Gemini-specific behaviour."""
|
||||
|
||||
def __init__(self, client: ResolvedCLIClient):
|
||||
super().__init__(client)
|
||||
36
clink/constants.py
Normal file
36
clink/constants.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Internal defaults and constants for clink."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 1800
|
||||
DEFAULT_STREAM_LIMIT = 10 * 1024 * 1024 # 10MB per stream
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
BUILTIN_PROMPTS_DIR = PROJECT_ROOT / "systemprompts" / "clink"
|
||||
CONFIG_DIR = PROJECT_ROOT / "conf" / "cli_clients"
|
||||
USER_CONFIG_DIR = Path.home() / ".zen" / "cli_clients"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CLIInternalDefaults:
|
||||
"""Internal defaults applied to a CLI client during registry load."""
|
||||
|
||||
parser: str
|
||||
additional_args: list[str] = field(default_factory=list)
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
default_role_prompt: str | None = None
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS
|
||||
runner: str | None = None
|
||||
|
||||
|
||||
INTERNAL_DEFAULTS: dict[str, CLIInternalDefaults] = {
|
||||
"gemini": CLIInternalDefaults(
|
||||
parser="gemini_json",
|
||||
additional_args=["-o", "json"],
|
||||
default_role_prompt="systemprompts/clink/gemini_default.txt",
|
||||
runner="gemini",
|
||||
),
|
||||
}
|
||||
98
clink/models.py
Normal file
98
clink/models.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Pydantic models for clink configuration and runtime structures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, PositiveInt, field_validator
|
||||
|
||||
|
||||
class OutputCaptureConfig(BaseModel):
|
||||
"""Optional configuration for CLIs that write output to disk."""
|
||||
|
||||
flag_template: str = Field(..., description="Template used to inject the output path, e.g. '--output {path}'.")
|
||||
cleanup: bool = Field(
|
||||
default=True,
|
||||
description="Whether the temporary file should be removed after reading.",
|
||||
)
|
||||
|
||||
|
||||
class CLIRoleConfig(BaseModel):
|
||||
"""Role-specific configuration loaded from JSON manifests."""
|
||||
|
||||
prompt_path: str | None = Field(
|
||||
default=None,
|
||||
description="Path to the prompt file that seeds this role.",
|
||||
)
|
||||
role_args: list[str] = Field(default_factory=list)
|
||||
description: str | None = Field(default=None)
|
||||
|
||||
@field_validator("role_args", mode="before")
|
||||
@classmethod
|
||||
def _ensure_list(cls, value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
raise TypeError("role_args must be a list of strings or a single string")
|
||||
|
||||
|
||||
class CLIClientConfig(BaseModel):
|
||||
"""Raw CLI client configuration before internal defaults are applied."""
|
||||
|
||||
name: str
|
||||
command: str | None = None
|
||||
working_dir: str | None = None
|
||||
additional_args: list[str] = Field(default_factory=list)
|
||||
env: dict[str, str] = Field(default_factory=dict)
|
||||
timeout_seconds: PositiveInt | None = Field(default=None)
|
||||
roles: dict[str, CLIRoleConfig] = Field(default_factory=dict)
|
||||
output_to_file: OutputCaptureConfig | None = None
|
||||
|
||||
@field_validator("additional_args", mode="before")
|
||||
@classmethod
|
||||
def _ensure_args_list(cls, value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
raise TypeError("additional_args must be a list of strings or a single string")
|
||||
|
||||
|
||||
class ResolvedCLIRole(BaseModel):
|
||||
"""Runtime representation of a CLI role with resolved prompt path."""
|
||||
|
||||
name: str
|
||||
prompt_path: Path
|
||||
role_args: list[str] = Field(default_factory=list)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ResolvedCLIClient(BaseModel):
|
||||
"""Runtime configuration after merging defaults and validating paths."""
|
||||
|
||||
name: str
|
||||
executable: list[str]
|
||||
working_dir: Path | None
|
||||
internal_args: list[str] = Field(default_factory=list)
|
||||
config_args: list[str] = Field(default_factory=list)
|
||||
env: dict[str, str] = Field(default_factory=dict)
|
||||
timeout_seconds: int
|
||||
parser: str
|
||||
roles: dict[str, ResolvedCLIRole]
|
||||
output_to_file: OutputCaptureConfig | None = None
|
||||
|
||||
def list_roles(self) -> list[str]:
|
||||
return list(self.roles.keys())
|
||||
|
||||
def get_role(self, role_name: str | None) -> ResolvedCLIRole:
|
||||
key = role_name or "default"
|
||||
if key not in self.roles:
|
||||
available = ", ".join(sorted(self.roles.keys()))
|
||||
raise KeyError(f"Role '{role_name}' not configured for CLI '{self.name}'. Available roles: {available}")
|
||||
return self.roles[key]
|
||||
26
clink/parsers/__init__.py
Normal file
26
clink/parsers/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Parser registry for clink."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import BaseParser, ParsedCLIResponse, ParserError
|
||||
from .gemini import GeminiJSONParser
|
||||
|
||||
_PARSER_CLASSES: dict[str, type[BaseParser]] = {
|
||||
GeminiJSONParser.name: GeminiJSONParser,
|
||||
}
|
||||
|
||||
|
||||
def get_parser(name: str) -> BaseParser:
|
||||
normalized = (name or "").lower()
|
||||
if normalized not in _PARSER_CLASSES:
|
||||
raise ParserError(f"No parser registered for '{name}'")
|
||||
parser_cls = _PARSER_CLASSES[normalized]
|
||||
return parser_cls()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseParser",
|
||||
"ParsedCLIResponse",
|
||||
"ParserError",
|
||||
"get_parser",
|
||||
]
|
||||
27
clink/parsers/base.py
Normal file
27
clink/parsers/base.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Parser interfaces for clink runner outputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedCLIResponse:
|
||||
"""Result of parsing CLI stdout/stderr."""
|
||||
|
||||
content: str
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
class ParserError(RuntimeError):
|
||||
"""Raised when CLI output cannot be parsed into a structured response."""
|
||||
|
||||
|
||||
class BaseParser:
|
||||
"""Base interface for CLI output parsers."""
|
||||
|
||||
name: str = "base"
|
||||
|
||||
def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
|
||||
raise NotImplementedError("Parsers must implement parse()")
|
||||
49
clink/parsers/gemini.py
Normal file
49
clink/parsers/gemini.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Parser for Gemini CLI JSON output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseParser, ParsedCLIResponse, ParserError
|
||||
|
||||
|
||||
class GeminiJSONParser(BaseParser):
|
||||
"""Parse stdout produced by `gemini -o json`."""
|
||||
|
||||
name = "gemini_json"
|
||||
|
||||
def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
|
||||
if not stdout.strip():
|
||||
raise ParserError("Gemini 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 Gemini CLI JSON output: {exc}") from exc
|
||||
|
||||
response = payload.get("response")
|
||||
if not isinstance(response, str) or not response.strip():
|
||||
raise ParserError("Gemini CLI response is missing a textual 'response' field")
|
||||
|
||||
metadata: dict[str, Any] = {"raw": payload}
|
||||
|
||||
stats = payload.get("stats")
|
||||
if isinstance(stats, dict):
|
||||
metadata["stats"] = stats
|
||||
models = stats.get("models")
|
||||
if isinstance(models, dict) and models:
|
||||
model_name = next(iter(models.keys()))
|
||||
metadata["model_used"] = model_name
|
||||
model_stats = models.get(model_name) or {}
|
||||
tokens = model_stats.get("tokens")
|
||||
if isinstance(tokens, dict):
|
||||
metadata["token_usage"] = tokens
|
||||
api_stats = model_stats.get("api")
|
||||
if isinstance(api_stats, dict):
|
||||
metadata["latency_ms"] = api_stats.get("totalLatencyMs")
|
||||
|
||||
if stderr and stderr.strip():
|
||||
metadata["stderr"] = stderr.strip()
|
||||
|
||||
return ParsedCLIResponse(content=response.strip(), metadata=metadata)
|
||||
252
clink/registry.py
Normal file
252
clink/registry.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Configuration registry for clink CLI integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
from clink.constants import (
|
||||
CONFIG_DIR,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
INTERNAL_DEFAULTS,
|
||||
PROJECT_ROOT,
|
||||
USER_CONFIG_DIR,
|
||||
CLIInternalDefaults,
|
||||
)
|
||||
from clink.models import (
|
||||
CLIClientConfig,
|
||||
CLIRoleConfig,
|
||||
ResolvedCLIClient,
|
||||
ResolvedCLIRole,
|
||||
)
|
||||
from utils.env import get_env
|
||||
from utils.file_utils import read_json_file
|
||||
|
||||
logger = logging.getLogger("clink.registry")
|
||||
|
||||
CONFIG_ENV_VAR = "CLI_CLIENTS_CONFIG_PATH"
|
||||
|
||||
|
||||
class RegistryLoadError(RuntimeError):
|
||||
"""Raised when configuration files are invalid or missing critical data."""
|
||||
|
||||
|
||||
class ClinkRegistry:
|
||||
"""Loads CLI client definitions and exposes them for schema generation/runtime use."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._clients: dict[str, ResolvedCLIClient] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
self._clients.clear()
|
||||
for config_path in self._iter_config_files():
|
||||
try:
|
||||
data = read_json_file(str(config_path))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RegistryLoadError(f"Invalid JSON in {config_path}: {exc}") from exc
|
||||
|
||||
if not data:
|
||||
logger.debug("Skipping empty configuration file: %s", config_path)
|
||||
continue
|
||||
|
||||
config = CLIClientConfig.model_validate(data)
|
||||
resolved = self._resolve_config(config, source_path=config_path)
|
||||
key = resolved.name.lower()
|
||||
if key in self._clients:
|
||||
logger.info("Overriding CLI configuration for '%s' from %s", resolved.name, config_path)
|
||||
else:
|
||||
logger.debug("Loaded CLI configuration for '%s' from %s", resolved.name, config_path)
|
||||
self._clients[key] = resolved
|
||||
|
||||
if not self._clients:
|
||||
raise RegistryLoadError(
|
||||
"No CLI clients configured. Ensure conf/cli_clients contains at least one definition or set "
|
||||
f"{CONFIG_ENV_VAR}."
|
||||
)
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload configurations from disk."""
|
||||
self._load()
|
||||
|
||||
def list_clients(self) -> list[str]:
|
||||
return sorted(client.name for client in self._clients.values())
|
||||
|
||||
def list_roles(self, cli_name: str) -> list[str]:
|
||||
config = self.get_client(cli_name)
|
||||
return sorted(config.roles.keys())
|
||||
|
||||
def get_client(self, cli_name: str) -> ResolvedCLIClient:
|
||||
key = cli_name.lower()
|
||||
if key not in self._clients:
|
||||
available = ", ".join(self.list_clients())
|
||||
raise KeyError(f"CLI '{cli_name}' is not configured. Available clients: {available}")
|
||||
return self._clients[key]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _iter_config_files(self) -> Iterable[Path]:
|
||||
search_paths: list[Path] = []
|
||||
|
||||
# 1. Built-in configs
|
||||
search_paths.append(CONFIG_DIR)
|
||||
|
||||
# 2. CLI_CLIENTS_CONFIG_PATH environment override (file or directory)
|
||||
env_path_raw = get_env(CONFIG_ENV_VAR)
|
||||
if env_path_raw:
|
||||
env_path = Path(env_path_raw).expanduser()
|
||||
search_paths.append(env_path)
|
||||
|
||||
# 3. User overrides in ~/.zen/cli_clients
|
||||
search_paths.append(USER_CONFIG_DIR)
|
||||
|
||||
seen: set[Path] = set()
|
||||
|
||||
for base in search_paths:
|
||||
if not base:
|
||||
continue
|
||||
if base in seen:
|
||||
continue
|
||||
seen.add(base)
|
||||
|
||||
if base.is_file() and base.suffix.lower() == ".json":
|
||||
yield base
|
||||
continue
|
||||
|
||||
if base.is_dir():
|
||||
for path in sorted(base.glob("*.json")):
|
||||
if path.is_file():
|
||||
yield path
|
||||
else:
|
||||
logger.debug("Configuration path does not exist: %s", base)
|
||||
|
||||
def _resolve_config(self, raw: CLIClientConfig, *, source_path: Path) -> ResolvedCLIClient:
|
||||
if not raw.name:
|
||||
raise RegistryLoadError(f"CLI configuration at {source_path} is missing a 'name' field")
|
||||
|
||||
normalized_name = raw.name.strip()
|
||||
internal_defaults = INTERNAL_DEFAULTS.get(normalized_name.lower())
|
||||
if internal_defaults is None:
|
||||
raise RegistryLoadError(f"CLI '{raw.name}' is not supported by clink")
|
||||
|
||||
executable = self._resolve_executable(raw, internal_defaults, source_path)
|
||||
|
||||
internal_args = list(internal_defaults.additional_args) if internal_defaults else []
|
||||
config_args = list(raw.additional_args)
|
||||
|
||||
timeout_seconds = raw.timeout_seconds or (
|
||||
internal_defaults.timeout_seconds if internal_defaults else DEFAULT_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
parser_name = internal_defaults.parser
|
||||
if not parser_name:
|
||||
raise RegistryLoadError(
|
||||
f"CLI '{raw.name}' must define a parser either in configuration or internal defaults"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
output_to_file = raw.output_to_file
|
||||
|
||||
return ResolvedCLIClient(
|
||||
name=normalized_name,
|
||||
executable=executable,
|
||||
internal_args=internal_args,
|
||||
config_args=config_args,
|
||||
env=env,
|
||||
timeout_seconds=int(timeout_seconds),
|
||||
parser=parser_name,
|
||||
roles=roles,
|
||||
output_to_file=output_to_file,
|
||||
working_dir=working_dir,
|
||||
)
|
||||
|
||||
def _resolve_executable(
|
||||
self,
|
||||
raw: CLIClientConfig,
|
||||
internal_defaults: CLIInternalDefaults | None,
|
||||
source_path: Path,
|
||||
) -> list[str]:
|
||||
command = raw.command
|
||||
if not command:
|
||||
raise RegistryLoadError(f"CLI '{raw.name}' must specify a 'command' in configuration")
|
||||
return shlex.split(command)
|
||||
|
||||
def _merge_env(
|
||||
self,
|
||||
raw: CLIClientConfig,
|
||||
internal_defaults: CLIInternalDefaults | None,
|
||||
) -> dict[str, str]:
|
||||
merged: dict[str, str] = {}
|
||||
if internal_defaults and internal_defaults.env:
|
||||
merged.update(internal_defaults.env)
|
||||
merged.update(raw.env)
|
||||
return merged
|
||||
|
||||
def _resolve_roles(
|
||||
self,
|
||||
raw: CLIClientConfig,
|
||||
internal_defaults: CLIInternalDefaults | None,
|
||||
source_path: Path,
|
||||
) -> dict[str, ResolvedCLIRole]:
|
||||
roles: dict[str, CLIRoleConfig] = dict(raw.roles)
|
||||
|
||||
default_role_prompt = internal_defaults.default_role_prompt if internal_defaults else None
|
||||
if "default" not in roles:
|
||||
roles["default"] = CLIRoleConfig(prompt_path=default_role_prompt)
|
||||
elif roles["default"].prompt_path is None and default_role_prompt:
|
||||
roles["default"].prompt_path = default_role_prompt
|
||||
|
||||
resolved: dict[str, ResolvedCLIRole] = {}
|
||||
for role_name, role_config in roles.items():
|
||||
prompt_path_str = role_config.prompt_path or default_role_prompt
|
||||
if not prompt_path_str:
|
||||
raise RegistryLoadError(f"Role '{role_name}' for CLI '{raw.name}' must define a prompt_path")
|
||||
prompt_path = self._resolve_prompt_path(prompt_path_str, source_path.parent)
|
||||
resolved[role_name] = ResolvedCLIRole(
|
||||
name=role_name,
|
||||
prompt_path=prompt_path,
|
||||
role_args=list(role_config.role_args),
|
||||
description=role_config.description,
|
||||
)
|
||||
return resolved
|
||||
|
||||
def _resolve_prompt_path(self, prompt_path: str, base_dir: Path) -> Path:
|
||||
resolved = self._resolve_path(prompt_path, base_dir)
|
||||
if not resolved.exists():
|
||||
raise RegistryLoadError(f"Prompt file not found: {resolved}")
|
||||
return resolved
|
||||
|
||||
def _resolve_optional_path(self, candidate: str | None, base_dir: Path) -> Path | None:
|
||||
if not candidate:
|
||||
return None
|
||||
return self._resolve_path(candidate, base_dir)
|
||||
|
||||
def _resolve_path(self, candidate: str, base_dir: Path) -> Path:
|
||||
path = Path(candidate)
|
||||
if path.is_absolute():
|
||||
return path
|
||||
|
||||
candidate_path = (base_dir / path).resolve()
|
||||
if candidate_path.exists():
|
||||
return candidate_path
|
||||
|
||||
project_relative = (PROJECT_ROOT / path).resolve()
|
||||
return project_relative
|
||||
|
||||
|
||||
_REGISTRY: ClinkRegistry | None = None
|
||||
|
||||
|
||||
def get_registry() -> ClinkRegistry:
|
||||
global _REGISTRY
|
||||
if _REGISTRY is None:
|
||||
_REGISTRY = ClinkRegistry()
|
||||
return _REGISTRY
|
||||
Reference in New Issue
Block a user