claude-code-cli-acp 0.1.1

An ACP-compatible adapter for the real Claude Code CLI
Documentation
use agent_client_protocol::schema::{
    ConfigOptionUpdate, CurrentModeUpdate, ModelInfo, SessionConfigOption,
    SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId, SessionMode,
    SessionModeState, SessionModelState, SessionUpdate,
};

use crate::config::settings::ClaudeSettings;

const DEFAULT_MODEL_ID: &str = "default";
const DEFAULT_EFFORT: &str = "high";

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionConfigState {
    mode: String,
    model: String,
    effort: String,
    models: Vec<ModelChoice>,
    allow_bypass: bool,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ModelChoice {
    pub id: String,
    pub name: String,
    pub description: Option<String>,
}

impl SessionConfigState {
    pub fn from_settings(settings: &ClaudeSettings) -> Self {
        let allow_bypass = allow_bypass_permissions();
        let mut models = configured_models(settings.available_models.as_deref());
        let model = resolve_model(
            &models,
            std::env::var("ANTHROPIC_MODEL")
                .ok()
                .as_deref()
                .or(settings.model.as_deref()),
        )
        .unwrap_or_else(|| DEFAULT_MODEL_ID.to_string());
        if !models.iter().any(|choice| choice.id == model) {
            models.push(ModelChoice {
                id: model.clone(),
                name: model.clone(),
                description: None,
            });
        }
        let mode =
            resolve_permission_mode(settings.permissions.default_mode.as_deref(), allow_bypass);
        let effort = normalize_effort(settings.effort_level.as_deref())
            .unwrap_or_else(|| DEFAULT_EFFORT.to_string());

        Self {
            mode,
            model,
            effort,
            models,
            allow_bypass,
        }
    }

    pub fn mode(&self) -> &str {
        &self.mode
    }

    pub fn model(&self) -> &str {
        &self.model
    }

    pub fn effort(&self) -> &str {
        &self.effort
    }

    pub fn modes(&self) -> SessionModeState {
        SessionModeState::new(self.mode.clone(), available_modes(self.allow_bypass))
    }

    pub fn models(&self) -> SessionModelState {
        SessionModelState::new(
            self.model.clone(),
            self.models
                .iter()
                .map(|choice| {
                    ModelInfo::new(choice.id.clone(), choice.name.clone())
                        .description(choice.description.clone())
                })
                .collect(),
        )
    }

    pub fn config_options(&self) -> Vec<SessionConfigOption> {
        vec![
            SessionConfigOption::select(
                "mode",
                "Mode",
                self.mode.clone(),
                available_modes(self.allow_bypass)
                    .into_iter()
                    .map(|mode| {
                        SessionConfigSelectOption::new(mode.id.0.to_string(), mode.name)
                            .description(mode.description)
                    })
                    .collect::<Vec<_>>(),
            )
            .description("Session permission mode")
            .category(SessionConfigOptionCategory::Mode),
            SessionConfigOption::select(
                "model",
                "Model",
                self.model.clone(),
                self.models
                    .iter()
                    .map(|choice| {
                        SessionConfigSelectOption::new(choice.id.clone(), choice.name.clone())
                            .description(choice.description.clone())
                    })
                    .collect::<Vec<_>>(),
            )
            .description("Claude model to use")
            .category(SessionConfigOptionCategory::Model),
            SessionConfigOption::select(
                "effort",
                "Effort",
                self.effort.clone(),
                ["low", "medium", "high", "xhigh", "max"]
                    .into_iter()
                    .map(|level| SessionConfigSelectOption::new(level, effort_label(level)))
                    .collect::<Vec<_>>(),
            )
            .description("Claude reasoning effort level")
            .category(SessionConfigOptionCategory::ThoughtLevel),
        ]
    }

    pub fn set_mode(&mut self, mode: &str) -> anyhow::Result<()> {
        let resolved = resolve_permission_mode(Some(mode), self.allow_bypass);
        if resolved == "default" && !mode.trim().eq_ignore_ascii_case("default") {
            anyhow::bail!("invalid permission mode: {mode}");
        }
        self.mode = resolved;
        Ok(())
    }

    pub fn set_model(&mut self, model: &str) -> anyhow::Result<String> {
        let Some(resolved) = resolve_model(&self.models, Some(model)) else {
            anyhow::bail!("invalid model: {model}");
        };
        self.model = resolved.clone();
        Ok(resolved)
    }

    pub fn set_effort(&mut self, effort: &str) -> anyhow::Result<()> {
        let Some(resolved) = normalize_effort(Some(effort)) else {
            anyhow::bail!("invalid effort: {effort}");
        };
        self.effort = resolved;
        Ok(())
    }

    pub fn set_option(
        &mut self,
        config_id: &str,
        value: &SessionConfigValueId,
    ) -> anyhow::Result<Option<SessionUpdate>> {
        match config_id {
            "mode" => {
                self.set_mode(value.0.as_ref())?;
                Ok(Some(SessionUpdate::CurrentModeUpdate(
                    CurrentModeUpdate::new(self.mode.clone()),
                )))
            }
            "model" => {
                self.set_model(value.0.as_ref())?;
                Ok(Some(SessionUpdate::ConfigOptionUpdate(
                    ConfigOptionUpdate::new(self.config_options()),
                )))
            }
            "effort" => {
                self.set_effort(value.0.as_ref())?;
                Ok(Some(SessionUpdate::ConfigOptionUpdate(
                    ConfigOptionUpdate::new(self.config_options()),
                )))
            }
            _ => anyhow::bail!("unknown config option: {config_id}"),
        }
    }
}

pub fn resolve_permission_mode(default_mode: Option<&str>, allow_bypass: bool) -> String {
    let Some(default_mode) = default_mode else {
        return "default".to_string();
    };
    let normalized = default_mode.trim().to_ascii_lowercase();
    match normalized.as_str() {
        "auto" => "auto".to_string(),
        "default" => "default".to_string(),
        "acceptedits" => "acceptEdits".to_string(),
        "dontask" => "dontAsk".to_string(),
        "plan" => "plan".to_string(),
        "bypasspermissions" | "bypass" if allow_bypass => "bypassPermissions".to_string(),
        _ => "default".to_string(),
    }
}

pub fn available_modes(allow_bypass: bool) -> Vec<SessionMode> {
    let mut modes = vec![
        SessionMode::new("auto", "Auto")
            .description("Use Claude's auto mode for permission prompts"),
        SessionMode::new("default", "Default")
            .description("Standard behavior, prompts for dangerous operations"),
        SessionMode::new("acceptEdits", "Accept Edits")
            .description("Auto-accept file edit operations"),
        SessionMode::new("plan", "Plan Mode").description("Planning mode, no tool execution"),
        SessionMode::new("dontAsk", "Don't Ask")
            .description("Deny unapproved permission prompts instead of asking"),
    ];
    if allow_bypass {
        modes.push(
            SessionMode::new("bypassPermissions", "Bypass Permissions")
                .description("Bypass all permission checks"),
        );
    }
    modes
}

pub fn allow_bypass_permissions() -> bool {
    !running_as_root() || std::env::var_os("IS_SANDBOX").is_some()
}

fn configured_models(allowlist: Option<&[String]>) -> Vec<ModelChoice> {
    let mut models = vec![ModelChoice {
        id: DEFAULT_MODEL_ID.to_string(),
        name: "Default".to_string(),
        description: Some("Claude Code default model".to_string()),
    }];

    let configured = allowlist
        .map(|models| models.to_vec())
        .unwrap_or_else(|| vec!["sonnet".to_string(), "opus".to_string()]);
    for model in configured {
        let trimmed = model.trim();
        if trimmed.is_empty() || models.iter().any(|choice| choice.id == trimmed) {
            continue;
        }
        models.push(ModelChoice {
            id: trimmed.to_string(),
            name: model_label(trimmed),
            description: None,
        });
    }
    models
}

fn resolve_model(models: &[ModelChoice], preference: Option<&str>) -> Option<String> {
    let preference = preference?.trim();
    if preference.is_empty() {
        return None;
    }
    let lower = preference.to_ascii_lowercase();
    models
        .iter()
        .find(|model| {
            let id = model.id.to_ascii_lowercase();
            let name = model.name.to_ascii_lowercase();
            id == lower || name == lower || id.contains(&lower) || lower.contains(&id)
        })
        .map(|model| model.id.clone())
}

fn normalize_effort(effort: Option<&str>) -> Option<String> {
    match effort?.trim().to_ascii_lowercase().as_str() {
        "low" => Some("low".to_string()),
        "medium" => Some("medium".to_string()),
        "high" => Some("high".to_string()),
        "xhigh" | "extra-high" | "extra_high" => Some("xhigh".to_string()),
        "max" => Some("max".to_string()),
        _ => None,
    }
}

fn model_label(model: &str) -> String {
    if model == DEFAULT_MODEL_ID {
        return "Default".to_string();
    }
    let mut chars = model.chars();
    match chars.next() {
        Some(first) => first.to_uppercase().chain(chars).collect(),
        None => model.to_string(),
    }
}

fn effort_label(level: &str) -> String {
    match level {
        "xhigh" => "XHigh".to_string(),
        _ => model_label(level),
    }
}

#[cfg(unix)]
fn running_as_root() -> bool {
    unsafe extern "C" {
        fn geteuid() -> u32;
    }
    unsafe { geteuid() == 0 }
}

#[cfg(not(unix))]
fn running_as_root() -> bool {
    false
}