use std::path::PathBuf;
use anyhow::{Context as _, Result};
use clap::{Parser, Subcommand};
use vs_protocol::Request;
#[derive(Debug, Parser)]
#[command(
name = "vs",
version,
about = "vibesurfer — agent-native browser CLI",
long_about = "vibesurfer client and daemon. `vs serve` runs the daemon; everything else sends a request to it over a Unix socket."
)]
pub struct Cli {
#[arg(long, short = 'S', global = true)]
pub session: Option<String>,
#[arg(long, global = true)]
pub socket: Option<PathBuf>,
#[arg(long, global = true)]
pub home: Option<PathBuf>,
#[arg(long, global = true)]
pub no_spawn: bool,
#[arg(long, short = 'j', global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(visible_alias = "so")]
SessionOpen {
#[arg(long)]
policy: Option<String>,
},
#[command(visible_alias = "sc")]
SessionClose,
#[command(visible_alias = "o")]
Open { url: String },
#[command(visible_alias = "c")]
Close { page: String },
#[command(visible_alias = "v")]
View {
page: String,
#[arg(long, short = 'F')]
full: bool,
},
#[command(visible_alias = "r")]
Read {
page: String,
#[arg(value_name = "REF")]
r: u32,
},
#[command(visible_alias = "a")]
Act {
page: String,
#[arg(value_name = "REF")]
r: u32,
op: String,
value: Option<String>,
#[arg(long)]
token: String,
#[arg(long)]
group: Option<String>,
},
#[command(visible_alias = "f")]
Find { query: String },
#[command(visible_alias = "w")]
Wait {
page: String,
cond: String,
value: Option<String>,
#[arg(long, default_value_t = 5000)]
timeout: u64,
},
#[command(visible_alias = "st")]
Status,
#[command(visible_alias = "x")]
Extract {
page: String,
schema: String,
#[arg(long)]
token: String,
},
#[command(visible_alias = "m")]
Mark {
page: String,
#[arg(value_name = "REF")]
r: u32,
name: String,
#[arg(long)]
token: String,
},
#[command(visible_alias = "an")]
Annotate {
target: String,
key: String,
value: Option<String>,
},
#[command(visible_alias = "l")]
Log {
#[arg(long, short = 'P')]
page: Option<String>,
#[arg(long)]
group: Option<String>,
#[arg(long, short = 's')]
since: Option<i64>,
#[arg(long, short = 'n')]
limit: Option<i64>,
},
#[command(visible_alias = "sk")]
Skill {
sub: Option<String>,
name: Option<String>,
},
#[command(visible_alias = "cap")]
Capture {
page: String,
#[arg(value_name = "REF")]
r: Option<u32>,
#[arg(long)]
full_page: bool,
},
#[command(visible_alias = "vp")]
Viewport {
page: String,
spec: String,
#[arg(long, default_value_t = 2)]
dpr: u32,
},
#[command(visible_alias = "lay")]
Layout {
page: String,
#[arg(value_name = "REF", required = true)]
refs: Vec<u32>,
},
#[command(visible_alias = "au")]
Auth {
sub: String,
#[arg(num_args = 0..=2)]
rest: Vec<String>,
},
#[command(visible_alias = "i")]
Inspect {
page: String,
kind: String,
#[arg(num_args = 0..=3)]
rest: Vec<String>,
#[arg(long, short = 's')]
since: Option<String>,
#[arg(long)]
level: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long)]
max: Option<String>,
#[arg(long, short = 'F')]
full: bool,
#[arg(long = "unsafe-log")]
unsafe_log: bool,
},
Serve {
#[arg(long)]
stop: bool,
},
Mcp,
}
impl Command {
#[allow(clippy::too_many_lines)]
pub fn to_request(&self, session_id: Option<&str>) -> Result<Request> {
Ok(match self {
Self::SessionOpen { policy } => {
let mut r = Request::new("vs_session_open");
if let Some(p) = policy {
r = r.flag_value("policy", p.clone());
}
r
}
Self::SessionClose => {
let s = require_session(session_id)?;
Request::new("vs_session_close").arg(s)
}
Self::Open { url } => {
let s = require_session(session_id)?;
Request::new("vs_open")
.arg(url.clone())
.flag_value("session", s)
}
Self::Close { page } => {
let s = require_session(session_id)?;
Request::new("vs_close")
.arg(page.clone())
.flag_value("session", s)
}
Self::View { page, full } => {
let s = require_session(session_id)?;
let mut r = Request::new("vs_view")
.arg(page.clone())
.flag_value("session", s);
if *full {
r = r.flag("full");
}
r
}
Self::Read { page, r } => {
let s = require_session(session_id)?;
Request::new("vs_read")
.arg(page.clone())
.arg(r.to_string())
.flag_value("session", s)
}
Self::Act {
page,
r,
op,
value,
token,
group,
} => {
let s = require_session(session_id)?;
let mut req = Request::new("vs_act")
.arg(page.clone())
.arg(r.to_string())
.arg(op.clone());
if let Some(v) = value {
req = req.arg(v.clone());
}
req = req
.flag_value("session", s)
.flag_value("token", token.clone());
if let Some(g) = group {
req = req.flag_value("group", g.clone());
}
req
}
Self::Find { query } => {
let s = require_session(session_id)?;
Request::new("vs_find")
.arg(query.clone())
.flag_value("session", s)
}
Self::Wait {
page,
cond,
value,
timeout,
} => {
let s = require_session(session_id)?;
let mut req = Request::new("vs_wait").arg(page.clone()).arg(cond.clone());
if let Some(v) = value {
req = req.arg(v.clone());
}
req.flag_value("session", s)
.flag_value("timeout", format!("{timeout}ms"))
}
Self::Status => {
let mut r = Request::new("vs_status");
if let Some(s) = session_id {
r = r.flag_value("session", s.to_string());
}
r
}
Self::Extract {
page,
schema,
token,
} => {
let s = require_session(session_id)?;
Request::new("vs_extract")
.arg(page.clone())
.arg(schema.clone())
.flag_value("session", s)
.flag_value("token", token.clone())
}
Self::Mark {
page,
r,
name,
token,
} => {
let s = require_session(session_id)?;
Request::new("vs_mark")
.arg(page.clone())
.arg(r.to_string())
.arg(name.clone())
.flag_value("session", s)
.flag_value("token", token.clone())
}
Self::Annotate { target, key, value } => {
let s = require_session(session_id)?;
let mut req = Request::new("vs_annotate")
.arg(target.clone())
.arg(key.clone());
if let Some(v) = value {
req = req.arg(v.clone());
}
req.flag_value("session", s)
}
Self::Log {
page,
group,
since,
limit,
} => {
let s = require_session(session_id)?;
let mut req = Request::new("vs_log").flag_value("session", s);
if let Some(p) = page {
req = req.flag_value("page", p.clone());
}
if let Some(g) = group {
req = req.flag_value("group", g.clone());
}
if let Some(t) = since {
req = req.flag_value("since", t.to_string());
}
if let Some(l) = limit {
req = req.flag_value("limit", l.to_string());
}
req
}
Self::Skill { sub, name } => {
let s = require_session(session_id)?;
let mut req = Request::new("vs_skill").flag_value("session", s);
let sub = sub.as_deref().unwrap_or("list");
req = req.arg(sub.to_string());
if let Some(n) = name {
req = req.arg(n.clone());
}
req
}
Self::Capture { page, r, full_page } => {
let s = require_session(session_id)?;
let mut req = Request::new("vs_capture")
.arg(page.clone())
.flag_value("session", s);
if let Some(rr) = r {
req = req.arg(rr.to_string());
}
if *full_page {
req = req.flag("full-page");
}
req
}
Self::Viewport { page, spec, dpr } => {
let s = require_session(session_id)?;
Request::new("vs_viewport")
.arg(page.clone())
.arg(spec.clone())
.flag_value("session", s)
.flag_value("dpr", dpr.to_string())
}
Self::Layout { page, refs } => {
let s = require_session(session_id)?;
let mut req = Request::new("vs_layout").arg(page.clone());
for r in refs {
req = req.arg(r.to_string());
}
req.flag_value("session", s)
}
Self::Auth { sub, rest } => {
let s = require_session(session_id)?;
let mut req = Request::new("vs_auth")
.arg(sub.clone())
.flag_value("session", s);
for r in rest {
req = req.arg(r.clone());
}
req
}
Self::Inspect {
page,
kind,
rest,
since,
level,
status,
max,
full,
unsafe_log,
} => {
let s = require_session(session_id)?;
let kind_long = normalize_inspect_kind(kind);
let mut req = Request::new("vs_inspect")
.arg(kind_long.to_string())
.arg(page.clone());
for r in rest {
req = req.arg(r.clone());
}
req = req.flag_value("session", s);
if let Some(v) = since {
req = req.flag_value("since", v.clone());
}
if let Some(v) = level {
req = req.flag_value("level", v.clone());
}
if let Some(v) = status {
req = req.flag_value("status", v.clone());
}
if let Some(v) = max {
req = req.flag_value("max", v.clone());
}
if *full {
req = req.flag("full");
}
if *unsafe_log {
req = req.flag("unsafe-log");
}
req
}
Self::Serve { .. } => {
anyhow::bail!("vs_serve is local; route via main, not the wire dispatcher");
}
Self::Mcp => {
anyhow::bail!("vs_mcp is local; route via main, not the wire dispatcher");
}
})
}
#[must_use]
pub fn needs_session(&self) -> bool {
!matches!(
self,
Self::SessionOpen { .. } | Self::Status | Self::Serve { .. } | Self::Mcp
)
}
}
fn require_session(session: Option<&str>) -> Result<String> {
session
.map(str::to_string)
.context("no active session — run `vs session-open` or pass `--session=<id>`")
}
fn normalize_inspect_kind(kind: &str) -> &str {
match kind {
"co" => "console",
"n" => "network",
"req" => "request",
"e" => "eval",
"s" => "storage",
"scr" => "scripts",
"src" => "script",
"d" => "dom",
"p" => "performance",
"ce" => "cookie-events",
other => other,
}
}
mod dispatch;
mod render;
pub use dispatch::{connect, resolve_paths, resolve_session, run};
pub use render::render;