mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-07-03 14:05:26 +02:00
Extract client CLI adapter abstraction
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""Client CLI adapter implementations."""
|
||||
|
||||
from .claude import CLAUDE_CLI_ADAPTER, ClaudeCliAdapter
|
||||
from .registry import DEFAULT_CLIENT_CLI_ID, get_client_cli_adapter
|
||||
|
||||
__all__ = [
|
||||
"CLAUDE_CLI_ADAPTER",
|
||||
"DEFAULT_CLIENT_CLI_ID",
|
||||
"ClaudeCliAdapter",
|
||||
"get_client_cli_adapter",
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Shared contracts for client CLI subprocess adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CliTaskRequest:
|
||||
"""A single prompt execution request for a managed client CLI process."""
|
||||
|
||||
prompt: str
|
||||
session_id: str | None = None
|
||||
fork_session: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CliInvocation:
|
||||
"""Concrete subprocess invocation assembled by a client CLI adapter."""
|
||||
|
||||
argv: tuple[str, ...]
|
||||
env: dict[str, str]
|
||||
cwd: str
|
||||
trace_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CliParseState:
|
||||
"""Mutable line-parser state for a single client CLI process run."""
|
||||
|
||||
log_raw_cli_diagnostics: bool = False
|
||||
session_id_extracted: bool = False
|
||||
|
||||
|
||||
class ClientCliAdapter(Protocol):
|
||||
"""Adapter boundary for client CLI command/env construction and output parsing."""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
default_binary: str
|
||||
install_hint: str
|
||||
trace_stage: str
|
||||
process_launch_event: str
|
||||
trace_source: str
|
||||
|
||||
def build_task_invocation(
|
||||
self,
|
||||
*,
|
||||
config: Any,
|
||||
request: CliTaskRequest,
|
||||
base_env: Mapping[str, str],
|
||||
) -> CliInvocation:
|
||||
"""Build the subprocess invocation for a managed task run."""
|
||||
...
|
||||
|
||||
def parse_stdout_line(self, line: str, state: CliParseState) -> Iterable[Any]:
|
||||
"""Parse one stdout line into parser-ready internal CLI events."""
|
||||
...
|
||||
|
||||
def extract_session_id(self, event: Any) -> str | None:
|
||||
"""Extract a persistent client CLI session id from a parsed event."""
|
||||
...
|
||||
|
||||
def get_launcher_binary_name(self, settings: Any) -> str:
|
||||
"""Return the configured executable name for a wrapper entrypoint."""
|
||||
...
|
||||
|
||||
def build_launcher_env(
|
||||
self,
|
||||
*,
|
||||
proxy_root_url: str,
|
||||
auth_token: str,
|
||||
base_env: Mapping[str, str],
|
||||
) -> dict[str, str]:
|
||||
"""Build environment variables for a wrapper-launched client CLI."""
|
||||
...
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Claude Code client CLI adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .base import CliInvocation, CliParseState, CliTaskRequest
|
||||
|
||||
_AUTO_COMPACT_WINDOW = "190000"
|
||||
|
||||
|
||||
class ClaudeCliAdapter:
|
||||
"""Client CLI adapter for Claude Code."""
|
||||
|
||||
id = "claude"
|
||||
display_name = "Claude Code"
|
||||
default_binary = "claude"
|
||||
install_hint = "Install Claude Code with: npm install -g @anthropic-ai/claude-code"
|
||||
trace_stage = "claude_cli"
|
||||
process_launch_event = "claude_cli.process.launch"
|
||||
trace_source = "claude_cli"
|
||||
|
||||
def build_task_invocation(
|
||||
self,
|
||||
*,
|
||||
config: Any,
|
||||
request: CliTaskRequest,
|
||||
base_env: Mapping[str, str],
|
||||
) -> CliInvocation:
|
||||
"""Build a Claude Code stream-json subprocess invocation."""
|
||||
|
||||
env = self._task_env(
|
||||
api_url=config.api_url,
|
||||
auth_token=config.auth_token,
|
||||
base_env=base_env,
|
||||
)
|
||||
cmd = self._task_command(
|
||||
claude_bin=config.claude_bin,
|
||||
prompt=request.prompt,
|
||||
session_id=request.session_id,
|
||||
fork_session=request.fork_session,
|
||||
allowed_dirs=config.allowed_dirs,
|
||||
plans_directory=config.plans_directory,
|
||||
)
|
||||
|
||||
resume_session_id = (
|
||||
request.session_id
|
||||
if request.session_id and not request.session_id.startswith("pending_")
|
||||
else None
|
||||
)
|
||||
return CliInvocation(
|
||||
argv=tuple(cmd),
|
||||
env=env,
|
||||
cwd=config.workspace_path,
|
||||
trace_metadata={
|
||||
"client_cli_id": self.id,
|
||||
"resume_session_id": resume_session_id,
|
||||
"fork_session": request.fork_session,
|
||||
"prompt": request.prompt,
|
||||
"cwd": config.workspace_path,
|
||||
"claude_binary": config.claude_bin,
|
||||
"cli_argv": cmd,
|
||||
},
|
||||
)
|
||||
|
||||
def parse_stdout_line(self, line: str, state: CliParseState) -> Iterable[Any]:
|
||||
"""Parse one Claude Code JSONL line into existing parser-ready events."""
|
||||
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
if state.log_raw_cli_diagnostics:
|
||||
logger.debug("Non-JSON output: {}", line)
|
||||
else:
|
||||
logger.debug("Non-JSON CLI line: char_len={}", len(line))
|
||||
yield {"type": "raw", "content": line}
|
||||
return
|
||||
|
||||
if not state.session_id_extracted:
|
||||
extracted_id = self.extract_session_id(event)
|
||||
if extracted_id:
|
||||
state.session_id_extracted = True
|
||||
logger.info(f"Extracted session ID: {extracted_id}")
|
||||
yield {"type": "session_info", "session_id": extracted_id}
|
||||
|
||||
yield event
|
||||
|
||||
def extract_session_id(self, event: Any) -> str | None:
|
||||
"""Extract a Claude Code session ID from supported event shapes."""
|
||||
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
|
||||
if session_id := _string_value(event.get("session_id")):
|
||||
return session_id
|
||||
if session_id := _string_value(event.get("sessionId")):
|
||||
return session_id
|
||||
|
||||
for key in ["init", "system", "result", "metadata"]:
|
||||
nested = event.get(key)
|
||||
if not isinstance(nested, dict):
|
||||
continue
|
||||
if session_id := _string_value(nested.get("session_id")):
|
||||
return session_id
|
||||
if session_id := _string_value(nested.get("sessionId")):
|
||||
return session_id
|
||||
|
||||
conv = event.get("conversation")
|
||||
if isinstance(conv, dict):
|
||||
return _string_value(conv.get("id"))
|
||||
|
||||
return None
|
||||
|
||||
def get_launcher_binary_name(self, settings: Any) -> str:
|
||||
"""Return the configured Claude Code binary name."""
|
||||
|
||||
configured = getattr(settings, "claude_cli_bin", "")
|
||||
return configured or self.default_binary
|
||||
|
||||
def build_launcher_env(
|
||||
self,
|
||||
*,
|
||||
proxy_root_url: str,
|
||||
auth_token: str,
|
||||
base_env: Mapping[str, str],
|
||||
) -> dict[str, str]:
|
||||
"""Return a Claude Code environment that targets the local proxy."""
|
||||
|
||||
env = {
|
||||
key: value
|
||||
for key, value in base_env.items()
|
||||
if not key.startswith("ANTHROPIC_")
|
||||
}
|
||||
env.pop("ANTHROPIC_API_KEY", None)
|
||||
env["ANTHROPIC_BASE_URL"] = proxy_root_url
|
||||
env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
|
||||
env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = _AUTO_COMPACT_WINDOW
|
||||
if token := auth_token.strip():
|
||||
env["ANTHROPIC_AUTH_TOKEN"] = token
|
||||
return env
|
||||
|
||||
def _task_env(
|
||||
self,
|
||||
*,
|
||||
api_url: str,
|
||||
auth_token: str,
|
||||
base_env: Mapping[str, str],
|
||||
) -> dict[str, str]:
|
||||
env = dict(base_env)
|
||||
env["ANTHROPIC_API_URL"] = api_url
|
||||
if api_url.endswith("/v1"):
|
||||
env["ANTHROPIC_BASE_URL"] = api_url[:-3]
|
||||
else:
|
||||
env["ANTHROPIC_BASE_URL"] = api_url
|
||||
env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
|
||||
env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = _AUTO_COMPACT_WINDOW
|
||||
env.pop("ANTHROPIC_API_KEY", None)
|
||||
if token := auth_token.strip():
|
||||
env["ANTHROPIC_AUTH_TOKEN"] = token
|
||||
else:
|
||||
env.pop("ANTHROPIC_AUTH_TOKEN", None)
|
||||
|
||||
env["TERM"] = "dumb"
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
return env
|
||||
|
||||
def _task_command(
|
||||
self,
|
||||
*,
|
||||
claude_bin: str,
|
||||
prompt: str,
|
||||
session_id: str | None,
|
||||
fork_session: bool,
|
||||
allowed_dirs: list[str],
|
||||
plans_directory: str | None,
|
||||
) -> list[str]:
|
||||
if session_id and not session_id.startswith("pending_"):
|
||||
cmd = [
|
||||
claude_bin,
|
||||
"--resume",
|
||||
session_id,
|
||||
]
|
||||
if fork_session:
|
||||
cmd.append("--fork-session")
|
||||
cmd += [
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--dangerously-skip-permissions",
|
||||
"--verbose",
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
claude_bin,
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--dangerously-skip-permissions",
|
||||
"--verbose",
|
||||
]
|
||||
|
||||
for directory in allowed_dirs:
|
||||
cmd.extend(["--add-dir", directory])
|
||||
|
||||
if plans_directory is not None:
|
||||
settings_json = json.dumps({"plansDirectory": plans_directory})
|
||||
cmd.extend(["--settings", settings_json])
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def _string_value(value: Any) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
CLAUDE_CLI_ADAPTER = ClaudeCliAdapter()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Internal client CLI adapter registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import ClientCliAdapter
|
||||
from .claude import CLAUDE_CLI_ADAPTER
|
||||
|
||||
DEFAULT_CLIENT_CLI_ID = "claude"
|
||||
|
||||
_ADAPTERS: dict[str, ClientCliAdapter] = {
|
||||
CLAUDE_CLI_ADAPTER.id: CLAUDE_CLI_ADAPTER,
|
||||
}
|
||||
|
||||
|
||||
def get_client_cli_adapter(
|
||||
client_cli_id: str = DEFAULT_CLIENT_CLI_ID,
|
||||
) -> ClientCliAdapter:
|
||||
"""Return a registered client CLI adapter by id."""
|
||||
|
||||
try:
|
||||
return _ADAPTERS[client_cli_id]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown client CLI adapter: {client_cli_id}") from exc
|
||||
+28
-26
@@ -18,6 +18,8 @@ import uvicorn
|
||||
|
||||
from api.admin_urls import local_admin_url, local_proxy_root_url
|
||||
from api.app import GracefulLifespanApp, create_app
|
||||
from cli.adapters.base import ClientCliAdapter
|
||||
from cli.adapters.claude import CLAUDE_CLI_ADAPTER
|
||||
from cli.process_registry import (
|
||||
kill_all_best_effort,
|
||||
kill_pid_tree_best_effort,
|
||||
@@ -174,18 +176,11 @@ def _claude_child_env(
|
||||
) -> dict[str, str]:
|
||||
"""Return a Claude Code environment that targets this proxy."""
|
||||
|
||||
env = {
|
||||
key: value
|
||||
for key, value in base_env.items()
|
||||
if not key.startswith("ANTHROPIC_")
|
||||
}
|
||||
env.pop("ANTHROPIC_API_KEY", None)
|
||||
env["ANTHROPIC_BASE_URL"] = local_proxy_root_url(settings)
|
||||
env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
|
||||
env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "190000"
|
||||
if token := settings.anthropic_auth_token.strip():
|
||||
env["ANTHROPIC_AUTH_TOKEN"] = token
|
||||
return env
|
||||
return CLAUDE_CLI_ADAPTER.build_launcher_env(
|
||||
proxy_root_url=local_proxy_root_url(settings),
|
||||
auth_token=settings.anthropic_auth_token,
|
||||
base_env=base_env,
|
||||
)
|
||||
|
||||
|
||||
def _preflight_proxy(proxy_root_url: str) -> str | None:
|
||||
@@ -211,6 +206,14 @@ def _preflight_proxy(proxy_root_url: str) -> str | None:
|
||||
def launch_claude(argv: Sequence[str] | None = None) -> None:
|
||||
"""Launch Claude Code with Free Claude Code proxy environment variables."""
|
||||
|
||||
_launch_client_cli(CLAUDE_CLI_ADAPTER, argv)
|
||||
|
||||
|
||||
def _launch_client_cli(
|
||||
adapter: ClientCliAdapter, argv: Sequence[str] | None = None
|
||||
) -> None:
|
||||
"""Launch a client CLI with Free Claude Code proxy environment variables."""
|
||||
|
||||
settings = get_settings()
|
||||
proxy_root_url = local_proxy_root_url(settings)
|
||||
if error := _preflight_proxy(proxy_root_url):
|
||||
@@ -222,20 +225,22 @@ def launch_claude(argv: Sequence[str] | None = None) -> None:
|
||||
raise SystemExit(1)
|
||||
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
claude_command = shutil.which(settings.claude_cli_bin)
|
||||
if claude_command is None:
|
||||
binary_name = adapter.get_launcher_binary_name(settings)
|
||||
client_command = shutil.which(binary_name)
|
||||
if client_command is None:
|
||||
print(
|
||||
f"Could not find Claude Code command: {settings.claude_cli_bin}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Install Claude Code with: npm install -g @anthropic-ai/claude-code",
|
||||
f"Could not find {adapter.display_name} command: {binary_name}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(adapter.install_hint, file=sys.stderr)
|
||||
raise SystemExit(127)
|
||||
|
||||
command = [claude_command, *args]
|
||||
env = _claude_child_env(settings, os.environ)
|
||||
command = [client_command, *args]
|
||||
env = adapter.build_launcher_env(
|
||||
proxy_root_url=proxy_root_url,
|
||||
auth_token=settings.anthropic_auth_token,
|
||||
base_env=os.environ,
|
||||
)
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
process = subprocess.Popen(command, env=env)
|
||||
@@ -244,13 +249,10 @@ def launch_claude(argv: Sequence[str] | None = None) -> None:
|
||||
return_code = process.wait()
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
f"Could not find Claude Code command: {settings.claude_cli_bin}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Install Claude Code with: npm install -g @anthropic-ai/claude-code",
|
||||
f"Could not find {adapter.display_name} command: {binary_name}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(adapter.install_hint, file=sys.stderr)
|
||||
raise SystemExit(127) from None
|
||||
except KeyboardInterrupt:
|
||||
if process is not None and process.pid:
|
||||
|
||||
@@ -11,6 +11,8 @@ import uuid
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .adapters.base import ClientCliAdapter
|
||||
from .adapters.registry import get_client_cli_adapter
|
||||
from .session import CLISession
|
||||
|
||||
|
||||
@@ -31,6 +33,7 @@ class CLISessionManager:
|
||||
claude_bin: str = "claude",
|
||||
auth_token: str = "",
|
||||
*,
|
||||
client_cli_adapter: ClientCliAdapter | None = None,
|
||||
log_raw_cli_diagnostics: bool = False,
|
||||
log_messaging_error_details: bool = False,
|
||||
):
|
||||
@@ -49,6 +52,7 @@ class CLISessionManager:
|
||||
self.plans_directory = plans_directory
|
||||
self.claude_bin = claude_bin
|
||||
self.auth_token = auth_token
|
||||
self._client_cli_adapter = client_cli_adapter or get_client_cli_adapter()
|
||||
self._log_raw_cli_diagnostics = log_raw_cli_diagnostics
|
||||
self._log_messaging_error_details = log_messaging_error_details
|
||||
|
||||
@@ -85,6 +89,7 @@ class CLISessionManager:
|
||||
plans_directory=self.plans_directory,
|
||||
claude_bin=self.claude_bin,
|
||||
auth_token=self.auth_token,
|
||||
client_cli_adapter=self._client_cli_adapter,
|
||||
log_raw_cli_diagnostics=self._log_raw_cli_diagnostics,
|
||||
)
|
||||
self._pending_sessions[temp_id] = new_session
|
||||
|
||||
+32
-115
@@ -1,7 +1,6 @@
|
||||
"""Claude Code CLI session management."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import dataclass, field
|
||||
@@ -11,6 +10,8 @@ from loguru import logger
|
||||
|
||||
from core.trace import trace_event
|
||||
|
||||
from .adapters.base import ClientCliAdapter, CliParseState, CliTaskRequest
|
||||
from .adapters.registry import get_client_cli_adapter
|
||||
from .process_registry import kill_pid_tree_best_effort, register_pid, unregister_pid
|
||||
|
||||
# Cap stderr capture so a runaway child cannot exhaust memory; pipe is still drained.
|
||||
@@ -41,6 +42,7 @@ class CLISession:
|
||||
claude_bin: str = "claude",
|
||||
auth_token: str = "",
|
||||
*,
|
||||
client_cli_adapter: ClientCliAdapter | None = None,
|
||||
log_raw_cli_diagnostics: bool = False,
|
||||
):
|
||||
self.config = ClaudeCliConfig(
|
||||
@@ -57,6 +59,7 @@ class CLISession:
|
||||
self.plans_directory = self.config.plans_directory
|
||||
self.claude_bin = self.config.claude_bin
|
||||
self.auth_token = self.config.auth_token
|
||||
self._client_cli_adapter = client_cli_adapter or get_client_cli_adapter()
|
||||
self._log_raw_cli_diagnostics = log_raw_cli_diagnostics
|
||||
self.process: asyncio.subprocess.Process | None = None
|
||||
self.current_session_id: str | None = None
|
||||
@@ -111,83 +114,30 @@ class CLISession:
|
||||
"""
|
||||
async with self._cli_lock:
|
||||
self._is_busy = True
|
||||
env = os.environ.copy()
|
||||
|
||||
env["ANTHROPIC_API_URL"] = self.api_url
|
||||
if self.api_url.endswith("/v1"):
|
||||
env["ANTHROPIC_BASE_URL"] = self.api_url[:-3]
|
||||
else:
|
||||
env["ANTHROPIC_BASE_URL"] = self.api_url
|
||||
env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
|
||||
env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "190000"
|
||||
env.pop("ANTHROPIC_API_KEY", None)
|
||||
if token := self.auth_token.strip():
|
||||
env["ANTHROPIC_AUTH_TOKEN"] = token
|
||||
else:
|
||||
env.pop("ANTHROPIC_AUTH_TOKEN", None)
|
||||
|
||||
env["TERM"] = "dumb"
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
|
||||
# Build command
|
||||
if session_id and not session_id.startswith("pending_"):
|
||||
cmd = [
|
||||
self.claude_bin,
|
||||
"--resume",
|
||||
session_id,
|
||||
]
|
||||
if fork_session:
|
||||
cmd.append("--fork-session")
|
||||
cmd += [
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--dangerously-skip-permissions",
|
||||
"--verbose",
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
self.claude_bin,
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--dangerously-skip-permissions",
|
||||
"--verbose",
|
||||
]
|
||||
|
||||
if self.allowed_dirs:
|
||||
for d in self.allowed_dirs:
|
||||
cmd.extend(["--add-dir", d])
|
||||
|
||||
if self.plans_directory is not None:
|
||||
settings_json = json.dumps({"plansDirectory": self.plans_directory})
|
||||
cmd.extend(["--settings", settings_json])
|
||||
invocation = self._client_cli_adapter.build_task_invocation(
|
||||
config=self.config,
|
||||
request=CliTaskRequest(
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
fork_session=fork_session,
|
||||
),
|
||||
base_env=os.environ,
|
||||
)
|
||||
|
||||
trace_event(
|
||||
stage="claude_cli",
|
||||
event="claude_cli.process.launch",
|
||||
source="claude_cli",
|
||||
resume_session_id=(
|
||||
session_id
|
||||
if session_id and not session_id.startswith("pending_")
|
||||
else None
|
||||
),
|
||||
fork_session=fork_session,
|
||||
prompt=prompt,
|
||||
cwd=self.workspace,
|
||||
claude_binary=self.claude_bin,
|
||||
cli_argv=cmd,
|
||||
stage=self._client_cli_adapter.trace_stage,
|
||||
event=self._client_cli_adapter.process_launch_event,
|
||||
source=self._client_cli_adapter.trace_source,
|
||||
**invocation.trace_metadata,
|
||||
)
|
||||
|
||||
try:
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
*invocation.argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.workspace,
|
||||
env=env,
|
||||
cwd=invocation.cwd,
|
||||
env=invocation.env,
|
||||
)
|
||||
if self.process and self.process.pid:
|
||||
register_pid(self.process.pid)
|
||||
@@ -196,7 +146,9 @@ class CLISession:
|
||||
yield {"type": "exit", "code": 1}
|
||||
return
|
||||
|
||||
session_id_extracted = False
|
||||
parse_state = CliParseState(
|
||||
log_raw_cli_diagnostics=self._log_raw_cli_diagnostics
|
||||
)
|
||||
buffer = bytearray()
|
||||
stderr_task: asyncio.Task[bytes] | None = None
|
||||
if self.process.stderr:
|
||||
@@ -214,10 +166,8 @@ class CLISession:
|
||||
).strip()
|
||||
if line_str:
|
||||
async for event in self._handle_line_gen(
|
||||
line_str, session_id_extracted
|
||||
line_str, parse_state
|
||||
):
|
||||
if event.get("type") == "session_info":
|
||||
session_id_extracted = True
|
||||
yield event
|
||||
break
|
||||
|
||||
@@ -234,10 +184,8 @@ class CLISession:
|
||||
line_str = line.decode("utf-8", errors="replace").strip()
|
||||
if line_str:
|
||||
async for event in self._handle_line_gen(
|
||||
line_str, session_id_extracted
|
||||
line_str, parse_state
|
||||
):
|
||||
if event.get("type") == "session_info":
|
||||
session_id_extracted = True
|
||||
yield event
|
||||
except asyncio.CancelledError:
|
||||
# Cancelling the handler task should not leave a Claude CLI
|
||||
@@ -283,50 +231,19 @@ class CLISession:
|
||||
unregister_pid(self.process.pid)
|
||||
|
||||
async def _handle_line_gen(
|
||||
self, line_str: str, session_id_extracted: bool
|
||||
self, line_str: str, parse_state: CliParseState
|
||||
) -> AsyncGenerator[dict]:
|
||||
"""Process a single line and yield events."""
|
||||
try:
|
||||
event = json.loads(line_str)
|
||||
if not session_id_extracted:
|
||||
extracted_id = self._extract_session_id(event)
|
||||
if extracted_id:
|
||||
self.current_session_id = extracted_id
|
||||
logger.info(f"Extracted session ID: {extracted_id}")
|
||||
yield {"type": "session_info", "session_id": extracted_id}
|
||||
|
||||
for event in self._client_cli_adapter.parse_stdout_line(line_str, parse_state):
|
||||
if isinstance(event, dict) and event.get("type") == "session_info":
|
||||
session_id = event.get("session_id")
|
||||
if isinstance(session_id, str):
|
||||
self.current_session_id = session_id
|
||||
yield event
|
||||
except json.JSONDecodeError:
|
||||
if self._log_raw_cli_diagnostics:
|
||||
logger.debug("Non-JSON output: {}", line_str)
|
||||
else:
|
||||
logger.debug("Non-JSON CLI line: char_len={}", len(line_str))
|
||||
yield {"type": "raw", "content": line_str}
|
||||
|
||||
def _extract_session_id(self, event: Any) -> str | None:
|
||||
"""Extract session ID from CLI event."""
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
|
||||
if "session_id" in event:
|
||||
return event["session_id"]
|
||||
if "sessionId" in event:
|
||||
return event["sessionId"]
|
||||
|
||||
for key in ["init", "system", "result", "metadata"]:
|
||||
if key in event and isinstance(event[key], dict):
|
||||
nested = event[key]
|
||||
if "session_id" in nested:
|
||||
return nested["session_id"]
|
||||
if "sessionId" in nested:
|
||||
return nested["sessionId"]
|
||||
|
||||
if "conversation" in event and isinstance(event["conversation"], dict):
|
||||
conv = event["conversation"]
|
||||
if "id" in conv:
|
||||
return conv["id"]
|
||||
|
||||
return None
|
||||
return self._client_cli_adapter.extract_session_id(event)
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the CLI process."""
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from cli.adapters.base import CliParseState, CliTaskRequest
|
||||
from cli.adapters.claude import CLAUDE_CLI_ADAPTER
|
||||
from cli.adapters.registry import DEFAULT_CLIENT_CLI_ID, get_client_cli_adapter
|
||||
|
||||
|
||||
def _config(**overrides: object) -> SimpleNamespace:
|
||||
values: dict[str, object] = {
|
||||
"workspace_path": "/workspace",
|
||||
"api_url": "http://127.0.0.1:8082/v1",
|
||||
"allowed_dirs": [],
|
||||
"plans_directory": None,
|
||||
"claude_bin": "claude-test",
|
||||
"auth_token": "",
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def test_registry_returns_default_claude_adapter() -> None:
|
||||
assert DEFAULT_CLIENT_CLI_ID == "claude"
|
||||
assert get_client_cli_adapter() is CLAUDE_CLI_ADAPTER
|
||||
assert get_client_cli_adapter("claude") is CLAUDE_CLI_ADAPTER
|
||||
|
||||
|
||||
def test_claude_adapter_builds_new_task_command_and_env() -> None:
|
||||
invocation = CLAUDE_CLI_ADAPTER.build_task_invocation(
|
||||
config=_config(auth_token="proxy-token"),
|
||||
request=CliTaskRequest(prompt="hello"),
|
||||
base_env={
|
||||
"KEEP_ME": "yes",
|
||||
"ANTHROPIC_API_KEY": "official-key",
|
||||
"ANTHROPIC_AUTH_TOKEN": "stale-token",
|
||||
},
|
||||
)
|
||||
|
||||
assert invocation.argv == (
|
||||
"claude-test",
|
||||
"-p",
|
||||
"hello",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--dangerously-skip-permissions",
|
||||
"--verbose",
|
||||
)
|
||||
assert invocation.env["KEEP_ME"] == "yes"
|
||||
assert invocation.env["ANTHROPIC_API_URL"] == "http://127.0.0.1:8082/v1"
|
||||
assert invocation.env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8082"
|
||||
assert invocation.env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token"
|
||||
assert invocation.env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
|
||||
assert invocation.env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000"
|
||||
assert "ANTHROPIC_API_KEY" not in invocation.env
|
||||
assert invocation.trace_metadata["client_cli_id"] == "claude"
|
||||
|
||||
|
||||
def test_claude_adapter_builds_resume_fork_command() -> None:
|
||||
invocation = CLAUDE_CLI_ADAPTER.build_task_invocation(
|
||||
config=_config(),
|
||||
request=CliTaskRequest(
|
||||
prompt="continue",
|
||||
session_id="sess_123",
|
||||
fork_session=True,
|
||||
),
|
||||
base_env={},
|
||||
)
|
||||
|
||||
assert invocation.argv[:4] == (
|
||||
"claude-test",
|
||||
"--resume",
|
||||
"sess_123",
|
||||
"--fork-session",
|
||||
)
|
||||
assert "-p" in invocation.argv
|
||||
assert "continue" in invocation.argv
|
||||
assert invocation.trace_metadata["resume_session_id"] == "sess_123"
|
||||
assert invocation.trace_metadata["fork_session"] is True
|
||||
|
||||
|
||||
def test_claude_adapter_does_not_resume_pending_session() -> None:
|
||||
invocation = CLAUDE_CLI_ADAPTER.build_task_invocation(
|
||||
config=_config(),
|
||||
request=CliTaskRequest(prompt="new", session_id="pending_123"),
|
||||
base_env={},
|
||||
)
|
||||
|
||||
assert "--resume" not in invocation.argv
|
||||
assert invocation.trace_metadata["resume_session_id"] is None
|
||||
|
||||
|
||||
def test_claude_adapter_adds_allowed_dirs_and_plans_directory() -> None:
|
||||
invocation = CLAUDE_CLI_ADAPTER.build_task_invocation(
|
||||
config=_config(
|
||||
allowed_dirs=["/dir1", "/dir2"],
|
||||
plans_directory="./agent_workspace/plans",
|
||||
),
|
||||
request=CliTaskRequest(prompt="hello"),
|
||||
base_env={},
|
||||
)
|
||||
|
||||
assert invocation.argv.count("--add-dir") == 2
|
||||
assert "/dir1" in invocation.argv
|
||||
assert "/dir2" in invocation.argv
|
||||
settings_idx = invocation.argv.index("--settings")
|
||||
settings = json.loads(invocation.argv[settings_idx + 1])
|
||||
assert settings["plansDirectory"] == "./agent_workspace/plans"
|
||||
|
||||
|
||||
def test_claude_adapter_launcher_env_targets_proxy() -> None:
|
||||
env = CLAUDE_CLI_ADAPTER.build_launcher_env(
|
||||
proxy_root_url="http://127.0.0.1:9191",
|
||||
auth_token=" proxy-token ",
|
||||
base_env={
|
||||
"PATH": "keep",
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_API_KEY": "official-key",
|
||||
"ANTHROPIC_AUTH_TOKEN": "stale-token",
|
||||
},
|
||||
)
|
||||
|
||||
assert env["PATH"] == "keep"
|
||||
assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9191"
|
||||
assert env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token"
|
||||
assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
|
||||
assert env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000"
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
|
||||
|
||||
def test_claude_adapter_extracts_supported_session_id_shapes() -> None:
|
||||
assert CLAUDE_CLI_ADAPTER.extract_session_id({"session_id": "direct"}) == "direct"
|
||||
assert CLAUDE_CLI_ADAPTER.extract_session_id({"sessionId": "camel"}) == "camel"
|
||||
assert (
|
||||
CLAUDE_CLI_ADAPTER.extract_session_id({"init": {"session_id": "nested"}})
|
||||
== "nested"
|
||||
)
|
||||
assert (
|
||||
CLAUDE_CLI_ADAPTER.extract_session_id({"result": {"sessionId": "result"}})
|
||||
== "result"
|
||||
)
|
||||
assert (
|
||||
CLAUDE_CLI_ADAPTER.extract_session_id({"conversation": {"id": "conv"}})
|
||||
== "conv"
|
||||
)
|
||||
assert CLAUDE_CLI_ADAPTER.extract_session_id({"type": "message"}) is None
|
||||
assert CLAUDE_CLI_ADAPTER.extract_session_id("not a dict") is None
|
||||
|
||||
|
||||
def test_claude_adapter_invalid_stdout_json_becomes_raw_event() -> None:
|
||||
events = list(
|
||||
CLAUDE_CLI_ADAPTER.parse_stdout_line(
|
||||
"Not valid json",
|
||||
CliParseState(log_raw_cli_diagnostics=False),
|
||||
)
|
||||
)
|
||||
|
||||
assert events == [{"type": "raw", "content": "Not valid json"}]
|
||||
|
||||
|
||||
def test_claude_adapter_synthesizes_session_info_once() -> None:
|
||||
state = CliParseState()
|
||||
|
||||
first_events = list(
|
||||
CLAUDE_CLI_ADAPTER.parse_stdout_line('{"session_id": "sess_1"}', state)
|
||||
)
|
||||
second_events = list(
|
||||
CLAUDE_CLI_ADAPTER.parse_stdout_line('{"session_id": "sess_2"}', state)
|
||||
)
|
||||
|
||||
assert first_events == [
|
||||
{"type": "session_info", "session_id": "sess_1"},
|
||||
{"session_id": "sess_1"},
|
||||
]
|
||||
assert second_events == [{"session_id": "sess_2"}]
|
||||
+103
-1
@@ -3,7 +3,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import cast
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -639,6 +640,107 @@ class TestCLISession:
|
||||
assert len(events) == 1
|
||||
assert events[0]["content"] == "Not valid json"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_uses_injected_client_cli_adapter(self, tmp_path):
|
||||
"""CLISession delegates argv/env/line parsing to the injected adapter."""
|
||||
from cli.adapters.base import CliInvocation, CliParseState, CliTaskRequest
|
||||
from cli.session import CLISession
|
||||
|
||||
class FakeClientCliAdapter:
|
||||
id = "fake"
|
||||
display_name = "Fake CLI"
|
||||
default_binary = "fake"
|
||||
install_hint = "Install Fake CLI"
|
||||
trace_stage = "claude_cli"
|
||||
process_launch_event = "claude_cli.process.launch"
|
||||
trace_source = "claude_cli"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.request: CliTaskRequest | None = None
|
||||
|
||||
def build_task_invocation(
|
||||
self,
|
||||
*,
|
||||
config: Any,
|
||||
request: CliTaskRequest,
|
||||
base_env: Mapping[str, str],
|
||||
) -> CliInvocation:
|
||||
self.request = request
|
||||
return CliInvocation(
|
||||
argv=("fake-cli", "--prompt", request.prompt),
|
||||
env={"FAKE_ENV": base_env.get("KEEP_ME", "")},
|
||||
cwd=config.workspace_path,
|
||||
trace_metadata={"client_cli_id": self.id},
|
||||
)
|
||||
|
||||
def parse_stdout_line(
|
||||
self, line: str, state: CliParseState
|
||||
) -> Iterable[dict[str, Any]]:
|
||||
if not state.session_id_extracted:
|
||||
state.session_id_extracted = True
|
||||
yield {"type": "session_info", "session_id": "fake_session"}
|
||||
yield {"type": "message", "content": line}
|
||||
|
||||
def extract_session_id(self, event: Any) -> str | None:
|
||||
if isinstance(event, dict):
|
||||
value = event.get("session_id")
|
||||
return value if isinstance(value, str) else None
|
||||
return None
|
||||
|
||||
def get_launcher_binary_name(self, settings: Any) -> str:
|
||||
return self.default_binary
|
||||
|
||||
def build_launcher_env(
|
||||
self,
|
||||
*,
|
||||
proxy_root_url: str,
|
||||
auth_token: str,
|
||||
base_env: Mapping[str, str],
|
||||
) -> dict[str, str]:
|
||||
return dict(base_env)
|
||||
|
||||
adapter = FakeClientCliAdapter()
|
||||
session = CLISession(
|
||||
str(tmp_path),
|
||||
"http://localhost:8082/v1",
|
||||
client_cli_adapter=adapter,
|
||||
)
|
||||
|
||||
mock_process = AsyncMock()
|
||||
mock_process.stdout.read.side_effect = [b"hello\n", b""]
|
||||
mock_process.stderr.read.return_value = b""
|
||||
mock_process.wait.return_value = 0
|
||||
mock_process.returncode = 0
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"KEEP_ME": "yes"}, clear=False),
|
||||
patch(
|
||||
"asyncio.create_subprocess_exec", new_callable=AsyncMock
|
||||
) as mock_exec,
|
||||
):
|
||||
mock_exec.return_value = mock_process
|
||||
events = [
|
||||
e
|
||||
async for e in session.start_task(
|
||||
"adapter prompt",
|
||||
session_id="sess_fake",
|
||||
fork_session=True,
|
||||
)
|
||||
]
|
||||
|
||||
assert adapter.request == CliTaskRequest(
|
||||
prompt="adapter prompt",
|
||||
session_id="sess_fake",
|
||||
fork_session=True,
|
||||
)
|
||||
assert mock_exec.call_args.args == ("fake-cli", "--prompt", "adapter prompt")
|
||||
assert mock_exec.call_args.kwargs["env"] == {"FAKE_ENV": "yes"}
|
||||
assert session.current_session_id == "fake_session"
|
||||
assert events[:2] == [
|
||||
{"type": "session_info", "session_id": "fake_session"},
|
||||
{"type": "message", "content": "hello"},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_exception(self):
|
||||
"""Test exception handling during stop."""
|
||||
|
||||
@@ -3,6 +3,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
def _fake_client_cli_adapter():
|
||||
adapter = MagicMock()
|
||||
adapter.id = "fake"
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_real_session_id_moves_pending_to_active_and_maps():
|
||||
from cli.manager import CLISessionManager
|
||||
@@ -104,3 +110,23 @@ async def test_stop_all_handles_stop_exceptions():
|
||||
s2.stop.assert_awaited_once()
|
||||
assert manager.get_stats()["active_sessions"] == 0
|
||||
assert manager.get_stats()["pending_sessions"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_session_passes_client_cli_adapter():
|
||||
from cli.manager import CLISessionManager
|
||||
|
||||
adapter = _fake_client_cli_adapter()
|
||||
with patch("cli.manager.CLISession") as mock_session_cls:
|
||||
mock_session = MagicMock()
|
||||
mock_session.is_busy = False
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
manager = CLISessionManager(
|
||||
workspace_path="/tmp",
|
||||
api_url="http://x/v1",
|
||||
client_cli_adapter=adapter,
|
||||
)
|
||||
await manager.get_or_create_session()
|
||||
|
||||
assert mock_session_cls.call_args.kwargs["client_cli_adapter"] is adapter
|
||||
|
||||
Reference in New Issue
Block a user