claude-code-sdk-rust 0.2.0

Async Rust SDK for the Claude Code CLI: streaming agent turns, tool use, and sessions.
Documentation
use crate::error::{ClaudeSDKError, Result};
use crate::session_store::{
    project_key_for_directory, SessionKey, SessionStoreEntry, SessionStoreHandle,
};
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, BufReader};

const MAX_PENDING_ENTRIES: usize = 500;
const MAX_PENDING_BYTES: usize = 1 << 20;

#[derive(Debug, Clone)]
pub struct ImportSessionOptions {
    pub directory: Option<String>,
    pub include_subagents: bool,
    pub batch_size: usize,
}

impl Default for ImportSessionOptions {
    fn default() -> Self {
        Self {
            directory: None,
            include_subagents: true,
            batch_size: MAX_PENDING_ENTRIES,
        }
    }
}

pub async fn import_session_to_store(
    session_id: &str,
    store: &SessionStoreHandle,
    options: ImportSessionOptions,
) -> Result<()> {
    validate_uuid(session_id)?;
    let resolved = resolve_session_file_path(session_id, options.directory.as_deref())
        .ok_or_else(|| ClaudeSDKError::Session(format!("Session {session_id} not found")))?;
    let project_key = resolved
        .parent()
        .and_then(|path| path.file_name())
        .and_then(|name| name.to_str())
        .ok_or_else(|| ClaudeSDKError::Session("session path has no project key".to_string()))?
        .to_string();
    let batch_size = if options.batch_size == 0 {
        MAX_PENDING_ENTRIES
    } else {
        options.batch_size
    };

    let main_key = SessionKey {
        project_key: project_key.clone(),
        session_id: session_id.to_string(),
        subpath: None,
    };
    append_jsonl_file_in_batches(&resolved, main_key, store, batch_size).await?;

    if options.include_subagents {
        import_subagents(&resolved, &project_key, session_id, store, batch_size).await?;
    }
    Ok(())
}

async fn import_subagents(
    session_file: &Path,
    project_key: &str,
    session_id: &str,
    store: &SessionStoreHandle,
    batch_size: usize,
) -> Result<()> {
    let session_dir = session_file.with_extension("");
    let subagents_dir = session_dir.join("subagents");
    for file_path in collect_jsonl_files(&subagents_dir) {
        let subpath = subpath_for_file(&session_dir, &file_path)?;
        let key = SessionKey {
            project_key: project_key.to_string(),
            session_id: session_id.to_string(),
            subpath: Some(subpath),
        };
        append_jsonl_file_in_batches(&file_path, key.clone(), store, batch_size).await?;
        if let Some(meta) = read_meta_sidecar(&file_path).await? {
            store.append(key, vec![meta]).await?;
        }
    }
    Ok(())
}

async fn append_jsonl_file_in_batches(
    file_path: &Path,
    key: SessionKey,
    store: &SessionStoreHandle,
    batch_size: usize,
) -> Result<()> {
    let file = tokio::fs::File::open(file_path).await?;
    let mut lines = BufReader::new(file).lines();
    let mut batch = Vec::new();
    let mut nbytes = 0usize;

    while let Some(line) = lines.next_line().await? {
        if line.is_empty() {
            continue;
        }
        nbytes += line.len();
        let entry = serde_json::from_str::<SessionStoreEntry>(&line)?;
        batch.push(entry);
        if batch.len() >= batch_size || nbytes >= MAX_PENDING_BYTES {
            store
                .append(key.clone(), std::mem::take(&mut batch))
                .await?;
            nbytes = 0;
        }
    }
    if !batch.is_empty() {
        store.append(key, batch).await?;
    }
    Ok(())
}

