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 #[arg(long, alias = "b64")]
172 base64: bool,
173 },
174 #[command(visible_alias = "vp")]
177 Viewport {
178 page: String,
179 spec: String,
180 #[arg(long, default_value_t = 2)]
181 dpr: u32,
182 },
183 #[command(visible_alias = "lay")]
185 Layout {
186 page: String,
187 #[arg(value_name = "REF", required = true)]
188 refs: Vec<u32>,
189 },
190 #[command(visible_alias = "au")]
193 Auth {
194 sub: String,
195 #[arg(num_args = 0..=2)]
196 rest: Vec<String>,
197 },
198 #[command(visible_alias = "i")]
204 Inspect {
205 page: String,
206 kind: String,
207 #[arg(num_args = 0..=3)]
208 rest: Vec<String>,
209 #[arg(long, short = 's')]
210 since: Option<String>,
211 #[arg(long)]
212 level: Option<String>,
213 #[arg(long)]
214 status: Option<String>,
215 #[arg(long)]
216 max: Option<String>,
217 #[arg(long, short = 'F')]
218 full: bool,
219 #[arg(long = "unsafe-log")]
220 unsafe_log: bool,
221 },
222 #[command(visible_alias = "mt")]
226 MoveTo {
227 page: String,
228 x: f64,
229 y: f64,
230 #[arg(long, short = 'M', default_value = "human")]
231 mode: String,
232 },
233 #[command(visible_alias = "ca")]
235 ClickAt {
236 page: String,
237 x: f64,
238 y: f64,
239 #[arg(long)]
240 token: String,
241 #[arg(long, short = 'M', default_value = "human")]
242 mode: String,
243 },
244 #[command(visible_alias = "ha")]
246 HoverAt {
247 page: String,
248 x: f64,
249 y: f64,
250 #[arg(long, short = 'M', default_value = "human")]
251 mode: String,
252 },
253 #[command(visible_alias = "dr")]
255 Drag {
256 page: String,
257 x1: f64,
258 y1: f64,
259 x2: f64,
260 y2: f64,
261 #[arg(long)]
262 token: String,
263 #[arg(long, short = 'M', default_value = "human")]
264 mode: String,
265 },
266 #[command(visible_alias = "pi")]
272 PromptInput {
273 page: String,
274 #[arg(value_name = "REF")]
275 r: u32,
276 #[arg(long)]
277 message: String,
278 #[arg(long)]
279 secret: bool,
280 #[arg(long)]
281 token: String,
282 #[arg(long)]
283 group: Option<String>,
284 },
285 #[command(visible_alias = "pc")]
289 PromptConfirm {
290 page: String,
291 #[arg(long)]
292 message: String,
293 },
294 #[command(hide = true)]
299 PromptInputQueue {
300 page: String,
301 r: u32,
302 #[arg(long)]
303 message: String,
304 #[arg(long, default_value_t = false)]
305 secret: bool,
306 #[arg(long)]
307 token: String,
308 #[arg(long)]
309 group: Option<String>,
310 #[arg(long = "timeout-ms", default_value_t = 300_000)]
313 timeout_ms: u64,
314 },
315 #[command(visible_alias = "pe")]
321 Pending {
322 #[command(subcommand)]
323 sub: PendingSub,
324 },
325 Serve {
329 #[arg(long)]
333 stop: bool,
334 },
335 Mcp,
340}
341
342#[derive(Debug, Subcommand)]
343pub enum PendingSub {
344 #[command(visible_alias = "ls")]
347 List,
348 #[command(visible_alias = "f")]
353 Fulfill {
354 id: Option<String>,
357 },
358 #[command(visible_alias = "c")]
361 Cancel {
362 id: String,
363 },
364}
365
366impl Command {
367 #[allow(clippy::too_many_lines)]
370 pub fn to_request(&self, session_id: Option<&str>) -> Result<Request> {
371 Ok(match self {
372 Self::SessionOpen { policy } => {
373 let mut r = Request::new("vs_session_open");
374 if let Some(p) = policy {
375 r = r.flag_value("policy", p.clone());
376 }
377 r
378 }
379 Self::SessionClose => {
380 let s = require_session(session_id)?;
381 Request::new("vs_session_close").arg(s)
382 }
383 Self::Open { url } => {
384 let s = require_session(session_id)?;
385 Request::new("vs_open")
386 .arg(url.clone())
387 .flag_value("session", s)
388 }
389 Self::Close { page } => {
390 let s = require_session(session_id)?;
391 Request::new("vs_close")
392 .arg(page.clone())
393 .flag_value("session", s)
394 }
395 Self::View { page, full } => {
396 let s = require_session(session_id)?;
397 let mut r = Request::new("vs_view")
398 .arg(page.clone())
399 .flag_value("session", s);
400 if *full {
401 r = r.flag("full");
402 }
403 r
404 }
405 Self::Read { page, r } => {
406 let s = require_session(session_id)?;
407 Request::new("vs_read")
408 .arg(page.clone())
409 .arg(r.to_string())
410 .flag_value("session", s)
411 }
412 Self::Act {
413 page,
414 r,
415 op,
416 value,
417 token,
418 group,
419 } => {
420 let s = require_session(session_id)?;
421 let mut req = Request::new("vs_act")
422 .arg(page.clone())
423 .arg(r.to_string())
424 .arg(op.clone());
425 if let Some(v) = value {
426 req = req.arg(v.clone());
427 }
428 req = req
429 .flag_value("session", s)
430 .flag_value("token", token.clone());
431 if let Some(g) = group {
432 req = req.flag_value("group", g.clone());
433 }
434 req
435 }
436 Self::Find { query } => {
437 let s = require_session(session_id)?;
438 Request::new("vs_find")
439 .arg(query.clone())
440 .flag_value("session", s)
441 }
442 Self::Wait {
443 page,
444 cond,
445 value,
446 timeout,
447 } => {
448 let s = require_session(session_id)?;
449 let mut req = Request::new("vs_wait").arg(page.clone()).arg(cond.clone());
450 if let Some(v) = value {
451 req = req.arg(v.clone());
452 }
453 req.flag_value("session", s)
454 .flag_value("timeout", format!("{timeout}ms"))
455 }
456 Self::Status => {
457 let mut r = Request::new("vs_status");
458 if let Some(s) = session_id {
459 r = r.flag_value("session", s.to_string());
460 }
461 r
462 }
463 Self::Extract {
464 page,
465 schema,
466 token,
467 } => {
468 let s = require_session(session_id)?;
469 Request::new("vs_extract")
470 .arg(page.clone())
471 .arg(schema.clone())
472 .flag_value("session", s)
473 .flag_value("token", token.clone())
474 }
475 Self::Mark {
476 page,
477 r,
478 name,
479 token,
480 } => {
481 let s = require_session(session_id)?;
482 Request::new("vs_mark")
483 .arg(page.clone())
484 .arg(r.to_string())
485 .arg(name.clone())
486 .flag_value("session", s)
487 .flag_value("token", token.clone())
488 }
489 Self::Annotate { target, key, value } => {
490 let s = require_session(session_id)?;
491 let mut req = Request::new("vs_annotate")
492 .arg(target.clone())
493 .arg(key.clone());
494 if let Some(v) = value {
495 req = req.arg(v.clone());
496 }
497 req.flag_value("session", s)
498 }
499 Self::Log {
500 page,
501 group,
502 since,
503 limit,
504 } => {
505 let s = require_session(session_id)?;
506 let mut req = Request::new("vs_log").flag_value("session", s);
507 if let Some(p) = page {
508 req = req.flag_value("page", p.clone());
509 }
510 if let Some(g) = group {
511 req = req.flag_value("group", g.clone());
512 }
513 if let Some(t) = since {
514 req = req.flag_value("since", t.to_string());
515 }
516 if let Some(l) = limit {
517 req = req.flag_value("limit", l.to_string());
518 }
519 req
520 }
521 Self::Skill { sub, name } => {
522 let s = require_session(session_id)?;
523 let mut req = Request::new("vs_skill").flag_value("session", s);
524 let sub = sub.as_deref().unwrap_or("list");
525 req = req.arg(sub.to_string());
526 if let Some(n) = name {
527 req = req.arg(n.clone());
528 }
529 req
530 }
531 Self::Capture { page, r, full_page, base64: _ } => {
532 let s = require_session(session_id)?;
536 let mut req = Request::new("vs_capture")
537 .arg(page.clone())
538 .flag_value("session", s);
539 if let Some(rr) = r {
540 req = req.arg(rr.to_string());
541 }
542 if *full_page {
543 req = req.flag("full-page");
544 }
545 req
546 }
547 Self::Viewport { page, spec, dpr } => {
548 let s = require_session(session_id)?;
549 Request::new("vs_viewport")
550 .arg(page.clone())
551 .arg(spec.clone())
552 .flag_value("session", s)
553 .flag_value("dpr", dpr.to_string())
554 }
555 Self::Layout { page, refs } => {
556 let s = require_session(session_id)?;
557 let mut req = Request::new("vs_layout").arg(page.clone());
558 for r in refs {
559 req = req.arg(r.to_string());
560 }
561 req.flag_value("session", s)
562 }
563 Self::Auth { sub, rest } => {
564 let s = require_session(session_id)?;
565 let mut req = Request::new("vs_auth")
566 .arg(sub.clone())
567 .flag_value("session", s);
568 for r in rest {
569 req = req.arg(r.clone());
570 }
571 req
572 }
573 Self::Inspect {
574 page,
575 kind,
576 rest,
577 since,
578 level,
579 status,
580 max,
581 full,
582 unsafe_log,
583 } => {
584 let s = require_session(session_id)?;
585 let kind_long = normalize_inspect_kind(kind);
586 let mut req = Request::new("vs_inspect")
587 .arg(kind_long.to_string())
588 .arg(page.clone());
589 for r in rest {
590 req = req.arg(r.clone());
591 }
592 req = req.flag_value("session", s);
593 if let Some(v) = since {
594 req = req.flag_value("since", v.clone());
595 }
596 if let Some(v) = level {
597 req = req.flag_value("level", v.clone());
598 }
599 if let Some(v) = status {
600 req = req.flag_value("status", v.clone());
601 }
602 if let Some(v) = max {
603 req = req.flag_value("max", v.clone());
604 }
605 if *full {
606 req = req.flag("full");
607 }
608 if *unsafe_log {
609 req = req.flag("unsafe-log");
610 }
611 req
612 }
613 Self::MoveTo { page, x, y, mode } => {
614 let s = require_session(session_id)?;
615 Request::new("vs_move_to")
616 .arg(page.clone())
617 .arg(x.to_string())
618 .arg(y.to_string())
619 .flag_value("session", s)
620 .flag_value("mode", mode.clone())
621 }
622 Self::ClickAt {
623 page,
624 x,
625 y,
626 token,
627 mode,
628 } => {
629 let s = require_session(session_id)?;
630 Request::new("vs_click_at")
631 .arg(page.clone())
632 .arg(x.to_string())
633 .arg(y.to_string())
634 .flag_value("session", s)
635 .flag_value("token", token.clone())
636 .flag_value("mode", mode.clone())
637 }
638 Self::HoverAt { page, x, y, mode } => {
639 let s = require_session(session_id)?;
640 Request::new("vs_hover_at")
641 .arg(page.clone())
642 .arg(x.to_string())
643 .arg(y.to_string())
644 .flag_value("session", s)
645 .flag_value("mode", mode.clone())
646 }
647 Self::Drag {
648 page,
649 x1,
650 y1,
651 x2,
652 y2,
653 token,
654 mode,
655 } => {
656 let s = require_session(session_id)?;
657 Request::new("vs_drag")
658 .arg(page.clone())
659 .arg(x1.to_string())
660 .arg(y1.to_string())
661 .arg(x2.to_string())
662 .arg(y2.to_string())
663 .flag_value("session", s)
664 .flag_value("token", token.clone())
665 .flag_value("mode", mode.clone())
666 }
667 Self::PromptInput { .. } | Self::PromptConfirm { .. } => {
668 anyhow::bail!("vs_prompt_* is local; route via main, not the wire dispatcher");
669 }
670 Self::PromptInputQueue {
671 page, r, message, secret, token, group, timeout_ms,
672 } => {
673 let s = require_session(session_id)?;
674 let mut req = Request::new("vs_prompt_input_queue")
675 .arg(page.clone())
676 .arg(r.to_string())
677 .arg(message.clone())
678 .flag_value("session", s)
679 .flag_value("token", token.clone())
680 .flag_value("timeout-ms", timeout_ms.to_string());
681 if *secret { req = req.flag("secret"); }
682 if let Some(g) = group { req = req.flag_value("group", g.clone()); }
683 req
684 }
685 Self::Pending { sub } => match sub {
686 PendingSub::List => Request::new("vs_pending_list"),
687 PendingSub::Fulfill { id } => {
688 let id_v = id.clone().unwrap_or_default();
693 Request::new("vs_pending_fulfill").arg(id_v).arg(String::new())
694 }
695 PendingSub::Cancel { id } => Request::new("vs_pending_cancel").arg(id.clone()),
696 },
697 Self::Serve { .. } => {
698 anyhow::bail!("vs_serve is local; route via main, not the wire dispatcher");
699 }
700 Self::Mcp => {
701 anyhow::bail!("vs_mcp is local; route via main, not the wire dispatcher");
702 }
703 })
704 }
705
706 #[must_use]
708 pub fn needs_session(&self) -> bool {
709 !matches!(
710 self,
711 Self::SessionOpen { .. } | Self::Status | Self::Serve { .. } | Self::Mcp | Self::Pending { .. }
712 )
713 }
714}
715
716fn require_session(session: Option<&str>) -> Result<String> {
717 session
718 .map(str::to_string)
719 .context("no active session — run `vs session-open` or pass `--session=<id>`")
720}
721
722fn normalize_inspect_kind(kind: &str) -> &str {
727 match kind {
728 "co" => "console",
729 "n" => "network",
730 "req" => "request",
731 "e" => "eval",
732 "s" => "storage",
733 "scr" => "scripts",
734 "src" => "script",
735 "d" => "dom",
736 "p" => "performance",
737 "ce" => "cookie-events",
738 other => other,
739 }
740}
741
742mod dispatch;
743mod render;
744
745pub use dispatch::{connect, resolve_paths, resolve_session, run};
746pub use render::render;