free-claude-code/wrapper/cli_session.py
2026-01-28 18:47:45 -08:00

97 lines
3.1 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)])
logger.info(
f"Launching Claude CLI in {normalized_workspace} with API {self.api_url}"
)
self.process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=normalized_workspace,
env=env,
)
# Read stdout line by line
while True:
line = await self.process.stdout.readline()
if not line:
break
line_str = line.decode("utf-8").strip()
if not line_str:
continue
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}