chrome-agent 0.14.0

Browser automation for AI agents. Single binary, zero deps, CDP direct to Chrome.
//! The `macro` surface: what an agent types, and what it reads back.
//!
//! Four verbs, and the split between them is where the browser is: `list`, `show` and `record`
//! touch files only, so they never open a page — a Chrome launched to answer "what macros exist"
//! would be the kind of cost nobody sees until the bill. Only `run` needs a browser.

use std::collections::BTreeMap;

use serde_json::{json, Value};

use crate::cli::{Cli, MacroAction};
use crate::macros::Macro;
use crate::macros_record::{self, Observed};

/// `chrome-agent macro …`
pub async fn run_cli(cli: &Cli, action: &MacroAction) -> Result<(), crate::BoxError> {
    let json_mode = cli.json;
    match action {
        MacroAction::List => {
            let names = crate::macros::list();
            if json_mode {
                let summaries: Vec<Value> =
                    names.iter().map(|name| crate::macros::summary(name)).collect();
                println!("{}", json!({"ok": true, "macros": summaries}));
            } else if names.is_empty() {
                println!(
                    "No macros yet. Record one from a session that worked: \
                     `chrome-agent macro record <name> --from-recording <file>`."
                );
            } else {
                for name in &names {
                    let summary = crate::macros::summary(name);
                    println!(
                        "{name}  steps={}  unguarded={}  site={}",
                        summary["steps"],
                        summary["unguarded_steps"],
                        summary["site"].as_str().unwrap_or("-")
                    );
                }
            }
        }
        MacroAction::Show { name } => {
            let macro_file = Macro::load(name)?;
            if json_mode {
                println!("{}", json!({"ok": true, "macro": macro_file}));
            } else {
                print!("{}", render(&macro_file));
            }
        }
        MacroAction::Record { name, from_recording, from } => {
            let report = record_from_recording(name, from_recording, *from)?;
            if json_mode {
                println!("{report}");
            } else {
                print!("{}", render_record(&report));
            }
        }
        MacroAction::Run { name, var } => {
            let vars = parse_vars(var)?;
            let report = crate::macros_run::run(cli, name, &vars).await?;
            if report.get("ok").and_then(Value::as_bool) == Some(true) {
                if json_mode {
                    println!("{report}");
                } else {
                    print!("{}", crate::macros_run::render_run(&report));
                }
            } else {
                // A macro whose guard did not hold is a failure of the run, and a shell that
                // chains on it has to see that. It carries its own report and prints it once —
                // `1`, not `2`: `2` means an assertion did not hold and belongs to `assert`.
                return Err(Box::new(crate::macros_run::Stopped::new(report, json_mode)));
            }
        }
    }
    Ok(())
}

/// `--var k=v` into a map, refusing the shapes that silently do nothing.
pub fn parse_vars(pairs: &[String]) -> Result<BTreeMap<String, String>, crate::BoxError> {
    let mut vars = BTreeMap::new();
    for pair in pairs {
        let (key, value) = pair.split_once('=').ok_or_else(|| {
            format!("--var expects name=value, got '{pair}'. Nothing was run.")
        })?;
        if key.is_empty() {
            return Err(format!("--var '{pair}' has no name. Nothing was run.").into());
        }
        vars.insert(key.to_string(), value.to_string());
    }
    Ok(vars)
}

/// Distil a `_record` file. The pure part is `macros_record::distil`; this is the file half.
pub fn record_from_recording(
    name: &str,
    path: &str,
    from: Option<usize>,
) -> Result<Value, crate::BoxError> {
    let text = std::fs::read_to_string(path)
        .map_err(|e| format!("Cannot read the recording '{path}': {e}"))?;
    // The snapshot a step's uid has to be read against is the last one the session took, and a
    // recording holds those too: the `inspect` responses it kept while the agent explored.
    let mut snapshot: Option<String> = None;
    let mut history: Vec<Observed> = Vec::new();
    for line in text.lines().filter(|line| !line.trim().is_empty()) {
        let Ok(entry) = serde_json::from_str::<Value>(line) else { continue };
        let cmd = entry.get("cmd").unwrap_or(&Value::Null);
        let response = entry.get("response").unwrap_or(&Value::Null);
        history.push(Observed::read_with_snapshot(cmd, response, snapshot.as_deref()));
        if let Some(fresh) = response.get("snapshot").and_then(Value::as_str) {
            snapshot = Some(fresh.to_string());
        }
    }
    if history.is_empty() {
        return Err(format!(
            "The recording '{path}' holds no command. A pipe session records the commands that \
             carry `_record`, so a session recorded from its second command starts there."
        )
        .into());
    }
    save_distilled(name, &history, from)
}

