use anyhow::{Context, Result, bail};
use base64::Engine;
use cdpkit::CDP;
use clap::{Parser, Subcommand};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
#[derive(Parser)]
#[command(name = "chromium-bridge", about = "Bridge agents to Chromium browsers via CDP")]
struct Cli {
#[arg(long, default_value = "127.0.0.1", env = "CHROMIUM_BRIDGE_HOST")]
host: String,
#[arg(long, default_value = "9222", env = "CHROMIUM_BRIDGE_PORT")]
port: u16,
#[arg(long, default_value = "5000")]
timeout: u64,
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Check,
List,
Navigate {
url: String,
#[arg(long, default_value = "0")]
tab: String,
},
Evaluate {
expression: String,
#[arg(long, default_value = "0")]
tab: String,
},
Screenshot {
url: Option<String>,
#[arg(short, long)]
output: Option<String>,
#[arg(long, default_value = "0")]
tab: String,
},
Markdown {
url: String,
#[arg(long, default_value = "0")]
tab: String,
},
Click {
selector: String,
#[arg(long, default_value = "0")]
tab: String,
},
Type {
selector: String,
text: String,
#[arg(long, default_value = "0")]
tab: String,
},
SelectTab {
selector: String,
},
Wait {
selector: String,
#[arg(long, default_value = "10000")]
wait_timeout: u64,
#[arg(long, default_value = "0")]
tab: String,
},
Snapshot {
#[arg(long)]
depth: Option<i64>,
#[arg(long, default_value = "0")]
tab: String,
},
Skill {
#[command(subcommand)]
action: SkillAction,
},
Setup,
}
#[derive(Subcommand)]
enum SkillAction {
Install,
Check,
}
#[derive(Deserialize, Serialize)]
struct BrowserVersion {
#[serde(rename = "Browser")]
browser: String,
#[serde(rename = "Protocol-Version")]
protocol_version: String,
#[serde(rename = "webSocketDebuggerUrl")]
#[serde(default)]
web_socket_debugger_url: String,
}
#[derive(Deserialize, Serialize)]
struct Tab {
id: String,
title: String,
url: String,
#[serde(rename = "type")]
tab_type: String,
#[serde(rename = "webSocketDebuggerUrl")]
#[serde(default)]
web_socket_debugger_url: String,
}
fn base_url(cli: &Cli) -> String {
format!("http://{}:{}", cli.host, cli.port)
}
fn client(cli: &Cli) -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(cli.timeout))
.build()
.expect("failed to build HTTP client")
}
async fn get_tabs(cli: &Cli) -> Result<Vec<Tab>> {
let resp = client(cli)
.get(format!("{}/json/list", base_url(cli)))
.send()
.await
.context(format!(
"Browser not responding on {}:{}. Is remote debugging enabled?",
cli.host, cli.port
))?;
let tabs: Vec<Tab> = resp.json().await?;
Ok(tabs)
}
fn resolve_tab<'a>(pages: &[&'a Tab], selector: &str) -> Result<&'a Tab> {
if let Ok(index) = selector.parse::<usize>() {
pages
.get(index)
.copied()
.context(format!("No tab at index {}", index))
} else {
let matches: Vec<&&Tab> = pages
.iter()
.filter(|t| t.url.contains(selector) || t.title.contains(selector))
.collect();
match matches.len() {
0 => bail!("No tab matching pattern '{}'", selector),
1 => Ok(matches[0]),
n => bail!(
"Pattern '{}' matched {} tabs. Be more specific:\n{}",
selector,
n,
matches
.iter()
.enumerate()
.map(|(i, t)| format!(" [{}] {} — {}", i, t.title, t.url))
.collect::<Vec<_>>()
.join("\n")
),
}
}
}
async fn connect_to_tab(cli: &Cli, selector: &str) -> Result<(CDP, String)> {
let tabs = get_tabs(cli).await?;
let pages: Vec<&Tab> = tabs.iter().filter(|t| t.tab_type == "page").collect();
let tab = resolve_tab(&pages, selector)?;
let cdp = CDP::connect(&format!("{}:{}", cli.host, cli.port))
.await
.context("Failed to connect CDP client")?;
let attach = cdpkit::target::methods::AttachToTarget::new(&tab.id)
.with_flatten(true)
.send(&cdp, None)
.await
.context("Failed to attach to tab")?;
Ok((cdp, attach.session_id))
}
async fn cmd_check(cli: &Cli) -> Result<()> {
let resp = client(cli)
.get(format!("{}/json/version", base_url(cli)))
.send()
.await
.context(format!(
"Browser not responding on {}:{}. Is remote debugging enabled?",
cli.host, cli.port
))?;
let version: BrowserVersion = resp.json().await?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&version)?);
} else {
println!(
"OK — {} (protocol {})",
version.browser, version.protocol_version
);
}
Ok(())
}
async fn cmd_list(cli: &Cli) -> Result<()> {
let tabs = get_tabs(cli).await?;
let pages: Vec<&Tab> = tabs.iter().filter(|t| t.tab_type == "page").collect();
if cli.json {
println!("{}", serde_json::to_string_pretty(&pages)?);
} else {
for (i, tab) in pages.iter().enumerate() {
println!("[{}] {} — {}", i, tab.title, tab.url);
}
}
Ok(())
}
async fn cmd_navigate(cli: &Cli, url: &str, tab_selector: &str) -> Result<()> {
let (cdp, session) = connect_to_tab(cli, tab_selector).await?;
cdpkit::page::methods::Enable::new()
.send(&cdp, Some(&session))
.await?;
let result = cdpkit::page::methods::Navigate::new(url)
.send(&cdp, Some(&session))
.await?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"frameId": result.frame_id,
}))?);
} else {
println!("Navigated to {}", url);
}
Ok(())
}
async fn cmd_evaluate(cli: &Cli, expression: &str, tab_selector: &str) -> Result<()> {
let (cdp, session) = connect_to_tab(cli, tab_selector).await?;
let result = cdpkit::runtime::methods::Evaluate::new(expression)
.with_return_by_value(true)
.send(&cdp, Some(&session))
.await?;
if cli.json {
let json = serde_json::json!({
"type": result.result.type_,
"value": result.result.value,
"description": result.result.description,
});
println!("{}", serde_json::to_string_pretty(&json)?);
} else if let Some(value) = &result.result.value {
match value {
serde_json::Value::String(s) => println!("{}", s),
other => println!("{}", other),
}
} else if let Some(desc) = &result.result.description {
println!("{}", desc);
}
Ok(())
}
async fn cmd_screenshot(
cli: &Cli,
url: Option<&str>,
output: Option<&str>,
tab_selector: &str,
) -> Result<()> {
let (cdp, session) = connect_to_tab(cli, tab_selector).await?;
if let Some(url) = url {
cdpkit::page::methods::Enable::new()
.send(&cdp, Some(&session))
.await?;
cdpkit::page::methods::Navigate::new(url)
.send(&cdp, Some(&session))
.await?;
let mut events = cdpkit::page::events::LoadEventFired::subscribe(&cdp);
let _ = tokio::time::timeout(
std::time::Duration::from_secs(10),
events.next(),
)
.await;
}
let result = cdpkit::page::methods::CaptureScreenshot::new()
.send(&cdp, Some(&session))
.await?;
if let Some(path) = output {
let bytes = base64::engine::general_purpose::STANDARD.decode(&result.data)?;
std::fs::write(path, bytes)?;
eprintln!("Screenshot saved to {}", path);
} else {
println!("{}", result.data);
}
Ok(())
}
async fn cmd_markdown(cli: &Cli, url: &str, tab_selector: &str) -> Result<()> {
let (cdp, session) = connect_to_tab(cli, tab_selector).await?;
cdpkit::page::methods::Enable::new()
.send(&cdp, Some(&session))
.await?;
cdpkit::page::methods::Navigate::new(url)
.send(&cdp, Some(&session))
.await?;
let mut events = cdpkit::page::events::LoadEventFired::subscribe(&cdp);
let _ = tokio::time::timeout(
std::time::Duration::from_secs(10),
events.next(),
)
.await;
let js = r#"
(function() {
const clone = document.cloneNode(true);
clone.querySelectorAll('script, style, nav, footer, aside, iframe, noscript').forEach(el => el.remove());
function nodeToMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent.replace(/\s+/g, ' ');
}
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const tag = node.tagName.toLowerCase();
const children = Array.from(node.childNodes).map(c => nodeToMarkdown(c)).join('');
switch(tag) {
case 'h1': return '\n# ' + children.trim() + '\n';
case 'h2': return '\n## ' + children.trim() + '\n';
case 'h3': return '\n### ' + children.trim() + '\n';
case 'h4': return '\n#### ' + children.trim() + '\n';
case 'h5': return '\n##### ' + children.trim() + '\n';
case 'h6': return '\n###### ' + children.trim() + '\n';
case 'p': return '\n' + children.trim() + '\n';
case 'br': return '\n';
case 'strong': case 'b': return '**' + children.trim() + '**';
case 'em': case 'i': return '*' + children.trim() + '*';
case 'code': return '`' + children.trim() + '`';
case 'pre': return '\n```\n' + children.trim() + '\n```\n';
case 'a': {
const href = node.getAttribute('href') || '';
return '[' + children.trim() + '](' + href + ')';
}
case 'img': {
const alt = node.getAttribute('alt') || '';
const src = node.getAttribute('src') || '';
return '';
}
case 'li': return '- ' + children.trim() + '\n';
case 'ul': case 'ol': return '\n' + children;
case 'blockquote': return '\n> ' + children.trim().replace(/\n/g, '\n> ') + '\n';
case 'hr': return '\n---\n';
case 'table': return '\n' + children + '\n';
case 'tr': return children + '|\n';
case 'th': return '| **' + children.trim() + '** ';
case 'td': return '| ' + children.trim() + ' ';
default: return children;
}
}
const article = clone.querySelector('article, main, [role="main"]') || clone.querySelector('body') || clone.documentElement;
let md = nodeToMarkdown(article);
md = md.replace(/\n{3,}/g, '\n\n').trim();
return md;
})()
"#;
let result = cdpkit::runtime::methods::Evaluate::new(js)
.with_return_by_value(true)
.send(&cdp, Some(&session))
.await?;
if let Some(serde_json::Value::String(md)) = &result.result.value {
println!("{}", md);
} else {
bail!("Failed to extract markdown from page");
}
Ok(())
}
async fn cmd_click(cli: &Cli, selector: &str, tab_selector: &str) -> Result<()> {
let (cdp, session) = connect_to_tab(cli, tab_selector).await?;
let doc = cdpkit::dom::methods::GetDocument::new()
.send(&cdp, Some(&session))
.await?;
let result = cdpkit::dom::methods::QuerySelector::new(doc.root.node_id, selector)
.send(&cdp, Some(&session))
.await
.context(format!("No element matching selector '{}'", selector))?;
if result.node_id == 0 {
bail!("No element matching selector '{}'", selector);
}
let box_model = cdpkit::dom::methods::GetBoxModel::new()
.with_node_id(result.node_id)
.send(&cdp, Some(&session))
.await
.context("Failed to get element box model")?;
let q = &box_model.model.content;
let cx = (q[0] + q[2] + q[4] + q[6]) / 4.0;
let cy = (q[1] + q[3] + q[5] + q[7]) / 4.0;
cdpkit::input::methods::DispatchMouseEvent::new("mouseMoved", cx, cy)
.send(&cdp, Some(&session))
.await?;
cdpkit::input::methods::DispatchMouseEvent::new("mousePressed", cx, cy)
.with_button(cdpkit::input::types::MouseButton::Left)
.with_click_count(1)
.send(&cdp, Some(&session))
.await?;
cdpkit::input::methods::DispatchMouseEvent::new("mouseReleased", cx, cy)
.with_button(cdpkit::input::types::MouseButton::Left)
.with_click_count(1)
.send(&cdp, Some(&session))
.await?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"selector": selector,
"x": cx,
"y": cy,
}))?);
} else {
println!("Clicked '{}' at ({:.0}, {:.0})", selector, cx, cy);
}
Ok(())
}
async fn cmd_type(cli: &Cli, selector: &str, text: &str, tab_selector: &str) -> Result<()> {
let (cdp, session) = connect_to_tab(cli, tab_selector).await?;
let doc = cdpkit::dom::methods::GetDocument::new()
.send(&cdp, Some(&session))
.await?;
let result = cdpkit::dom::methods::QuerySelector::new(doc.root.node_id, selector)
.send(&cdp, Some(&session))
.await
.context(format!("No element matching selector '{}'", selector))?;
if result.node_id == 0 {
bail!("No element matching selector '{}'", selector);
}
cdpkit::dom::methods::Focus::new()
.with_node_id(result.node_id)
.send(&cdp, Some(&session))
.await
.context("Failed to focus element")?;
let paragraphs: Vec<&str> = text.split("\n\n").collect();
for (i, paragraph) in paragraphs.iter().enumerate() {
if !paragraph.is_empty() {
cdpkit::input::methods::InsertText::new(*paragraph)
.send(&cdp, Some(&session))
.await?;
}
if i < paragraphs.len() - 1 {
cdpkit::input::methods::DispatchKeyEvent::new("keyDown")
.with_key("Enter")
.with_code("Enter")
.with_text("\r")
.with_modifiers(8)
.with_windows_virtual_key_code(13)
.send(&cdp, Some(&session))
.await?;
cdpkit::input::methods::DispatchKeyEvent::new("keyUp")
.with_key("Enter")
.with_code("Enter")
.with_modifiers(8)
.with_windows_virtual_key_code(13)
.send(&cdp, Some(&session))
.await?;
cdpkit::input::methods::DispatchKeyEvent::new("keyDown")
.with_key("Enter")
.with_code("Enter")
.with_text("\r")
.with_modifiers(8)
.with_windows_virtual_key_code(13)
.send(&cdp, Some(&session))
.await?;
cdpkit::input::methods::DispatchKeyEvent::new("keyUp")
.with_key("Enter")
.with_code("Enter")
.with_modifiers(8)
.with_windows_virtual_key_code(13)
.send(&cdp, Some(&session))
.await?;
}
}
if cli.json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"selector": selector,
"length": text.len(),
"paragraphs": paragraphs.len(),
}))?);
} else {
println!(
"Typed {} chars ({} paragraph{}) into '{}'",
text.len(),
paragraphs.len(),
if paragraphs.len() == 1 { "" } else { "s" },
selector
);
}
Ok(())
}
async fn cmd_select_tab(cli: &Cli, selector: &str) -> Result<()> {
let tabs = get_tabs(cli).await?;
let pages: Vec<&Tab> = tabs.iter().filter(|t| t.tab_type == "page").collect();
let tab = resolve_tab(&pages, selector)?;
let resp = client(cli)
.get(format!("{}/json/activate/{}", base_url(cli), tab.id))
.send()
.await
.context("Failed to activate tab")?;
if !resp.status().is_success() {
bail!("Failed to activate tab: HTTP {}", resp.status());
}
if cli.json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"id": tab.id,
"title": tab.title,
"url": tab.url,
}))?);
} else {
println!("Activated: {} — {}", tab.title, tab.url);
}
Ok(())
}
async fn cmd_wait(cli: &Cli, selector: &str, timeout_ms: u64, tab_selector: &str) -> Result<()> {
let (cdp, session) = connect_to_tab(cli, tab_selector).await?;
let js = format!(
r#"document.querySelector({}) !== null"#,
serde_json::to_string(selector)?
);
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
let poll_interval = std::time::Duration::from_millis(250);
loop {
let result = cdpkit::runtime::methods::Evaluate::new(&js)
.with_return_by_value(true)
.send(&cdp, Some(&session))
.await?;
if result.result.value == Some(serde_json::Value::Bool(true)) {
if cli.json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"selector": selector,
"found": true,
}))?);
} else {
println!("Found '{}'", selector);
}
return Ok(());
}
if std::time::Instant::now() >= deadline {
bail!("Timeout waiting for selector '{}' after {}ms", selector, timeout_ms);
}
tokio::time::sleep(poll_interval).await;
}
}
async fn cmd_snapshot(cli: &Cli, depth: Option<i64>, tab_selector: &str) -> Result<()> {
let (cdp, session) = connect_to_tab(cli, tab_selector).await?;
let mut req = cdpkit::accessibility::methods::GetFullAxTree::new();
if let Some(d) = depth {
req = req.with_depth(d);
}
let result = req.send(&cdp, Some(&session)).await?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&result.nodes)?);
} else {
for node in &result.nodes {
if node.ignored {
continue;
}
let role = node
.role
.as_ref()
.and_then(|v| v.value.as_ref())
.and_then(|v| v.as_str())
.unwrap_or("?");
let name = node
.name
.as_ref()
.and_then(|v| v.value.as_ref())
.and_then(|v| v.as_str())
.unwrap_or("");
if role == "none" || role == "generic" {
continue;
}
if name.is_empty() {
println!("[{}]", role);
} else {
let truncated = if name.len() > 80 {
format!("{}…", &name[..80])
} else {
name.to_string()
};
println!("[{}] {}", role, truncated);
}
}
}
Ok(())
}
const BUNDLED_SKILL: &str = include_str!("../SKILL.md");
fn resolve_skill_root() -> std::path::PathBuf {
if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "--show-superproject-working-tree"])
.output()
{
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !root.is_empty() {
return std::path::PathBuf::from(root);
}
}
if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
{
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !root.is_empty() {
return std::path::PathBuf::from(root);
}
}
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
}
fn cmd_skill_install(cli: &Cli) -> Result<()> {
let root = resolve_skill_root();
let dir = root.join(".claude/skills/chromium-bridge");
std::fs::create_dir_all(&dir)?;
let path = dir.join("SKILL.md");
let already_current = path.exists()
&& std::fs::read_to_string(&path)
.map(|existing| existing == BUNDLED_SKILL)
.unwrap_or(false);
if already_current {
if cli.json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"path": path.display().to_string(),
"updated": false,
}))?);
} else {
println!("Skill already up to date: {}", path.display());
}
} else {
std::fs::write(&path, BUNDLED_SKILL)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"path": path.display().to_string(),
"updated": true,
}))?);
} else {
println!("Skill installed: {}", path.display());
}
}
Ok(())
}
fn cmd_skill_check(cli: &Cli) -> Result<()> {
let root = resolve_skill_root();
let path = root.join(".claude/skills/chromium-bridge/SKILL.md");
let up_to_date = path.exists()
&& std::fs::read_to_string(&path)
.map(|existing| existing == BUNDLED_SKILL)
.unwrap_or(false);
if cli.json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"path": path.display().to_string(),
"up_to_date": up_to_date,
}))?);
} else if up_to_date {
println!("Skill up to date: {}", path.display());
} else if path.exists() {
eprintln!("Skill outdated: {}", path.display());
eprintln!("Run: chromium-bridge skill install");
std::process::exit(1);
} else {
eprintln!("Skill not installed");
eprintln!("Run: chromium-bridge skill install");
std::process::exit(1);
}
Ok(())
}
fn cmd_setup() -> Result<()> {
let browsers = [
(
"Brave",
"/opt/brave-bin/brave",
"~/.config/brave-flags.conf",
),
(
"Chrome",
"/usr/bin/google-chrome-stable",
"~/.config/chrome-flags.conf",
),
(
"Chromium",
"/usr/bin/chromium",
"~/.config/chromium-flags.conf",
),
];
println!("Detected browsers:");
let mut found = false;
for (name, path, flags_file) in &browsers {
if std::path::Path::new(path).exists() {
found = true;
let flags_path = flags_file.replace("~", &std::env::var("HOME").unwrap_or_default());
let has_flag = std::fs::read_to_string(&flags_path)
.map(|c| c.contains("--remote-debugging-port"))
.unwrap_or(false);
let status = if has_flag {
"remote debugging configured"
} else {
"remote debugging NOT configured"
};
println!(" [{}] {} — {}", name, path, status);
if !has_flag {
println!(
" → echo \"--remote-debugging-port=9222\" >> {}",
flags_file
);
}
}
}
if !found {
println!(" No Chromium-based browsers found.");
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match &cli.command {
Command::Check => cmd_check(&cli).await,
Command::List => cmd_list(&cli).await,
Command::Navigate { url, tab } => cmd_navigate(&cli, url, tab).await,
Command::Evaluate { expression, tab } => cmd_evaluate(&cli, expression, tab).await,
Command::Screenshot { url, output, tab } => {
cmd_screenshot(&cli, url.as_deref(), output.as_deref(), tab).await
}
Command::Markdown { url, tab } => cmd_markdown(&cli, url, tab).await,
Command::Click { selector, tab } => cmd_click(&cli, selector, tab).await,
Command::Type {
selector,
text,
tab,
} => cmd_type(&cli, selector, text, tab).await,
Command::SelectTab { selector } => cmd_select_tab(&cli, selector).await,
Command::Wait {
selector,
wait_timeout,
tab,
} => cmd_wait(&cli, selector, *wait_timeout, tab).await,
Command::Snapshot { depth, tab } => cmd_snapshot(&cli, *depth, tab).await,
Command::Skill { action } => match action {
SkillAction::Install => cmd_skill_install(&cli),
SkillAction::Check => cmd_skill_check(&cli),
},
Command::Setup => cmd_setup(),
}
}