claude-code-cli-acp 0.1.1

An ACP-compatible adapter for the real Claude Code CLI
Documentation
use std::fs::File;
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::path::{Path, PathBuf};

use anyhow::Context;

use crate::transcript::events::{TranscriptEvent, parse_transcript_line};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TranscriptLocator {
    claude_home: PathBuf,
}

impl TranscriptLocator {
    pub fn new(claude_home: impl Into<PathBuf>) -> Self {
        Self {
            claude_home: claude_home.into(),
        }
    }

    pub fn default_home() -> anyhow::Result<Self> {
        let home = dirs::home_dir().context("home directory unavailable")?;
        Ok(Self::new(home.join(".claude")))
    }

    pub fn find_transcript(&self, session_id: &str) -> anyhow::Result<Option<PathBuf>> {
        let projects = self.claude_home.join("projects");
        if !projects.exists() {
            return Ok(None);
        }

        let target = format!("{session_id}.jsonl");
        find_file_named(&projects, &target)
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TranscriptTailer {
    session_id: String,
    path: PathBuf,
    offset: u64,
}

impl TranscriptTailer {
    pub fn from_path(session_id: impl Into<String>, path: impl AsRef<Path>) -> Self {
        Self {
            session_id: session_id.into(),
            path: path.as_ref().to_path_buf(),
            offset: 0,
        }
    }

    pub fn from_path_at_end(
        session_id: impl Into<String>,
        path: impl AsRef<Path>,
    ) -> anyhow::Result<Self> {
        let path = path.as_ref().to_path_buf();
        let offset = std::fs::metadata(&path)
            .with_context(|| format!("stat transcript {}", path.display()))?
            .len();
        Ok(Self {
            session_id: session_id.into(),
            path,
            offset,
        })
    }

    pub fn from_locator(
        session_id: impl Into<String>,
        locator: &TranscriptLocator,
    ) -> anyhow::Result<Option<Self>> {
        let session_id = session_id.into();
        Ok(locator
            .find_transcript(&session_id)?
            .map(|path| Self::from_path(session_id, path)))
    }

    pub fn from_locator_at_end(
        session_id: impl Into<String>,
        locator: &TranscriptLocator,
    ) -> anyhow::Result<Option<Self>> {
        let session_id = session_id.into();
        locator
            .find_transcript(&session_id)?
            .map(|path| Self::from_path_at_end(session_id, path))
            .transpose()
    }

    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn poll(&mut self) -> anyhow::Result<Vec<TranscriptEvent>> {
        let mut file = File::open(&self.path)
            .with_context(|| format!("open transcript {}", self.path.display()))?;
        file.seek(SeekFrom::Start(self.offset))
            .with_context(|| format!("seek transcript {}", self.path.display()))?;

        let mut reader = BufReader::new(file);
        let mut events = Vec::new();
        let mut line = String::new();
        loop {
            line.clear();
            let read = reader
                .read_line(&mut line)
                .with_context(|| format!("read transcript {}", self.path.display()))?;
            if read == 0 {
                break;
            }
            self.offset += read as u64;
            for event in parse_transcript_line(line.trim_end_matches(['\r', '\n']))? {
                match event.session_id() {
                    Some(session_id) if session_id == self.session_id => events.push(event),
                    None => events.push(event),
                    _ => {}
                }
            }
        }
        Ok(events)
    }
}

fn find_file_named(root: &Path, filename: &str) -> anyhow::Result<Option<PathBuf>> {
    let mut stack = vec![root.to_path_buf()];
    while let Some(path) = stack.pop() {
        for entry in std::fs::read_dir(&path)
            .with_context(|| format!("read transcript directory {}", path.display()))?
        {
            let entry = entry?;
            let entry_path = entry.path();
            let file_type = entry.file_type()?;
            if file_type.is_dir() {
                stack.push(entry_path);
            } else if file_type.is_file()
                && entry_path.file_name().and_then(|name| name.to_str()) == Some(filename)
            {
                return Ok(Some(entry_path));
            }
        }
    }
    Ok(None)
}