claude-code-cli-acp 0.1.1

An ACP-compatible adapter for the real Claude Code CLI
Documentation
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct PermissionDialog {
    pub title: String,
    pub options: Vec<PermissionDialogOption>,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct PermissionDialogOption {
    pub ordinal: usize,
    pub accelerator: Option<String>,
    pub label: String,
    pub decision: PermissionDecision,
}

#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PermissionDecision {
    AllowOnce,
    AllowAlways,
    Reject,
}

#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ScreenStatus {
    Idle,
    Thinking,
    WorkspaceTrust,
    Permission,
    Error,
    Exited,
    Unknown,
}

pub fn recognize_screen(text: &str) -> ScreenStatus {
    if recognize_exit(text) {
        ScreenStatus::Exited
    } else if recognize_error(text) {
        ScreenStatus::Error
    } else if recognize_workspace_trust(text) {
        ScreenStatus::WorkspaceTrust
    } else if recognize_permission(text) {
        ScreenStatus::Permission
    } else if recognize_thinking(text) {
        ScreenStatus::Thinking
    } else if recognize_idle(text) {
        ScreenStatus::Idle
    } else {
        ScreenStatus::Unknown
    }
}

pub fn recognize_idle(text: &str) -> bool {
    let normalized = normalize(text);
    normalized.contains("\n>")
        || normalized.contains("│ >")
        || normalized.ends_with("> ")
        || normalized.contains("\n")
        || normalized.ends_with("")
}

pub fn recognize_thinking(text: &str) -> bool {
    let normalized = normalize(text);
    normalized.contains("thinking")
        || normalized.contains("working")
        || normalized.contains("esc to interrupt")
}

pub fn recognize_permission(text: &str) -> bool {
    recognize_permission_dialog(text).is_some()
}

pub fn recognize_permission_dialog(text: &str) -> Option<PermissionDialog> {
    let normalized = normalize(text);
    if !(normalized.contains("permission")
        || normalized.contains("allow this action")
        || normalized.contains("do you want"))
    {
        return None;
    }

    let options = text
        .lines()
        .filter_map(parse_permission_option)
        .collect::<Vec<_>>();
    if options.is_empty() {
        return None;
    }

    Some(PermissionDialog {
        title: permission_title(text),
        options,
    })
}

pub fn recognize_workspace_trust(text: &str) -> bool {
    let normalized = normalize(text);
    normalized.contains("is this a project you created or one you trust")
        && normalized.contains("yes, i trust this folder")
        && normalized.contains("no, exit")
}

pub fn recognize_error(text: &str) -> bool {
    let normalized = normalize(text);
    normalized.contains("error:") || normalized.contains("failed") || normalized.contains("panic")
}

pub fn recognize_exit(text: &str) -> bool {
    let normalized = normalize(text);
    normalized.contains("goodbye")
        || normalized.contains("session ended")
        || normalized.contains("exited")
}

fn normalize(text: &str) -> String {
    text.to_lowercase()
}

fn parse_permission_option(line: &str) -> Option<PermissionDialogOption> {
    let trimmed = line.trim_start_matches([' ', '']).trim();
    let (ordinal, rest) = parse_option_prefix(trimmed)?;
    let label = rest.trim().to_string();
    let decision = classify_permission_label(&label)?;
    Some(PermissionDialogOption {
        ordinal,
        accelerator: Some(ordinal.to_string()),
        label,
        decision,
    })
}

fn parse_option_prefix(line: &str) -> Option<(usize, &str)> {
    if let Some(rest) = line.strip_prefix('[') {
        let (number, rest) = rest.split_once(']')?;
        return Some((number.parse().ok()?, rest));
    }

    let mut digits_end = 0;
    for (index, character) in line.char_indices() {
        if character.is_ascii_digit() {
            digits_end = index + character.len_utf8();
        } else {
            break;
        }
    }
    if digits_end == 0 {
        return None;
    }
    let rest = line[digits_end..].trim_start();
    let rest = rest.strip_prefix('.').or_else(|| rest.strip_prefix(')'))?;
    Some((line[..digits_end].parse().ok()?, rest))
}

fn classify_permission_label(label: &str) -> Option<PermissionDecision> {
    let normalized = normalize(label);
    if normalized.contains("always")
        || normalized.contains("don't ask")
        || normalized.contains("do not ask")
        || normalized.contains("remember")
        || normalized.contains("future")
        || normalized.contains("session")
    {
        Some(PermissionDecision::AllowAlways)
    } else if normalized.contains("deny")
        || normalized.contains("no")
        || normalized.contains("reject")
        || normalized.contains("decline")
        || normalized.contains("abort")
    {
        Some(PermissionDecision::Reject)
    } else if normalized.contains("allow")
        || normalized.contains("yes")
        || normalized.contains("proceed")
    {
        Some(PermissionDecision::AllowOnce)
    } else {
        None
    }
}

fn permission_title(text: &str) -> String {
    text.lines()
        .map(str::trim)
        .find(|line| {
            let normalized = normalize(line);
            normalized.contains("do you want") || normalized.contains("permission")
        })
        .unwrap_or("Claude permission request")
        .to_string()
}