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 #[command(visible_alias = "mt")]
221 MoveTo {
222 page: String,
223 x: f64,
224 y: f64,
225 #[arg(long, short = 'M', default_value = "human")]
226 mode: String,
227 },
228 #[command(visible_alias = "ca")]
230 ClickAt {
231 page: String,
232 x: f64,
233 y: f64,
234 #[arg(long)]
235 token: String,
236 #[arg(long, short = 'M', default_value = "human")]
237 mode: String,
238 },
239 #[command(visible_alias = "ha")]
241 HoverAt {
242 page: String,
243 x: f64,
244 y: f64,
245 #[arg(long, short = 'M', default_value = "human")]
246 mode: String,
247 },
248 #[command(visible_alias = "dr")]
250 Drag {
251 page: String,
252 x1: f64,
253 y1: f64,
254 x2: f64,
255 y2: f64,
256 #[arg(long)]
257 token: String,
258 #[arg(long, short = 'M', default_value = "human")]
259 mode: String,
260 },
261 Serve {
265 #[arg(long)]
269 stop: bool,
270 },
271 Mcp,
276}
277
278impl Command {
279 #[allow(clippy::too_many_lines)]
282 pub fn to_request(&self, session_id: Option<&str>) -> Result<Request> {
283 Ok(match self {
284 Self::SessionOpen { policy } => {
285 let mut r = Request::new("vs_session_open");
286 if let Some(p) = policy {
287 r = r.flag_value("policy", p.clone());
288 }
289 r
290 }
291 Self::SessionClose => {
292 let s = require_session(session_id)?;
293 Request::new("vs_session_close").arg(s)
294 }
295 Self::Open { url } => {
296 let s = require_session(session_id)?;
297 Request::new("vs_open")
298 .arg(url.clone())
299 .flag_value("session", s)
300 }
301 Self::Close { page } => {
302 let s = require_session(session_id)?;
303 Request::new("vs_close")
304 .arg(page.clone())
305 .flag_value("session", s)
306 }
307 Self::View { page, full } => {
308 let s = require_session(session_id)?;
309 let mut r = Request::new("vs_view")
310 .arg(page.clone())
311 .flag_value("session", s);
312 if *full {
313 r = r.flag("full");
314 }
315 r
316 }
317 Self::Read { page, r } => {
318 let s = require_session(session_id)?;
319 Request::new("vs_read")
320 .arg(page.clone())
321 .arg(r.to_string())
322 .flag_value("session", s)
323 }
324 Self::Act {
325 page,
326 r,
327 op,
328 value,
329 token,
330 group,
331 } => {
332 let s = require_session(session_id)?;
333 let mut req = Request::new("vs_act")
334 .arg(page.clone())
335 .arg(r.to_string())
336 .arg(op.clone());
337 if let Some(v) = value {
338 req = req.arg(v.clone());
339 }
340 req = req
341 .flag_value("session", s)
342 .flag_value("token", token.clone());
343 if let Some(g) = group {
344 req = req.flag_value("group", g.clone());
345 }
346 req
347 }
348 Self::Find { query } => {
349 let s = require_session(session_id)?;
350 Request::new("vs_find")
351 .arg(query.clone())
352 .flag_value("session", s)
353 }
354 Self::Wait {
355 page,
356 cond,
357 value,
358 timeout,
359 } => {
360 let s = require_session(session_id)?;
361 let mut req = Request::new("vs_wait").arg(page.clone()).arg(cond.clone());
362 if let Some(v) = value {
363 req = req.arg(v.clone());
364 }
365 req.flag_value("session", s)
366 .flag_value("timeout", format!("{timeout}ms"))
367 }
368 Self::Status => {
369 let mut r = Request::new("vs_status");
370 if let Some(s) = session_id {
371 r = r.flag_value("session", s.to_string());
372 }
373 r
374 }
375 Self::Extract {
376 page,
377 schema,
378 token,
379 } => {
380 let s = require_session(session_id)?;
381 Request::new("vs_extract")
382 .arg(page.clone())
383 .arg(schema.clone())
384 .flag_value("session", s)
385 .flag_value("token", token.clone())
386 }
387 Self::Mark {
388 page,
389 r,
390 name,
391 token,
392 } => {
393 let s = require_session(session_id)?;
394 Request::new("vs_mark")
395 .arg(page.clone())
396 .arg(r.to_string())
397 .arg(name.clone())
398 .flag_value("session", s)
399 .flag_value("token", token.clone())
400 }
401 Self::Annotate { target, key, value } => {
402 let s = require_session(session_id)?;
403 let mut req = Request::new("vs_annotate")
404 .arg(target.clone())
405 .arg(key.clone());
406 if let Some(v) = value {
407 req = req.arg(v.clone());
408 }
409 req.flag_value("session", s)
410 }
411 Self::Log {
412 page,
413 group,
414 since,
415 limit,
416 } => {
417 let s = require_session(session_id)?;
418 let mut req = Request::new("vs_log").flag_value("session", s);
419 if let Some(p) = page {
420 req = req.flag_value("page", p.clone());
421 }
422 if let Some(g) = group {
423 req = req.flag_value("group", g.clone());
424 }
425 if let Some(t) = since {
426 req = req.flag_value("since", t.to_string());
427 }
428 if let Some(l) = limit {
429 req = req.flag_value("limit", l.to_string());
430 }
431 req
432 }
433 Self::Skill { sub, name } => {
434 let s = require_session(session_id)?;
435 let mut req = Request::new("vs_skill").flag_value("session", s);
436 let sub = sub.as_deref().unwrap_or("list");
437 req = req.arg(sub.to_string());
438 if let Some(n) = name {
439 req = req.arg(n.clone());
440 }
441 req
442 }
443 Self::Capture { page, r, full_page } => {
444 let s = require_session(session_id)?;
445 let mut req = Request::new("vs_capture")
446 .arg(page.clone())
447 .flag_value("session", s);
448 if let Some(rr) = r {
449 req = req.arg(rr.to_string());
450 }
451 if *full_page {
452 req = req.flag("full-page");
453 }
454 req
455 }
456 Self::Viewport { page, spec, dpr } => {
457 let s = require_session(session_id)?;
458 Request::new("vs_viewport")
459 .arg(page.clone())
460 .arg(spec.clone())
461 .flag_value("session", s)
462 .flag_value("dpr", dpr.to_string())
463 }
464 Self::Layout { page, refs } => {
465 let s = require_session(session_id)?;
466 let mut req = Request::new("vs_layout").arg(page.clone());
467 for r in refs {
468 req = req.arg(r.to_string());
469 }
470 req.flag_value("session", s)
471 }
472 Self::Auth { sub, rest } => {
473 let s = require_session(session_id)?;
474 let mut req = Request::new("vs_auth")
475 .arg(sub.clone())
476 .flag_value("session", s);
477 for r in rest {
478 req = req.arg(r.clone());
479 }
480 req
481 }
482 Self::Inspect {
483 page,
484 kind,
485 rest,
486 since,
487 level,
488 status,
489 max,
490 full,
491 unsafe_log,
492 } => {
493 let s = require_session(session_id)?;
494 let kind_long = normalize_inspect_kind(kind);
495 let mut req = Request::new("vs_inspect")
496 .arg(kind_long.to_string())
497 .arg(page.clone());
498 for r in rest {
499 req = req.arg(r.clone());
500 }
501 req = req.flag_value("session", s);
502 if let Some(v) = since {
503 req = req.flag_value("since", v.clone());
504 }
505 if let Some(v) = level {
506 req = req.flag_value("level", v.clone());
507 }
508 if let Some(v) = status {
509 req = req.flag_value("status", v.clone());
510 }
511 if let Some(v) = max {
512 req = req.flag_value("max", v.clone());
513 }
514 if *full {
515 req = req.flag("full");
516 }
517 if *unsafe_log {
518 req = req.flag("unsafe-log");
519 }
520 req
521 }
522 Self::MoveTo { page, x, y, mode } => {
523 let s = require_session(session_id)?;
524 Request::new("vs_move_to")
525 .arg(page.clone())
526 .arg(x.to_string())
527 .arg(y.to_string())
528 .flag_value("session", s)
529 .flag_value("mode", mode.clone())
530 }
531 Self::ClickAt {
532 page,
533 x,
534 y,
535 token,
536 mode,
537 } => {
538 let s = require_session(session_id)?;
539 Request::new("vs_click_at")
540 .arg(page.clone())
541 .arg(x.to_string())
542 .arg(y.to_string())
543 .flag_value("session", s)
544 .flag_value("token", token.clone())
545 .flag_value("mode", mode.clone())
546 }
547 Self::HoverAt { page, x, y, mode } => {
548 let s = require_session(session_id)?;
549 Request::new("vs_hover_at")
550 .arg(page.clone())
551 .arg(x.to_string())
552 .arg(y.to_string())
553 .flag_value("session", s)
554 .flag_value("mode", mode.clone())
555 }
556 Self::Drag {
557 page,
558 x1,
559 y1,
560 x2,
561 y2,
562 token,
563 mode,
564 } => {
565 let s = require_session(session_id)?;
566 Request::new("vs_drag")
567 .arg(page.clone())
568 .arg(x1.to_string())
569 .arg(y1.to_string())
570 .arg(x2.to_string())
571 .arg(y2.to_string())
572 .flag_value("session", s)
573 .flag_value("token", token.clone())
574 .flag_value("mode", mode.clone())
575 }
576 Self::Serve { .. } => {
577 anyhow::bail!("vs_serve is local; route via main, not the wire dispatcher");
578 }
579 Self::Mcp => {
580 anyhow::bail!("vs_mcp is local; route via main, not the wire dispatcher");
581 }
582 })
583 }
584
585 #[must_use]
587 pub fn needs_session(&self) -> bool {
588 !matches!(
589 self,
590 Self::SessionOpen { .. } | Self::Status | Self::Serve { .. } | Self::Mcp
591 )
592 }
593}
594
595fn require_session(session: Option<&str>) -> Result<String> {
596 session
597 .map(str::to_string)
598 .context("no active session — run `vs session-open` or pass `--session=<id>`")
599}
600
601fn normalize_inspect_kind(kind: &str) -> &str {
606 match kind {
607 "co" => "console",
608 "n" => "network",
609 "req" => "request",
610 "e" => "eval",
611 "s" => "storage",
612 "scr" => "scripts",
613 "src" => "script",
614 "d" => "dom",
615 "p" => "performance",
616 "ce" => "cookie-events",
617 other => other,
618 }
619}
620
621mod dispatch;
622mod render;
623
624pub use dispatch::{connect, resolve_paths, resolve_session, run};
625pub use render::render;