rexec 0.1.1

Command execution aggregator for AI agents: a per-user host that runs commands in fresh PTYs, serialises their output to a shared console, strips ANSI escapes for the calling agent, and journals every run to a JSONL transcript.
Documentation
use std::fs::{File, OpenOptions, read_dir};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use crate::protocol::TranscriptEntry;

pub fn dir() -> std::io::Result<PathBuf> {
    let home = std::env::var_os("HOME")
        .ok_or_else(|| std::io::Error::other("HOME not set"))?;
    let path = PathBuf::from(home).join(".rexec");
    std::fs::create_dir_all(&path)?;
    Ok(path)
}

pub fn path_for(name: &str) -> std::io::Result<PathBuf> {
    let mut p = dir()?;
    p.push(format!("{name}.jsonl"));
    Ok(p)
}

pub struct TranscriptWriter {
    file: Mutex<BufWriter<File>>,
}

impl TranscriptWriter {
    pub fn create(name: &str) -> std::io::Result<Self> {
        let path = path_for(name)?;
        let file = OpenOptions::new()
            .create_new(true)
            .append(true)
            .open(&path)?;
        Ok(Self {
            file: Mutex::new(BufWriter::new(file)),
        })
    }

    pub fn append(&self, entry: &TranscriptEntry) -> std::io::Result<()> {
        let line = serde_json::to_string(entry)
            .map_err(|e| std::io::Error::other(format!("serialize transcript: {e}")))?;
        let mut g = self.file.lock().unwrap();
        g.write_all(line.as_bytes())?;
        g.write_all(b"\n")?;
        g.flush()?;
        Ok(())
    }
}

pub fn read_entries(path: &Path) -> std::io::Result<Vec<TranscriptEntry>> {
    let f = File::open(path)?;
    let reader = BufReader::new(f);
    let mut out = Vec::new();
    for line in reader.lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        match serde_json::from_str::<TranscriptEntry>(&line) {
            Ok(e) => out.push(e),
            Err(_) => continue,
        }
    }
    Ok(out)
}

pub struct TranscriptListing {
    pub name: String,
    pub command_count: usize,
}

pub fn list_recent(limit: usize) -> std::io::Result<Vec<TranscriptListing>> {
    let d = dir()?;
    let mut names: Vec<String> = Vec::new();
    for entry in read_dir(&d)? {
        let entry = entry?;
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
            continue;
        }
        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
            names.push(stem.to_string());
        }
    }
    names.sort();
    names.reverse();
    names.truncate(limit);

    let mut out = Vec::with_capacity(names.len());
    for name in names {
        let path = d.join(format!("{name}.jsonl"));
        let count = count_lines(&path).unwrap_or(0);
        out.push(TranscriptListing {
            name,
            command_count: count,
        });
    }
    Ok(out)
}

fn count_lines(path: &Path) -> std::io::Result<usize> {
    let f = File::open(path)?;
    let reader = BufReader::new(f);
    let mut count = 0;
    for line in reader.lines() {
        let line = line?;
        if !line.trim().is_empty() {
            count += 1;
        }
    }
    Ok(count)
}