async fn read_meta_sidecar(file_path: &Path) -> Result<Option<SessionStoreEntry>> {
    let meta_path = file_path.with_file_name(format!(
        "{}.meta.json",
        file_path
            .file_stem()
            .and_then(|stem| stem.to_str())
            .unwrap_or_default()
    ));
    let content = match tokio::fs::read_to_string(&meta_path).await {
        Ok(content) => content,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => return Err(error.into()),
    };
    let mut meta = serde_json::from_str::<SessionStoreEntry>(&content)?;
    let mut entry = SessionStoreEntry::new();
    entry.insert(
        "type".to_string(),
        serde_json::Value::String("agent_metadata".to_string()),
    );
    entry.append(&mut meta);
    Ok(Some(entry))
}

fn resolve_session_file_path(session_id: &str, directory: Option<&str>) -> Option<PathBuf> {
    let file_name = format!("{session_id}.jsonl");
    if let Some(directory) = directory {
        let canonical = canonicalize_path(Path::new(directory));
        if let Some(found) =
            find_project_dir(&canonical).and_then(|dir| stat_candidate(&dir, &file_name))
        {
            return Some(found);
        }
        return None;
    }

    let projects_dir = projects_dir();
    let entries = std::fs::read_dir(projects_dir).ok()?;
    for entry in entries.flatten() {
        if entry.file_type().ok().is_some_and(|ty| ty.is_dir()) {
            if let Some(found) = stat_candidate(&entry.path(), &file_name) {
                return Some(found);
            }
        }
    }
    None
}

fn find_project_dir(project_path: &Path) -> Option<PathBuf> {
    let exact = projects_dir().join(project_key_for_directory(Some(project_path)));
    if exact.is_dir() {
        Some(exact)
    } else {
        None
    }
}

fn stat_candidate(project_dir: &Path, file_name: &str) -> Option<PathBuf> {
    let candidate = project_dir.join(file_name);
    match std::fs::metadata(&candidate) {
        Ok(metadata) if metadata.is_file() && metadata.len() > 0 => Some(candidate),
        _ => None,
    }
}

fn collect_jsonl_files(base_dir: &Path) -> Vec<PathBuf> {
    let mut output = Vec::new();
    collect_jsonl_files_inner(base_dir, &mut output);
    output
}

fn collect_jsonl_files_inner(base_dir: &Path, output: &mut Vec<PathBuf>) {
    let Ok(entries) = std::fs::read_dir(base_dir) else {
        return;
    };
    let mut entries = entries.flatten().collect::<Vec<_>>();
    entries.sort_by_key(|entry| entry.file_name());
    for entry in entries {
        let path = entry.path();
        if path.is_dir() {
            collect_jsonl_files_inner(&path, output);
        } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
            output.push(path);
        }
    }
}

fn subpath_for_file(session_dir: &Path, file_path: &Path) -> Result<String> {
    let relative = file_path.strip_prefix(session_dir).map_err(|_| {
        ClaudeSDKError::Session("subagent path escaped session directory".to_string())
    })?;
    let mut parts = relative
        .components()
        .filter_map(|component| match component {
            std::path::Component::Normal(value) => Some(value.to_string_lossy().to_string()),
            _ => None,
        })
        .collect::<Vec<_>>();
    if let Some(last) = parts.last_mut() {
        if let Some(stripped) = last.strip_suffix(".jsonl") {
            *last = stripped.to_string();
        }
    }
    Ok(parts.join("/"))
}

fn projects_dir() -> PathBuf {
    std::env::var_os("CLAUDE_CONFIG_DIR")
        .map(PathBuf::from)
        .or_else(|| dirs::home_dir().map(|home| home.join(".claude")))
        .unwrap_or_else(|| PathBuf::from(".claude"))
        .join("projects")
}

fn canonicalize_path(path: &Path) -> PathBuf {
    let absolute = if path.is_absolute() {
        path.to_path_buf()
    } else {
        std::env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("."))
            .join(path)
    };
    std::fs::canonicalize(&absolute).unwrap_or(absolute)
}

fn validate_uuid(session_id: &str) -> Result<()> {
    uuid::Uuid::parse_str(session_id)
        .map(|_| ())
        .map_err(|_| ClaudeSDKError::Session(format!("Invalid session_id: {session_id}")))
}