browser-control-cli 0.1.1

A tiny, fast Rust CLI that drives a real browser over the Chrome DevTools Protocol, for coding agents.
use anyhow::{bail, Result};
use std::{
    fs,
    io::Read,
    path::{Path, PathBuf},
    process::Command,
    time::{SystemTime, UNIX_EPOCH},
};

pub fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64
}

pub fn debug(msg: &str) {
    if std::env::var("BROWSER_CONTROL_DEBUG").is_ok() {
        eprintln!("{msg}");
    }
}

pub fn init() -> Result<()> {
    for d in [
        "scripts",
        "helpers",
        "traces",
        "domain-skills",
        "interaction-skills",
    ] {
        fs::create_dir_all(format!(".browser-control/{d}"))?;
    }
    for root in asset_roots() {
        copy_dir(
            &root.join("skills/domain-skills"),
            Path::new(".browser-control/domain-skills"),
        )?;
        copy_dir(
            &root.join("skills/interaction-skills"),
            Path::new(".browser-control/interaction-skills"),
        )?;
    }
    fs::write(".browser-control/AGENTS.md", "# browser-control\n\nUse `browser-control snapshot` first. Use refs for simple actions. Use `eval` or `cdp` when stuck. Write reusable scripts in `.browser-control/scripts`. Verify before claiming success.\n")?;
    println!(".browser-control");
    Ok(())
}

pub fn asset_roots() -> Vec<PathBuf> {
    let mut roots = vec![];
    if let Ok(p) = std::env::var("BROWSER_CONTROL_ASSET_DIR") {
        roots.push(PathBuf::from(p));
    }
    roots.push(PathBuf::from("."));
    if let Ok(exe) = std::env::current_exe() {
        if let Some(bin) = exe.parent() {
            roots.push(bin.to_path_buf());
            if let Some(prefix) = bin.parent().and_then(|p| p.parent()) {
                roots.push(prefix.to_path_buf());
                roots.push(prefix.join("share/browser-control"));
            }
        }
    }
    roots
}

fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
    if !src.exists() {
        return Ok(());
    }
    fs::create_dir_all(dst)?;
    for e in fs::read_dir(src)? {
        let e = e?;
        let sp = e.path();
        let dp = dst.join(e.file_name());
        if sp.is_dir() {
            copy_dir(&sp, &dp)?;
        } else if !dp.exists() {
            fs::copy(&sp, &dp)?;
        }
    }
    Ok(())
}

pub fn current_tab_path() -> &'static Path {
    Path::new(".browser-control/current-tab")
}

pub fn read_current_tab() -> Option<String> {
    fs::read_to_string(current_tab_path())
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

pub fn write_current_tab(id: &str) -> Result<()> {
    fs::create_dir_all(".browser-control")?;
    fs::write(current_tab_path(), id)?;
    Ok(())
}

pub fn clear_current_tab() -> Result<()> {
    let _ = fs::remove_file(current_tab_path());
    Ok(())
}

pub fn run_stdin() -> Result<()> {
    let mut src = String::new();
    std::io::stdin().read_to_string(&mut src)?;
    if src.trim().is_empty() {
        bail!("no command and empty stdin")
    }
    fs::create_dir_all(".browser-control")?;
    let p = ".browser-control/stdin.sh";
    fs::write(p, src)?;
    run_script(p, &[])
}

pub fn run_script(script: &str, args: &[String]) -> Result<()> {
    let exe = std::env::current_exe()?;
    let mut c = match script.rsplit_once('.').map(|x| x.1) {
        Some("py") => {
            let mut c = Command::new("python3");
            c.arg(script);
            c
        }
        Some("js") => {
            let mut c = Command::new("node");
            c.arg(script);
            c
        }
        Some("ts") => {
            let mut c = Command::new("bun");
            c.arg(script);
            c
        }
        Some("sh") => {
            let mut c = Command::new("bash");
            c.arg(script);
            c
        }
        _ => Command::new(script),
    };
    let s = c
        .args(args)
        .env("BROWSER_CONTROL_BIN", exe)
        .env("BROWSER_CONTROL_WORKSPACE", ".browser-control")
        .status()?;
    if !s.success() {
        bail!("script exited with {s}")
    }
    Ok(())
}

pub async fn write_failure_trace(err: &anyhow::Error) -> Result<()> {
    if std::env::var("BROWSER_CONTROL_NO_TRACE").is_ok() || std::env::args().any(|a| a == "daemon")
    {
        return Ok(());
    }
    let dir = PathBuf::from(format!(".browser-control/traces/{}", now_ms()));
    fs::create_dir_all(&dir)?;
    fs::write(dir.join("error.txt"), format!("{err:#}"))?;
    if !crate::daemon::daemon_ready() {
        return Ok(());
    }
    for (name, cmd) in [
        ("daemon-state.json", serde_json::json!({"cmd":"state"})),
        ("events.json", serde_json::json!({"cmd":"events"})),
        ("network.json", serde_json::json!({"cmd":"network"})),
        ("console.json", serde_json::json!({"cmd":"console"})),
    ] {
        if let Ok(v) = crate::daemon::daemon_command(cmd).await {
            fs::write(dir.join(name), serde_json::to_string_pretty(&v)?)?;
        }
    }
    debug(&format!("failure trace: {}", dir.display()));
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn asset_roots_include_cwd() {
        assert!(asset_roots().iter().any(|p| p == Path::new(".")));
    }
}