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    /// Prompt the user (in the terminal that ran vs) for a value, then
262    /// fill it into a ref. The value is read from tty by the CLI and
263    /// shipped to the daemon over the local socket; the agent that
264    /// invoked vs prompt-input never sees the bytes. Use `--secret`
265    /// for passwords, TANs, and other credentials (terminal echo off).
266    #[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    /// Block until the user presses Enter in the terminal. Returns
281    /// `ok` on confirm or `! ABORTED` if the user sends EOF / Ctrl-C.
282    /// Use as a human-in-loop gate before a sensitive `vs act click`.
283    #[command(visible_alias = "pc")]
284    PromptConfirm {
285        page: String,
286        #[arg(long)]
287        message: String,
288    },
289    /// Run the daemon in this process. The `vs` binary doubles as the
290    /// daemon — `vs serve` is what auto-spawn re-execs when the socket
291    /// is missing. SIGINT shuts down cleanly.
292    Serve {
293        /// Send SIGTERM to the running daemon (PID file at
294        /// `~/.vibesurfer/daemon.pid`) and wait for the socket to
295        /// disappear. Returns immediately if no daemon is running.
296        #[arg(long)]
297        stop: bool,
298    },
299    /// Run the MCP (Model Context Protocol) server over stdio.
300    /// Speaks JSON-RPC 2.0; each of the 19 vibesurfer primitives is
301    /// exposed as one MCP tool. Wire to Claude Desktop / Claude Code
302    /// by configuring `vs mcp` as the server command.
303    Mcp,
304}
305
306impl Command {
307    /// Build the wire [`Request`] for this subcommand. Returns `None`
308    /// for commands that the CLI handles locally (none yet).
309    #[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    /// True if this subcommand requires an active session.
617    #[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
632/// Map short-form inspect kind aliases to their long form. Unknown
633/// inputs pass through unchanged so the wire-side parser can reject
634/// or accept them — the CLI does not gatekeep here. The two-letter
635/// short forms are unambiguous within the inspect subcommand set.
636fn 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;