1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
//! Shared agent-browser integration. Holds the availability probe, a
//! single `run_cmd` helper that both the Rhai binding and the CLI flag
//! use, and the `--browser-screenshot` CLI flow.
//!
//! We wrap the external CLI rather than linking a browser driver so the
//! dep surface stays the same — scripts without agent-browser installed
//! still load cleanly, they just see `agentBrowser::available == false`.
use anyhow::{anyhow, Context, Result};
use std::process::Command;
use std::sync::OnceLock;
/// Whether the agent-browser binary is reachable + its version string.
#[derive(Clone, Debug)]
pub struct AgentBrowserState {
pub available: bool,
/// e.g. "0.26.0" (parsed from `agent-browser --version` stdout). Empty
/// when `available` is false.
pub version: String,
}
/// Detected once at first access. Subsequent calls return the cached
/// value — script sessions don't change PATH mid-run.
fn state() -> &'static AgentBrowserState {
static CELL: OnceLock<AgentBrowserState> = OnceLock::new();
CELL.get_or_init(detect_state)
}
pub fn state_snapshot() -> AgentBrowserState {
state().clone()
}
fn detect_state() -> AgentBrowserState {
match Command::new("agent-browser").arg("--version").output() {
Ok(out) if out.status.success() => {
// stdout like "agent-browser 0.26.0\n"
let text = String::from_utf8_lossy(&out.stdout);
let version = text
.split_whitespace()
.nth(1)
.unwrap_or("")
.trim_matches(|c: char| !c.is_ascii_digit() && c != '.')
.to_string();
AgentBrowserState {
available: true,
version,
}
}
_ => AgentBrowserState {
available: false,
version: String::new(),
},
}
}
/// Run `agent-browser <args...>`. When `json` is true, sets
/// `AGENT_BROWSER_JSON=1` so structured commands emit parseable output.
/// Returns stdout as UTF-8 lossy. Non-zero exit → Err with stderr text.
/// Missing binary → a specialised error with clear remediation hint.
pub fn run_cmd(args: &[&str], json: bool) -> Result<String> {
let mut cmd = Command::new("agent-browser");
cmd.args(args);
if json {
cmd.env("AGENT_BROWSER_JSON", "1");
}
let out = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow!(
"agent-browser: binary not found on PATH. \
Install via `brew install agent-browser` or \
`npm install -g agent-browser`."
)
} else {
anyhow!("agent-browser: spawn failed: {e}")
}
})?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let code = out
.status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".to_string());
return Err(anyhow!(
"agent-browser: exit {code}: {}",
stderr.trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Run agent-browser with `options` prepended before `args`. Used by the
/// Rhai script bindings to apply module-level default options + per-call
/// overrides before the command verb.
///
/// `options` is owned (the bindings module holds the canonical Vec) so we
/// only need to borrow during the call. `args` matches `run_cmd`'s shape.
pub fn run_cmd_with_options(
options: &[String],
args: &[&str],
json: bool,
) -> Result<String> {
let mut argv: Vec<&str> = options.iter().map(String::as_str).collect();
argv.extend_from_slice(args);
run_cmd(&argv, json)
}
/// Early-intercept handler for `recon --browser-screenshot URL [-o PATH]`.
pub fn run_screenshot_cli(url: &str, output: Option<&std::path::Path>) -> Result<()> {
let s = state();
if !s.available {
return Err(anyhow!(
"agent-browser: binary not found on PATH. \
Install via `brew install agent-browser` or \
`npm install -g agent-browser`."
));
}
// Open in a single session then screenshot; close on the way out so
// we don't leak a daemon / browser process for each invocation.
let _ = run_cmd(&["open", url], false).context("agent-browser: open")?;
let shot_args: Vec<&str> = match output {
Some(p) => vec!["screenshot", p.to_str().unwrap_or("")],
None => vec!["screenshot"],
};
let out = run_cmd(&shot_args, false).context("agent-browser: screenshot")?;
let _ = run_cmd(&["close"], false);
print!("{out}");
if !out.ends_with('\n') {
println!();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_is_deterministic() {
// Whatever the real PATH is, state() must be callable twice and
// return the same thing. We don't assert a specific available
// value — depends on the dev environment.
let a = state_snapshot();
let b = state_snapshot();
assert_eq!(a.available, b.available);
assert_eq!(a.version, b.version);
}
#[test]
fn run_cmd_with_options_prepends_correctly() {
// We can't run agent-browser in a unit test without it on PATH,
// but we can verify argv assembly by reading the helper's source.
// This test guards the helper signature compiles + is callable.
let opts = vec!["--ignore-https-errors".to_string()];
let result = run_cmd_with_options(&opts, &["--version"], false);
// Result depends on env: if agent-browser is on PATH, Ok; otherwise
// a "binary not found" error wrapped in anyhow. Both are valid
// proofs the helper assembled and dispatched.
let _ = result;
}
#[test]
fn version_parsing_trims_nondigits() {
// detect_state parses whatever agent-browser --version emits.
// Can't easily mock the Command; this test exists as a guard
// that the function compiles + runs.
let s = state_snapshot();
if s.available {
assert!(
s.version
.chars()
.all(|c| c.is_ascii_digit() || c == '.'),
"version '{}' should contain only digits and dots",
s.version
);
} else {
assert!(s.version.is_empty());
}
}
}