claude-code-cli-acp 0.1.1

An ACP-compatible adapter for the real Claude Code CLI
Documentation
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};

use agent_client_protocol::schema::McpServer;
use anyhow::{Context, anyhow};
use portable_pty::{Child, CommandBuilder, PtySize, native_pty_system};
use serde_json::{Value, json};
use uuid::Uuid;

use crate::pty::input;
use crate::terminal::{
    recognizers::{PermissionDecision, PermissionDialog, recognize_permission_dialog},
    screen::TerminalScreen,
};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClaudePtyConfig {
    pub executable: PathBuf,
    pub cwd: PathBuf,
    pub session_id: String,
    pub model: Option<String>,
    pub permission_mode: Option<String>,
    pub setting_sources: Option<String>,
    pub resume: Option<String>,
    pub continue_last: bool,
    pub mcp_servers: Vec<McpServer>,
    pub extra_args: Vec<OsString>,
    pub rows: u16,
    pub cols: u16,
}

impl Default for ClaudePtyConfig {
    fn default() -> Self {
        Self {
            executable: PathBuf::from("claude"),
            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
            session_id: uuid::Uuid::new_v4().to_string(),
            model: None,
            permission_mode: None,
            setting_sources: None,
            resume: None,
            continue_last: false,
            mcp_servers: Vec::new(),
            extra_args: Vec::new(),
            rows: 24,
            cols: 80,
        }
    }
}

impl ClaudePtyConfig {
    pub fn launch_argv(&self) -> anyhow::Result<Vec<OsString>> {
        let mut argv = Vec::new();
        argv.push(self.executable.clone().into_os_string());
        argv.push("--session-id".into());
        argv.push(self.session_id.clone().into());
        if let Some(model) = &self.model {
            argv.push("--model".into());
            argv.push(model.into());
        }
        if let Some(permission_mode) = &self.permission_mode {
            argv.push("--permission-mode".into());
            argv.push(permission_mode.into());
        }
        if let Some(setting_sources) = &self.setting_sources {
            argv.push("--setting-sources".into());
            argv.push(setting_sources.into());
        }
        if let Some(session) = &self.resume {
            argv.push("--resume".into());
            argv.push(session.into());
        }
        if self.continue_last {
            argv.push("--continue".into());
        }
        argv.extend(self.extra_args.iter().cloned());
        reject_print_mode_args(argv.iter().skip(1))?;
        Ok(argv)
    }
}

pub struct ClaudePtySession {
    child: Box<dyn Child + Send + Sync>,
    writer: Box<dyn Write + Send>,
    screen: Arc<Mutex<TerminalScreen>>,
    reader_thread: Option<JoinHandle<()>>,
    executable: PathBuf,
    cwd: PathBuf,
    session_id: String,
    model: Option<String>,
    permission_mode: Option<String>,
    generated_mcp_config: Option<PathBuf>,
}

impl ClaudePtySession {
    pub fn spawn(config: ClaudePtyConfig) -> anyhow::Result<Self> {
        let generated_mcp_config = write_mcp_config_file(&config)?;
        let mut argv = config.launch_argv()?;
        if let Some(path) = &generated_mcp_config {
            argv.push("--mcp-config".into());
            argv.push(path.into());
            argv.push("--strict-mcp-config".into());
        }
        let mut command = CommandBuilder::new(&argv[0]);
        command.args(argv.iter().skip(1));
        command.cwd(&config.cwd);
        command.env("CLAUDE_CODE_NO_FLICKER", "1");
        command.env("CLAUDE_CODE_DISABLE_MOUSE", "1");
        command.env("CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION", "false");

        let pty_system = native_pty_system();
        let pair = pty_system
            .openpty(PtySize {
                rows: config.rows,
                cols: config.cols,
                pixel_width: 0,
                pixel_height: 0,
            })
            .context("open pty")?;
        let child = pair
            .slave
            .spawn_command(command)
            .context("spawn claude pty")?;
        let mut reader = pair.master.try_clone_reader().context("clone pty reader")?;
        let writer = pair.master.take_writer().context("take pty writer")?;
        drop(pair.slave);

        let screen = Arc::new(Mutex::new(TerminalScreen::new(config.rows, config.cols)));
        let reader_screen = Arc::clone(&screen);
        let reader_thread = thread::spawn(move || {
            let mut buffer = [0_u8; 8192];
            loop {
                match reader.read(&mut buffer) {
                    Ok(0) => break,
                    Ok(read) => {
                        if let Ok(mut screen) = reader_screen.lock() {
                            screen.process(&buffer[..read]);
                        } else {
                            break;
                        }
                    }
                    Err(_) => break,
                }
            }
        });

        Ok(Self {
            child,
            writer,
            screen,
            reader_thread: Some(reader_thread),
            executable: config.executable,
            cwd: config.cwd,
            session_id: config.session_id,
            model: config.model,
            permission_mode: config.permission_mode,
            generated_mcp_config,
        })
    }

    pub fn write_bytes(&mut self, bytes: &[u8]) -> anyhow::Result<()> {
        self.writer.write_all(bytes).context("write pty bytes")?;
        self.writer.flush().context("flush pty bytes")
    }

