Files
free-claude-code/wrapper/cli_session.py
T
2026-01-29 01:26:15 -08:00

116 lines
4.0 KiB
Python

import asyncio
import os
import json
import logging
from typing import AsyncGenerator, Optional
logger = logging.getLogger(__name__)
class CLISession:
"""Manages a single persistent Claude Code CLI subprocess."""
def __init__(
self,
workspace_path: str,
api_url: str,
allowed_dirs: Optional[list[str]] = None,
):
self.workspace = workspace_path
self.api_url = api_url
self.allowed_dirs = allowed_dirs or []
self.process: Optional[asyncio.subprocess.Process] = None
async def start_task(self, prompt: str) -> AsyncGenerator[dict, None]:
"""Runs a single prompt and yields JSON events from the CLI."""
env = os.environ.copy()
# FIX for 404:
# ANTHROPIC_API_URL usually wants the full path to the API endpoint or version root
env["ANTHROPIC_API_URL"] = self.api_url
# ANTHROPIC_BASE_URL usually wants the server root, and the client appends /v1/messages
# If self.api_url is "http://localhost:8082/v1", base should be "http://localhost:8082"
if self.api_url.endswith("/v1"):
env["ANTHROPIC_BASE_URL"] = self.api_url[:-3]
else:
env["ANTHROPIC_BASE_URL"] = self.api_url
# Ensure we don't try to use interactive TTY features
env["TERM"] = "dumb"
# Ensure path is normalized for Windows to avoid \a (bell) character issues
normalized_workspace = os.path.normpath(self.workspace)
cmd = [
"claude",
"-p",
prompt,
"--output-format",
"stream-json",
"--dangerously-skip-permissions",
"--verbose",
]
if self.allowed_dirs:
for d in self.allowed_dirs:
cmd.extend(["--add-dir", os.path.normpath(d)])
prompt_preview = prompt[:100].replace("\n", " ") + (
"..." if len(prompt) > 100 else ""
)
logger.info(
f'CLI_TASK: workspace={normalized_workspace} prompt_len={len(prompt)} preview="{prompt_preview}"'
)
self.process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=normalized_workspace,
env=env,
)
# Read stdout in chunks to handle long lines
buffer = bytearray()
while True:
chunk = await self.process.stdout.read(65536)
if not chunk:
if buffer:
line_str = buffer.decode("utf-8", errors="replace").strip()
if line_str:
async for event in self._handle_line_gen(line_str):
yield event
break
buffer.extend(chunk)
while True:
newline_pos = buffer.find(b"\n")
if newline_pos == -1:
break
line = buffer[:newline_pos]
buffer = buffer[newline_pos + 1 :]
line_str = line.decode("utf-8", errors="replace").strip()
if line_str:
async for event in self._handle_line_gen(line_str):
yield event
async def _handle_line_gen(self, line_str: str) -> AsyncGenerator[dict, None]:
try:
event = json.loads(line_str)
yield event
except json.JSONDecodeError:
# Log non-JSON lines for debugging but don't crash
logger.debug(f"Non-JSON output: {line_str}")
yield {"type": "raw", "content": line_str}
# Capture remaining stderr if the process crashed
stderr_output = await self.process.stderr.read()
if stderr_output:
logger.error(
f"Claude CLI Stderr: {stderr_output.decode('utf-8', errors='replace')}"
)
return_code = await self.process.wait()
logger.info(f"Claude CLI exited with code {return_code}")
yield {"type": "exit", "code": return_code}