1use std::path::PathBuf;
8
9use anyhow::{Context as _, Result};
10use clap::{Parser, Subcommand};
11use vs_protocol::Request;
12
13#[derive(Debug, Parser)]
15#[command(
16 name = "vs",
17 version,
18 about = "vibesurfer — agent-native browser CLI",
19 long_about = "vibesurfer client and daemon. `vs serve` runs the daemon; everything else sends a request to it over a Unix socket."
20)]
21pub struct Cli {
22 #[arg(long, short = 'S', global = true)]
25 pub session: Option<String>,
26
27 #[arg(long, global = true)]
30 pub socket: Option<PathBuf>,
31
32 #[arg(long, global = true)]
34 pub home: Option<PathBuf>,
35
36 #[arg(long, global = true)]
39 pub no_spawn: bool,
40
41 #[arg(long, short = 'j', global = true)]
44 pub json: bool,
45
46 #[command(subcommand)]
47 pub command: Command,
48}
49
50#[derive(Debug, Subcommand)]
56pub enum Command {
57 #[command(visible_alias = "so")]
59 SessionOpen {
60 #[arg(long)]
61 policy: Option<String>,
62 },
63 #[command(visible_alias = "sc")]
65 SessionClose,
66 #[command(visible_alias = "o")]
68 Open { url: String },
69 #[command(visible_alias = "c")]
71 Close { page: String },
72 #[command(visible_alias = "v")]
74 View {
75 page: String,
76 #[arg(long, short = 'F')]
77 full: bool,
78 },
79 #[command(visible_alias = "r")]
81 Read {
82 page: String,
83 #[arg(value_name = "REF")]
84 r: u32,
85 },
86 #[command(visible_alias = "a")]
88 Act {
89 page: String,
90 #[arg(value_name = "REF")]
91 r: u32,
92 op: String,
93 value: Option<String>,
94 #[arg(long)]
95 token: String,
96 #[arg(long)]
97 group: Option<String>,
98 },
99 #[command(visible_alias = "f")]
101 Find { query: String },
102 #[command(visible_alias = "w")]
104 Wait {
105 page: String,
106 cond: String,
107 value: Option<String>,
108 #[arg(long, default_value_t = 5000)]
109 timeout: u64,
110 },
111 #[command(visible_alias = "st")]
113 Status,
114 #[command(visible_alias = "x")]
116 Extract {
117 page: String,
118 schema: String,
119 #[arg(long)]
120 token: String,
121 },
122 #[command(visible_alias = "m")]
124 Mark {
125 page: String,
126 #[arg(value_name = "REF")]
127 r: u32,
128 name: String,
129 #[arg(long)]
130 token: String,
131 },
132 #[command(visible_alias = "an")]
135 Annotate {
136 target: String,
137 key: String,
138 value: Option<String>,
139 },
140 #[command(visible_alias = "l")]
142 Log {
143 #[arg(long, short = 'P')]
144 page: Option<String>,
145 #[arg(long)]
146 group: Option<String>,
147 #[arg(long, short = 's')]
148 since: Option<i64>,
149 #[arg(long, short = 'n')]
150 limit: Option<i64>,
151 },
152 #[command(visible_alias = "sk")]
155 Skill {
156 sub: Option<String>,
157 name: Option<String>,
158 },
159 #[command(visible_alias = "cap")]
162 Capture {
163 page: String,
164 #[arg(value_name = "REF")]
165 r: Option<u32>,
166 #[arg(long)]
167 full_page: bool,
168 },
169 #[command(visible_alias = "vp")]
172 Viewport {
173 page: String,
174 spec: String,
175 #[arg(long, default_value_t = 2)]
176 dpr: u32,
177 },
178 #[command(visible_alias = "lay")]
180 Layout {
181 page: String,
182 #[arg(value_name = "REF", required = true)]
183 refs: Vec<u32>,
184 },
185 #[command(visible_alias = "au")]
188 Auth {
189 sub: String,
190 #[arg(num_args = 0..=2)]
191 rest: Vec<String>,
192 },
193 #[command(visible_alias = "i")]
199 Inspect {
200 page: String,
201 kind: String,
202 #[arg(num_args = 0..=3)]
203 rest: Vec<String>,
204 #[arg(long, short = 's')]
205 since: Option<String>,
206 #[arg(long)]
207 level: Option<String>,
208 #[arg(long)]
209 status: Option<String>,
210 #[arg(long)]
211 max: Option<String>,
212 #[arg(long, short = 'F')]
213 full: bool,
214 #[arg(long = "unsafe-log")]
215 unsafe_log: bool,
216 },
217 Serve {
221 #[arg(long)]
225 stop: bool,
226 },
227 Mcp,
232}
233
234impl Command {
235 #[allow(clippy::too_many_lines)]
238 pub fn to_request(&self, session_id: Option<&str>) -> Result<Request> {
239 Ok(match self {
240 Self::SessionOpen { policy } => {
241 let mut r = Request::new("vs_session_open");
242 if let Some(p) = policy {
243 r = r.flag_value("policy", p.clone());
244 }
245 r
246 }
247 Self::SessionClose => {
248 let s = require_session(session_id)?;
249 Request::new("vs_session_close").arg(s)
250 }
251 Self::Open { url } => {
252 let s = require_session(session_id)?;
253 Request::new("vs_open")
254 .arg(url.clone())
255 .flag_value("session", s)
256 }
257 Self::Close { page } => {
258 let s = require_session(session_id)?;
259 Request::new("vs_close")
260 .arg(page.clone())
261 .flag_value("session", s)
262 }
263 Self::View { page, full } => {
264 let s = require_session(session_id)?;
265 let mut r = Request::new("vs_view")
266 .arg(page.clone())
267 .flag_value("session", s);
268 if *full {
269 r = r.flag("full");
270 }
271 r
272 }
273 Self::Read { page, r } => {
274 let s = require_session(session_id)?;
275 Request::new("vs_read")
276 .arg(page.clone())
277 .arg(r.to_string())
278 .flag_value("session", s)
279 }
280 Self::Act {
281 page,
282 r,
283 op,
284 value,
285 token,
286 group,
287 } => {
288 let s = require_session(session_id)?;
289 let mut req = Request::new("vs_act")
290 .arg(page.clone())
291 .arg(r.to_string())
292 .arg(op.clone());
293 if let Some(v) = value {
294 req = req.arg(v.clone());
295 }
296 req = req
297 .flag_value("session", s)
298 .flag_value("token", token.clone());
299 if let Some(g) = group {
300 req = req.flag_value("group", g.clone());
301 }
302 req
303 }
304 Self::Find { query } => {
305 let s = require_session(session_id)?;
306 Request::new("vs_find")
307 .arg(query.clone())
308 .flag_value("session", s)
309 }
310 Self::Wait {
311 page,
312 cond,
313 value,
314 timeout,
315 } => {
316 let s = require_session(session_id)?;
317 let mut req = Request::new("vs_wait").arg(page.clone()).arg(cond.clone());
318 if let Some(v) = value {
319 req = req.arg(v.clone());
320 }
321 req.flag_value("session", s)
322 .flag_value("timeout", format!("{timeout}ms"))
323 }
324 Self::Status => {
325 let mut r = Request::new("vs_status");
326 if let Some(s) = session_id {
327 r = r.flag_value("session", s.to_string());
328 }
329 r
330 }
331 Self::Extract {
332 page,
333 schema,
334 token,
335 } => {
336 let s = require_session(session_id)?;
337 Request::new("vs_extract")
338 .arg(page.clone())
339 .arg(schema.clone())
340 .flag_value("session", s)
341 .flag_value("token", token.clone())
342 }
343 Self::Mark {
344 page,
345 r,
346 name,
347 token,
348 } => {
349 let s = require_session(session_id)?;
350 Request::new("vs_mark")
351 .arg(page.clone())
352 .arg(r.to_string())
353 .arg(name.clone())
354 .flag_value("session", s)
355 .flag_value("token", token.clone())
356 }
357 Self::Annotate { target, key, value } => {
358 let s = require_session(session_id)?;
359 let mut req = Request::new("vs_annotate")
360 .arg(target.clone())
361 .arg(key.clone());
362 if let Some(v) = value {
363 req = req.arg(v.clone());
364 }
365 req.flag_value("session", s)
366 }
367 Self::Log {
368 page,
369 group,
370 since,
371 limit,
372 } => {
373 let s = require_session(session_id)?;
374 let mut req = Request::new("vs_log").flag_value("session", s);
375 if let Some(p) = page {
376 req = req.flag_value("page", p.clone());
377 }
378 if let Some(g) = group {
379 req = req.flag_value("group", g.clone());
380 }
381 if let Some(t) = since {
382 req = req.flag_value("since", t.to_string());
383 }
384 if let Some(l) = limit {
385 req = req.flag_value("limit", l.to_string());
386 }
387 req
388 }
389 Self::Skill { sub, name } => {
390 let s = require_session(session_id)?;
391 let mut req = Request::new("vs_skill").flag_value("session", s);
392 let sub = sub.as_deref().unwrap_or("list");
393 req = req.arg(sub.to_string());
394 if let Some(n) = name {
395 req = req.arg(n.clone());
396 }
397 req
398 }
399 Self::Capture { page, r, full_page } => {
400 let s = require_session(session_id)?;
401 let mut req = Request::new("vs_capture")
402 .arg(page.clone())
403 .flag_value("session", s);
404 if let Some(rr) = r {
405 req = req.arg(rr.to_string());
406 }
407 if *full_page {
408 req = req.flag("full-page");
409 }
410 req
411 }
412 Self::Viewport { page, spec, dpr } => {
413 let s = require_session(session_id)?;
414 Request::new("vs_viewport")
415 .arg(page.clone())
416 .arg(spec.clone())
417 .flag_value("session", s)
418 .flag_value("dpr", dpr.to_string())
419 }
420 Self::Layout { page, refs } => {
421 let s = require_session(session_id)?;
422 let mut req = Request::new("vs_layout").arg(page.clone());
423 for r in refs {
424 req = req.arg(r.to_string());
425 }
426 req.flag_value("session", s)
427 }
428 Self::Auth { sub, rest } => {
429 let s = require_session(session_id)?;
430 let mut req = Request::new("vs_auth")
431 .arg(sub.clone())
432 .flag_value("session", s);
433 for r in rest {
434 req = req.arg(r.clone());
435 }
436 req
437 }
438 Self::Inspect {
439 page,
440 kind,
441 rest,
442 since,
443 level,
444 status,
445 max,
446 full,
447 unsafe_log,
448 } => {
449 let s = require_session(session_id)?;
450 let kind_long = normalize_inspect_kind(kind);
451 let mut req = Request::new("vs_inspect")
452 .arg(kind_long.to_string())
453 .arg(page.clone());
454 for r in rest {
455 req = req.arg(r.clone());
456 }
457 req = req.flag_value("session", s);
458 if let Some(v) = since {
459 req = req.flag_value("since", v.clone());
460 }
461 if let Some(v) = level {
462 req = req.flag_value("level", v.clone());
463 }
464 if let Some(v) = status {
465 req = req.flag_value("status", v.clone());
466 }
467 if let Some(v) = max {
468 req = req.flag_value("max", v.clone());
469 }
470 if *full {
471 req = req.flag("full");
472 }
473 if *unsafe_log {
474 req = req.flag("unsafe-log");
475 }
476 req
477 }
478 Self::Serve { .. } => {
479 anyhow::bail!("vs_serve is local; route via main, not the wire dispatcher");
480 }
481 Self::Mcp => {
482 anyhow::bail!("vs_mcp is local; route via main, not the wire dispatcher");
483 }
484 })
485 }
486
487 #[must_use]
489 pub fn needs_session(&self) -> bool {
490 !matches!(
491 self,
492 Self::SessionOpen { .. } | Self::Status | Self::Serve { .. } | Self::Mcp
493 )
494 }
495}
496
497fn require_session(session: Option<&str>) -> Result<String> {
498 session
499 .map(str::to_string)
500 .context("no active session — run `vs session-open` or pass `--session=<id>`")
501}
502
503fn normalize_inspect_kind(kind: &str) -> &str {
508 match kind {
509 "co" => "console",
510 "n" => "network",
511 "req" => "request",
512 "e" => "eval",
513 "s" => "storage",
514 "scr" => "scripts",
515 "src" => "script",
516 "d" => "dom",
517 "p" => "performance",
518 "ce" => "cookie-events",
519 other => other,
520 }
521}
522
523mod dispatch;
524mod render;
525
526pub use dispatch::{connect, resolve_paths, resolve_session, run};
527pub use render::render;