Skip to main content

vs_cli/commands/
mod.rs

1//! `clap`-derived command tree and dispatch.
2//!
3//! Each primitive is one subcommand. [`run`] builds a [`Request`] from
4//! the subcommand, calls [`Client::call`](crate::client::Client::call),
5//! and returns the response; [`render`] formats it for stdout.
6
7use std::path::PathBuf;
8
9use anyhow::{Context as _, Result};
10use clap::{Parser, Subcommand};
11use vs_protocol::Request;
12
13/// Top-level CLI.
14#[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    /// Override the active session id (otherwise read from
23    /// `$VIBESURFER_HOME/active-session`).
24    #[arg(long, short = 'S', global = true)]
25    pub session: Option<String>,
26
27    /// Override the daemon socket path. Tests pass an explicit path;
28    /// in production this defaults to `$HOME/.vibesurfer/daemon.sock`.
29    #[arg(long, global = true)]
30    pub socket: Option<PathBuf>,
31
32    /// Override the vibesurfer home directory. Useful for tests.
33    #[arg(long, global = true)]
34    pub home: Option<PathBuf>,
35
36    /// Skip the daemon auto-spawn step. Tests start the daemon
37    /// themselves and connect via `--socket`.
38    #[arg(long, global = true)]
39    pub no_spawn: bool,
40
41    /// Emit the response as JSON for human inspection. The default is
42    /// the line-oriented wire form that agents consume.
43    #[arg(long, short = 'j', global = true)]
44    pub json: bool,
45
46    #[command(subcommand)]
47    pub command: Command,
48}
49
50/// The subcommand vocabulary. One variant per primitive.
51///
52/// Each variant has a `visible_alias` short form for token economy in
53/// agent contexts (mirrors agented's `s` / `i` / `d` / `w` / `br`
54/// pattern). Long forms remain for human readers.
55#[derive(Debug, Subcommand)]
56pub enum Command {
57    /// 1. Open a new session.
58    #[command(visible_alias = "so")]
59    SessionOpen {
60        #[arg(long)]
61        policy: Option<String>,
62    },
63    /// 2. Close the active session.
64    #[command(visible_alias = "sc")]
65    SessionClose,
66    /// 3. Open a page navigated to URL.
67    #[command(visible_alias = "o")]
68    Open { url: String },
69    /// 4. Close a page.
70    #[command(visible_alias = "c")]
71    Close { page: String },
72    /// 5. View a page (delta by default; `--full` re-baselines).
73    #[command(visible_alias = "v")]
74    View {
75        page: String,
76        #[arg(long, short = 'F')]
77        full: bool,
78    },
79    /// 6. Read the full text of a ref.
80    #[command(visible_alias = "r")]
81    Read {
82        page: String,
83        #[arg(value_name = "REF")]
84        r: u32,
85    },
86    /// 7. Perform an action on a ref.
87    #[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    /// 8. Search across pages in the session.
100    #[command(visible_alias = "f")]
101    Find { query: String },
102    /// 9. Wait for a condition on a page.
103    #[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    /// 13. Status summary for the active session (or all sessions).
112    #[command(visible_alias = "st")]
113    Status,
114    /// 10. Extract structured data using a known schema.
115    #[command(visible_alias = "x")]
116    Extract {
117        page: String,
118        schema: String,
119        #[arg(long)]
120        token: String,
121    },
122    /// 11. Persist a ref as a named anchor.
123    #[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    /// 12. Attach a (key, value) annotation to a target (one of
133    ///     `ref:N`, `mark:NAME`, or `page`).
134    #[command(visible_alias = "an")]
135    Annotate {
136        target: String,
137        key: String,
138        value: Option<String>,
139    },
140    /// 14. Slice the audit log.
141    #[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    /// 15. Skill management. Subcommand: `list` or `show <name>`.
153    ///     (M6 adds `<name> [args]` execution.)
154    #[command(visible_alias = "sk")]
155    Skill {
156        sub: Option<String>,
157        name: Option<String>,
158    },
159    /// 16. Capture a screenshot. Defaults to viewport scope; pass a
160    ///     ref to capture an element, or `--full-page`.
161    #[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    /// 17. Set the viewport. `spec` is a preset (e.g. `mobile`,
170    ///     `desktop`) or `WxH` (e.g. `1280x720`).
171    #[command(visible_alias = "vp")]
172    Viewport {
173        page: String,
174        spec: String,
175        #[arg(long, default_value_t = 2)]
176        dpr: u32,
177    },
178    /// 18. Compute layout boxes for one or more refs.
179    #[command(visible_alias = "lay")]
180    Layout {
181        page: String,
182        #[arg(value_name = "REF", required = true)]
183        refs: Vec<u32>,
184    },
185    /// 19. Auth blob management. Subcommand: `save <page> <name>`,
186    ///     `load <page> <name>`, `list`, or `clear <name>`.
187    #[command(visible_alias = "au")]
188    Auth {
189        sub: String,
190        #[arg(num_args = 0..=2)]
191        rest: Vec<String>,
192    },
193    /// 20. Inspect engine state — console, network, request detail,
194    ///     storage, scripts, dom, performance. The first positional is
195    ///     the page id; the second is the kind. Trailing positionals
196    ///     are kind-specific (e.g. `request <seq>`, `eval <expr>`,
197    ///     `storage <scope>`, `script <seq>`).
198    #[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    /// Move the cursor to `(x, y)` along a humanized Bezier path.
218    /// Native trusted-event dispatch on macOS; ENGINE_UNSUPPORTED
219    /// elsewhere until M7 wires GDK / CDP input.
220    #[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    /// Click at `(x, y)`. Trusted on macOS (`isTrusted = true`).
229    #[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    /// Hover at `(x, y)`.
240    #[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    /// Drag from `(x1, y1)` to `(x2, y2)`.
249    #[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    /// Run the daemon in this process. The `vs` binary doubles as the
262    /// daemon — `vs serve` is what auto-spawn re-execs when the socket
263    /// is missing. SIGINT shuts down cleanly.
264    Serve {
265        /// Send SIGTERM to the running daemon (PID file at
266        /// `~/.vibesurfer/daemon.pid`) and wait for the socket to
267        /// disappear. Returns immediately if no daemon is running.
268        #[arg(long)]
269        stop: bool,
270    },
271    /// Run the MCP (Model Context Protocol) server over stdio.
272    /// Speaks JSON-RPC 2.0; each of the 19 vibesurfer primitives is
273    /// exposed as one MCP tool. Wire to Claude Desktop / Claude Code
274    /// by configuring `vs mcp` as the server command.
275    Mcp,
276}
277
278impl Command {
279    /// Build the wire [`Request`] for this subcommand. Returns `None`
280    /// for commands that the CLI handles locally (none yet).
281    #[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    /// True if this subcommand requires an active session.
586    #[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
601/// Map short-form inspect kind aliases to their long form. Unknown
602/// inputs pass through unchanged so the wire-side parser can reject
603/// or accept them — the CLI does not gatekeep here. The two-letter
604/// short forms are unambiguous within the inspect subcommand set.
605fn 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;