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