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 Mcp,
226}
227
228impl Command {
229 #[allow(clippy::too_many_lines)]
232 pub fn to_request(&self, session_id: Option<&str>) -> Result<Request> {
233 Ok(match self {
234 Self::SessionOpen { policy } => {
235 let mut r = Request::new("vs_session_open");
236 if let Some(p) = policy {
237 r = r.flag_value("policy", p.clone());
238 }
239 r
240 }
241 Self::SessionClose => {
242 let s = require_session(session_id)?;
243 Request::new("vs_session_close").arg(s)
244 }
245 Self::Open { url } => {
246 let s = require_session(session_id)?;
247 Request::new("vs_open")
248 .arg(url.clone())
249 .flag_value("session", s)
250 }
251 Self::Close { page } => {
252 let s = require_session(session_id)?;
253 Request::new("vs_close")
254 .arg(page.clone())
255 .flag_value("session", s)
256 }
257 Self::View { page, full } => {
258 let s = require_session(session_id)?;
259 let mut r = Request::new("vs_view")
260 .arg(page.clone())
261 .flag_value("session", s);
262 if *full {
263 r = r.flag("full");
264 }
265 r
266 }
267 Self::Read { page, r } => {
268 let s = require_session(session_id)?;
269 Request::new("vs_read")
270 .arg(page.clone())
271 .arg(r.to_string())
272 .flag_value("session", s)
273 }
274 Self::Act {
275 page,
276 r,
277 op,
278 value,
279 token,
280 group,
281 } => {
282 let s = require_session(session_id)?;
283 let mut req = Request::new("vs_act")
284 .arg(page.clone())
285 .arg(r.to_string())
286 .arg(op.clone());
287 if let Some(v) = value {
288 req = req.arg(v.clone());
289 }
290 req = req
291 .flag_value("session", s)
292 .flag_value("token", token.clone());
293 if let Some(g) = group {
294 req = req.flag_value("group", g.clone());
295 }
296 req
297 }
298 Self::Find { query } => {
299 let s = require_session(session_id)?;
300 Request::new("vs_find")
301 .arg(query.clone())
302 .flag_value("session", s)
303 }
304 Self::Wait {
305 page,
306 cond,
307 value,
308 timeout,
309 } => {
310 let s = require_session(session_id)?;
311 let mut req = Request::new("vs_wait").arg(page.clone()).arg(cond.clone());
312 if let Some(v) = value {
313 req = req.arg(v.clone());
314 }
315 req.flag_value("session", s)
316 .flag_value("timeout", format!("{timeout}ms"))
317 }
318 Self::Status => {
319 let mut r = Request::new("vs_status");
320 if let Some(s) = session_id {
321 r = r.flag_value("session", s.to_string());
322 }
323 r
324 }
325 Self::Extract {
326 page,
327 schema,
328 token,
329 } => {
330 let s = require_session(session_id)?;
331 Request::new("vs_extract")
332 .arg(page.clone())
333 .arg(schema.clone())
334 .flag_value("session", s)
335 .flag_value("token", token.clone())
336 }
337 Self::Mark {
338 page,
339 r,
340 name,
341 token,
342 } => {
343 let s = require_session(session_id)?;
344 Request::new("vs_mark")
345 .arg(page.clone())
346 .arg(r.to_string())
347 .arg(name.clone())
348 .flag_value("session", s)
349 .flag_value("token", token.clone())
350 }
351 Self::Annotate { target, key, value } => {
352 let s = require_session(session_id)?;
353 let mut req = Request::new("vs_annotate")
354 .arg(target.clone())
355 .arg(key.clone());
356 if let Some(v) = value {
357 req = req.arg(v.clone());
358 }
359 req.flag_value("session", s)
360 }
361 Self::Log {
362 page,
363 group,
364 since,
365 limit,
366 } => {
367 let s = require_session(session_id)?;
368 let mut req = Request::new("vs_log").flag_value("session", s);
369 if let Some(p) = page {
370 req = req.flag_value("page", p.clone());
371 }
372 if let Some(g) = group {
373 req = req.flag_value("group", g.clone());
374 }
375 if let Some(t) = since {
376 req = req.flag_value("since", t.to_string());
377 }
378 if let Some(l) = limit {
379 req = req.flag_value("limit", l.to_string());
380 }
381 req
382 }
383 Self::Skill { sub, name } => {
384 let s = require_session(session_id)?;
385 let mut req = Request::new("vs_skill").flag_value("session", s);
386 let sub = sub.as_deref().unwrap_or("list");
387 req = req.arg(sub.to_string());
388 if let Some(n) = name {
389 req = req.arg(n.clone());
390 }
391 req
392 }
393 Self::Capture { page, r, full_page } => {
394 let s = require_session(session_id)?;
395 let mut req = Request::new("vs_capture")
396 .arg(page.clone())
397 .flag_value("session", s);
398 if let Some(rr) = r {
399 req = req.arg(rr.to_string());
400 }
401 if *full_page {
402 req = req.flag("full-page");
403 }
404 req
405 }
406 Self::Viewport { page, spec, dpr } => {
407 let s = require_session(session_id)?;
408 Request::new("vs_viewport")
409 .arg(page.clone())
410 .arg(spec.clone())
411 .flag_value("session", s)
412 .flag_value("dpr", dpr.to_string())
413 }
414 Self::Layout { page, refs } => {
415 let s = require_session(session_id)?;
416 let mut req = Request::new("vs_layout").arg(page.clone());
417 for r in refs {
418 req = req.arg(r.to_string());
419 }
420 req.flag_value("session", s)
421 }
422 Self::Auth { sub, rest } => {
423 let s = require_session(session_id)?;
424 let mut req = Request::new("vs_auth")
425 .arg(sub.clone())
426 .flag_value("session", s);
427 for r in rest {
428 req = req.arg(r.clone());
429 }
430 req
431 }
432 Self::Inspect {
433 page,
434 kind,
435 rest,
436 since,
437 level,
438 status,
439 max,
440 full,
441 unsafe_log,
442 } => {
443 let s = require_session(session_id)?;
444 let kind_long = normalize_inspect_kind(kind);
445 let mut req = Request::new("vs_inspect")
446 .arg(kind_long.to_string())
447 .arg(page.clone());
448 for r in rest {
449 req = req.arg(r.clone());
450 }
451 req = req.flag_value("session", s);
452 if let Some(v) = since {
453 req = req.flag_value("since", v.clone());
454 }
455 if let Some(v) = level {
456 req = req.flag_value("level", v.clone());
457 }
458 if let Some(v) = status {
459 req = req.flag_value("status", v.clone());
460 }
461 if let Some(v) = max {
462 req = req.flag_value("max", v.clone());
463 }
464 if *full {
465 req = req.flag("full");
466 }
467 if *unsafe_log {
468 req = req.flag("unsafe-log");
469 }
470 req
471 }
472 Self::Serve => {
473 anyhow::bail!("vs_serve is local; route via main, not the wire dispatcher");
474 }
475 Self::Mcp => {
476 anyhow::bail!("vs_mcp is local; route via main, not the wire dispatcher");
477 }
478 })
479 }
480
481 #[must_use]
483 pub fn needs_session(&self) -> bool {
484 !matches!(
485 self,
486 Self::SessionOpen { .. } | Self::Status | Self::Serve | Self::Mcp
487 )
488 }
489}
490
491fn require_session(session: Option<&str>) -> Result<String> {
492 session
493 .map(str::to_string)
494 .context("no active session — run `vs session-open` or pass `--session=<id>`")
495}
496
497fn normalize_inspect_kind(kind: &str) -> &str {
502 match kind {
503 "co" => "console",
504 "n" => "network",
505 "req" => "request",
506 "e" => "eval",
507 "s" => "storage",
508 "scr" => "scripts",
509 "src" => "script",
510 "d" => "dom",
511 "p" => "performance",
512 other => other,
513 }
514}
515
516mod dispatch;
517mod render;
518
519pub use dispatch::{connect, resolve_paths, resolve_session, run};
520pub use render::render;