/// Shared by the CLI and by the pipe command: distil, save, and report what did not make it.
pub fn save_distilled(
    name: &str,
    history: &[Observed],
    from: Option<usize>,
) -> Result<Value, crate::BoxError> {
    crate::macros::check_name(name)?;
    let start = from.unwrap_or_else(|| macros_record::default_start(history));
    let distilled = macros_record::distil(name, history, start)?;
    let path = distilled.macro_file.save()?;
    let unguarded = distilled.macro_file.steps.iter().filter(|s| s.expect.is_empty()).count();
    Ok(json!({
        "ok": true,
        "macro": distilled.macro_file.name,
        "path": path.display().to_string(),
        // Which entry the task was taken to start at, and whether that was the caller's choice.
        // The design suspected a marker; this is the same information, chosen with hindsight,
        // and it is printed rather than assumed — `--from` overrides it.
        "started_at": start,
        "started_by": if from.is_some() { "you" } else { "the last navigation" },
        "steps": distilled.macro_file.steps.len(),
        "unguarded_steps": unguarded,
        "params": distilled.macro_file.params.keys().collect::<Vec<_>>(),
        "dropped": distilled.dropped.iter().map(|r| json!({"index": r.index, "reason": r.reason})).collect::<Vec<_>>(),
        // Not the same thing as dropped, and the difference is the whole point: these acted on
        // the page and could not be written down, so the macro is SHORTER than the task.
        "refused": distilled.refused.iter().map(|r| json!({"index": r.index, "reason": r.reason})).collect::<Vec<_>>(),
    }))
}

/// `{"cmd":"macro", …}` inside a pipe session.
///
/// The session's own history is the source here, which is what makes recording possible without
/// planning for it: the agent finds out that the task worked, and only then asks for it to be
/// kept.
pub fn dispatch_pipe(cmd: &Value, history: &[Observed]) -> Result<Value, crate::BoxError> {
    let action = cmd.get("action").and_then(Value::as_str).unwrap_or("record");
    match action {
        "record" => {
            let name = cmd
                .get("name")
                .and_then(Value::as_str)
                .ok_or("macro record: give it a \"name\".")?;
            let from = cmd.get("from").and_then(Value::as_u64).map(|n| n as usize);
            save_distilled(name, history, from)
        }
        "list" => Ok(json!({
            "ok": true,
            "macros": crate::macros::list().iter().map(|n| crate::macros::summary(n)).collect::<Vec<_>>()
        })),
        "show" => {
            let name = cmd.get("name").and_then(Value::as_str).ok_or("macro show: give it a \"name\".")?;
            Ok(json!({"ok": true, "macro": Macro::load(name)?}))
        }
        other => Err(format!(
            "macro: unknown action {other:?}. In a pipe session the actions are \"record\", \
             \"list\" and \"show\" — running a macro inside the session that is recording it \
             would be recording the run."
        )
        .into()),
    }
}

/// One macro, for a person.
fn render(macro_file: &Macro) -> String {
    let mut out = format!("{}\n", macro_file.name);
    if let Some(site) = &macro_file.site {
        out.push_str(&format!("site: {site}\n"));
    }
    for (name, param) in &macro_file.params {
        out.push_str(&format!(
            "param: {name}{}{}\n",
            if param.required { " (required)" } else { "" },
            if param.secret { " SECRET — never stored, pass it every run" } else { "" }
        ));
    }
    for (index, step) in macro_file.steps.iter().enumerate() {
        let verb = step.action.get("cmd").and_then(Value::as_str).unwrap_or("?");
        let guards = serde_json::to_string(&step.expect).unwrap_or_default();
        out.push_str(&format!("{index}. {verb} {}\n", compact_action(&step.action)));
        if let Some(reason) = &step.unguarded {
            out.push_str(&format!("   UNGUARDED — {reason}\n"));
        } else {
            out.push_str(&format!("   expect {guards}\n"));
        }
    }
    out
}

fn compact_action(action: &Value) -> String {
    let mut parts = Vec::new();
    for key in ["url", "selector", "role", "name", "value", "key", "text"] {
        if let Some(value) = action.get(key).and_then(Value::as_str) {
            parts.push(format!("{key}={value}"));
        }
    }
    parts.join(" ")
}

fn render_record(report: &Value) -> String {
    let mut out = format!(
        "Recorded {} step(s) as '{}' ({})\n",
        report["steps"], report["macro"], report["path"].as_str().unwrap_or_default()
    );
    out.push_str(&format!(
        "started at entry {} ({})\n",
        report["started_at"],
        report["started_by"].as_str().unwrap_or("")
    ));
    if report["unguarded_steps"].as_u64().unwrap_or(0) > 0 {
        out.push_str(&format!(
            "{} step(s) promise nothing: `macro show` names them, and a run cannot verify them.\n",
            report["unguarded_steps"]
        ));
    }
    for refusal in report["refused"].as_array().into_iter().flatten() {
        out.push_str(&format!(
            "REFUSED entry {}: {}\n",
            refusal["index"], refusal["reason"].as_str().unwrap_or_default()
        ));
    }
    out
}


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

    #[test]
    fn a_var_without_an_equals_is_refused_before_anything_runs() {
        let error = parse_vars(&["email".to_string()]).expect_err("no =").to_string();
        assert!(error.contains("name=value"), "{error}");
        assert!(error.contains("Nothing was run"), "{error}");
        let vars = parse_vars(&["email=a@b.c".to_string(), "q=x=y".to_string()]).unwrap();
        assert_eq!(vars["email"], "a@b.c");
        assert_eq!(vars["q"], "x=y", "only the first = separates");
    }

    /// Running a macro from inside the session that is recording it would record the run.
    #[test]
    fn the_pipe_surface_refuses_to_run_a_macro() {
        let error = dispatch_pipe(&json!({"cmd": "macro", "action": "run", "name": "x"}), &[])
            .expect_err("refused")
            .to_string();
        assert!(error.contains("recording the run"), "{error}");
    }
}