Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions clink/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
from .base import AgentOutput, BaseCLIAgent, CLIAgentError
from .claude import ClaudeAgent
from .codex import CodexAgent
from .copilot import CopilotAgent
from .gemini import GeminiAgent

_AGENTS: dict[str, type[BaseCLIAgent]] = {
"gemini": GeminiAgent,
"codex": CodexAgent,
"claude": ClaudeAgent,
"copilot": CopilotAgent,
}


Expand Down
147 changes: 147 additions & 0 deletions clink/agents/copilot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""GitHub Copilot-specific CLI agent hooks."""

from __future__ import annotations

import asyncio
import shutil
import time
from collections.abc import Sequence

from clink.constants import DEFAULT_STREAM_LIMIT
from clink.models import ResolvedCLIClient, ResolvedCLIRole
from clink.parsers.base import ParserError

from .base import AgentOutput, BaseCLIAgent, CLIAgentError


class CopilotAgent(BaseCLIAgent):
"""Copilot CLI agent that passes the prompt via -p argument."""

def __init__(self, client: ResolvedCLIClient):
super().__init__(client)

async def run(
self,
*,
role: ResolvedCLIRole,
prompt: str,
system_prompt: str | None = None,
files: Sequence[str],
images: Sequence[str],
) -> AgentOutput:
_ = (files, images)
command = self._build_command(role=role, system_prompt=system_prompt)
# Copilot uses -p <text> for non-interactive mode instead of stdin.
command.extend(["-p", prompt])
Comment thread
TejGandham marked this conversation as resolved.
env = self._build_environment()

executable_name = command[0]
resolved_executable = shutil.which(executable_name)
if resolved_executable is None:
raise CLIAgentError(
f"Executable '{executable_name}' not found in PATH for CLI '{self.client.name}'. "
f"Ensure the command is installed and accessible."
)
command[0] = resolved_executable

sanitized_command = ["[REDACTED]" if i > 0 and command[i - 1] == "-p" else arg for i, arg in enumerate(command)]
cwd = str(self.client.working_dir) if self.client.working_dir else None
limit = DEFAULT_STREAM_LIMIT
start_time = time.monotonic()

self._logger.debug("Executing CLI command: %s", " ".join(sanitized_command))

try:
process = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.DEVNULL,
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(),
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 return_code != 0:
recovered = self._recover_from_error(
returncode=return_code,
stdout=stdout_text,
stderr=stderr_text,
sanitized_command=sanitized_command,
duration_seconds=duration,
output_file_content=None,
)
if recovered is not None:
return recovered
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,
)

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,
)
6 changes: 6 additions & 0 deletions clink/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,10 @@ class CLIInternalDefaults:
default_role_prompt="systemprompts/clink/default.txt",
runner="claude",
),
"copilot": CLIInternalDefaults(
parser="copilot_jsonl",
additional_args=["--output-format", "json"],
default_role_prompt="systemprompts/clink/default.txt",
runner="copilot",
),
}
2 changes: 2 additions & 0 deletions clink/parsers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
from .base import BaseParser, ParsedCLIResponse, ParserError
from .claude import ClaudeJSONParser
from .codex import CodexJSONLParser
from .copilot import CopilotJSONLParser
from .gemini import GeminiJSONParser

_PARSER_CLASSES: dict[str, type[BaseParser]] = {
CodexJSONLParser.name: CodexJSONLParser,
GeminiJSONParser.name: GeminiJSONParser,
ClaudeJSONParser.name: ClaudeJSONParser,
CopilotJSONLParser.name: CopilotJSONLParser,
}


Expand Down
60 changes: 60 additions & 0 deletions clink/parsers/copilot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Parser for GitHub Copilot CLI JSONL output."""

from __future__ import annotations

import json
from typing import Any

from .base import BaseParser, ParsedCLIResponse, ParserError


class CopilotJSONLParser(BaseParser):
"""Parse JSONL stdout emitted by `copilot -p ... --output-format json`."""

name = "copilot_jsonl"

def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
lines = [line.strip() for line in (stdout or "").splitlines() if line.strip()]
events: list[dict[str, Any]] = []
assistant_messages: list[str] = []
result_event: dict[str, Any] | None = None

for line in lines:
if not line.startswith("{"):
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue

events.append(event)
event_type = event.get("type")

if event_type == "assistant.message":
data = event.get("data") or {}
content = data.get("content")
if isinstance(content, str) and content.strip():
assistant_messages.append(content.strip())

elif event_type == "result":
result_event = event

if not assistant_messages:
raise ParserError("Copilot CLI JSONL output did not include an assistant.message event")

content = "\n\n".join(assistant_messages).strip()
metadata: dict[str, Any] = {"events": events}

if result_event:
metadata["result"] = result_event
session_id = result_event.get("sessionId")
if isinstance(session_id, str):
metadata["session_id"] = session_id
usage = result_event.get("usage")
if isinstance(usage, dict):
metadata["usage"] = usage

if stderr and stderr.strip():
metadata["stderr"] = stderr.strip()

return ParsedCLIResponse(content=content, metadata=metadata)
20 changes: 20 additions & 0 deletions conf/cli_clients/copilot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "copilot",
"command": "copilot",
"additional_args": [],
"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": []
}
}
}
1 change: 0 additions & 1 deletion simulator_tests/test_chat_simple_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
- Conversation context preservation across turns
"""


from .conversation_base_test import ConversationBaseTest


Expand Down
1 change: 0 additions & 1 deletion simulator_tests/test_conversation_chain_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
- Properly traverse parent relationships for history reconstruction
"""


from .conversation_base_test import ConversationBaseTest


Expand Down
1 change: 0 additions & 1 deletion simulator_tests/test_cross_tool_comprehensive.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
5. Proper tool chaining with context
"""


from .conversation_base_test import ConversationBaseTest


Expand Down
1 change: 0 additions & 1 deletion simulator_tests/test_ollama_custom_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
- Model alias resolution for local models
"""


from .base_test import BaseSimulatorTest


Expand Down
1 change: 0 additions & 1 deletion simulator_tests/test_openrouter_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
- Auto mode correctly selects OpenRouter models
"""


from .base_test import BaseSimulatorTest


Expand Down
1 change: 0 additions & 1 deletion simulator_tests/test_openrouter_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
- Error handling when models are not available
"""


from .base_test import BaseSimulatorTest


Expand Down
1 change: 0 additions & 1 deletion simulator_tests/test_xai_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
- API integration and response validation
"""


from .base_test import BaseSimulatorTest


Expand Down
Loading
Loading