    pub fn submit_prompt(&mut self, prompt: &str) -> anyhow::Result<()> {
        self.write_bytes(prompt.as_bytes())?;
        thread::sleep(std::time::Duration::from_millis(100));
        self.write_bytes(b"\r")
    }

    pub fn send_exit(&mut self) -> anyhow::Result<()> {
        self.write_bytes(&input::slash_command("exit"))
    }

    pub fn send_interrupt(&mut self) -> anyhow::Result<()> {
        self.write_bytes(&input::ctrl_c())
    }

    pub fn terminate(&mut self) -> anyhow::Result<()> {
        if self.child.try_wait().context("poll pty child")?.is_none() {
            self.child.kill().context("kill pty child")?;
        }
        if let Some(path) = self.generated_mcp_config.take() {
            drop(std::fs::remove_file(path));
        }
        Ok(())
    }

    pub fn screen_snapshot(&self) -> anyhow::Result<String> {
        Ok(self
            .screen
            .lock()
            .map(|screen| screen.text())
            .unwrap_or_else(|_| String::new()))
    }

    pub fn is_idle(&self) -> bool {
        self.screen_snapshot()
            .map(|text| crate::terminal::recognizers::recognize_idle(&text))
            .unwrap_or(false)
    }

    pub fn permission_dialog(&self) -> anyhow::Result<Option<PermissionDialog>> {
        Ok(recognize_permission_dialog(&self.screen_snapshot()?))
    }

    pub fn select_permission(&mut self, decision: PermissionDecision) -> anyhow::Result<bool> {
        let Some(dialog) = self.permission_dialog()? else {
            return Ok(false);
        };
        let Some(bytes) = input::permission_choice(&dialog, decision) else {
            return Ok(false);
        };
        self.write_bytes(&bytes)?;
        Ok(true)
    }

    pub fn detach_for_user(&mut self) -> anyhow::Result<()> {
        self.terminate()?;

        let mut command = std::process::Command::new(&self.executable);
        command
            .arg("--resume")
            .arg(&self.session_id)
            .current_dir(&self.cwd);
        if let Some(model) = &self.model {
            command.arg("--model").arg(model);
        }
        if let Some(permission_mode) = &self.permission_mode {
            command.arg("--permission-mode").arg(permission_mode);
        }
        let status = command
            .status()
            .context("attach user to resumed Claude session")?;
        if status.success() {
            Ok(())
        } else {
            Err(anyhow!(
                "attached Claude session exited with status {status}"
            ))
        }
    }
}

fn write_mcp_config_file(config: &ClaudePtyConfig) -> anyhow::Result<Option<PathBuf>> {
    if config.mcp_servers.is_empty() {
        return Ok(None);
    }
    let path = std::env::temp_dir().join(format!(
        "claude-code-cli-acp-mcp-{}-{}.json",
        config.session_id,
        Uuid::new_v4()
    ));
    let body = serde_json::to_vec(&mcp_config_json(&config.mcp_servers))?;
    std::fs::write(&path, body).context("write temporary Claude MCP config")?;
    Ok(Some(path))
}

pub fn mcp_config_json(servers: &[McpServer]) -> Value {
    let mut mcp_servers = serde_json::Map::new();
    for server in servers {
        match server {
            McpServer::Stdio(server) => {
                let env = server
                    .env
                    .iter()
                    .map(|var| (var.name.clone(), Value::String(var.value.clone())))
                    .collect::<serde_json::Map<_, _>>();
                mcp_servers.insert(
                    server.name.clone(),
                    json!({
                        "type": "stdio",
                        "command": server.command,
                        "args": server.args,
                        "env": env,
                    }),
                );
            }
            McpServer::Http(server) => {
                mcp_servers.insert(
                    server.name.clone(),
                    json!({
                        "type": "http",
                        "url": server.url,
                        "headers": headers_json(&server.headers),
                    }),
                );
            }
            McpServer::Sse(server) => {
                mcp_servers.insert(
                    server.name.clone(),
                    json!({
                        "type": "sse",
                        "url": server.url,
                        "headers": headers_json(&server.headers),
                    }),
                );
            }
            _ => {}
        }
    }
    json!({ "mcpServers": mcp_servers })
}

fn headers_json(
    headers: &[agent_client_protocol::schema::HttpHeader],
) -> serde_json::Map<String, Value> {
    headers
        .iter()
        .map(|header| (header.name.clone(), Value::String(header.value.clone())))
        .collect()
}

impl Drop for ClaudePtySession {
    fn drop(&mut self) {
        drop(self.terminate());
        drop(self.reader_thread.take());
    }
}

fn reject_print_mode_args<'a>(args: impl IntoIterator<Item = &'a OsString>) -> anyhow::Result<()> {
    for arg in args {
        if arg == "-p" || arg == "--print" {
            return Err(anyhow!(
                "Claude PTY sessions must use interactive mode and cannot launch with {}",
                arg.to_string_lossy()
            ));
        }
        if arg.to_string_lossy().starts_with("--print=") {
            return Err(anyhow!(
                "Claude PTY sessions must use interactive mode and cannot launch with {}",
                arg.to_string_lossy()
            ));
        }
    }
    Ok(())
}