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        /// Emit the PNG bytes as base64 on the response body (instead
169        /// of just a path on disk). Lets MCP-driven agents see the
170        /// screenshot inline; the on-disk PNG is still written.
171        #[arg(long, alias = "b64")]
172        base64: bool,
173    },
174    /// 17. Set the viewport. `spec` is a preset (e.g. `mobile`,
175    ///     `desktop`) or `WxH` (e.g. `1280x720`).
176    #[command(visible_alias = "vp")]
177    Viewport {
178        page: String,
179        spec: String,
180        #[arg(long, default_value_t = 2)]
181        dpr: u32,
182    },
183    /// 18. Compute layout boxes for one or more refs.
184    #[command(visible_alias = "lay")]
185    Layout {
186        page: String,
187        #[arg(value_name = "REF", required = true)]
188        refs: Vec<u32>,
189    },
190    /// 19. Auth blob management. Subcommand: `save <page> <name>`,
191    ///     `load <page> <name>`, `list`, or `clear <name>`.
192    #[command(visible_alias = "au")]
193    Auth {
194        sub: String,
195        #[arg(num_args = 0..=2)]
196        rest: Vec<String>,
197    },
198    /// 20. Inspect engine state — console, network, request detail,
199    ///     storage, scripts, dom, performance. The first positional is
200    ///     the page id; the second is the kind. Trailing positionals
201    ///     are kind-specific (e.g. `request <seq>`, `eval <expr>`,
202    ///     `storage <scope>`, `script <seq>`).
203    #[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    /// Move the cursor to `(x, y)` along a humanized Bezier path.
223    /// Native trusted-event dispatch on macOS; ENGINE_UNSUPPORTED
224    /// elsewhere until M7 wires GDK / CDP input.
225    #[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    /// Click at `(x, y)`. Trusted on macOS (`isTrusted = true`).
234    #[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    /// Hover at `(x, y)`.
245    #[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    /// Drag from `(x1, y1)` to `(x2, y2)`.
254    #[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    /// Prompt the user (in the terminal that ran vs) for a value, then
267    /// fill it into a ref. The value is read from tty by the CLI and
268    /// shipped to the daemon over the local socket; the agent that
269    /// invoked vs prompt-input never sees the bytes. Use `--secret`
270    /// for passwords, TANs, and other credentials (terminal echo off).
271    #[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    /// Block until the user presses Enter in the terminal. Returns
286    /// `ok` on confirm or `! ABORTED` if the user sends EOF / Ctrl-C.
287    /// Use as a human-in-loop gate before a sensitive `vs act click`.
288    #[command(visible_alias = "pc")]
289    PromptConfirm {
290        page: String,
291        #[arg(long)]
292        message: String,
293    },
294    /// Wire-only `vs_prompt_input` variant — does NOT read from the
295    /// local tty. Used by `vs mcp` so an MCP-driven agent can enqueue
296    /// a prompt the local user fulfills via `vs pending fulfill`.
297    /// Hidden from `--help`: only the MCP server wires it.
298    #[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        /// Timeout in milliseconds before the daemon gives up waiting
311        /// for `vs pending fulfill <id>` (default 5 min).
312        #[arg(long = "timeout-ms", default_value_t = 300_000)]
313        timeout_ms: u64,
314    },
315    /// List / fulfill / cancel pending `vs_prompt_input` entries
316    /// queued by an MCP-driven agent. Use `vs pending list` to see
317    /// what's waiting, `vs pending fulfill <id>` to type the value at
318    /// the local tty (`--secret` hides echo), `vs pending cancel <id>`
319    /// to abort.
320    #[command(visible_alias = "pe")]
321    Pending {
322        #[command(subcommand)]
323        sub: PendingSub,
324    },
325    /// Run the daemon in this process. The `vs` binary doubles as the
326    /// daemon — `vs serve` is what auto-spawn re-execs when the socket
327    /// is missing. SIGINT shuts down cleanly.
328    Serve {
329        /// Send SIGTERM to the running daemon (PID file at
330        /// `~/.vibesurfer/daemon.pid`) and wait for the socket to
331        /// disappear. Returns immediately if no daemon is running.
332        #[arg(long)]
333        stop: bool,
334    },
335    /// Run the MCP (Model Context Protocol) server over stdio.
336    /// Speaks JSON-RPC 2.0; each of the 19 vibesurfer primitives is
337    /// exposed as one MCP tool. Wire to Claude Desktop / Claude Code
338    /// by configuring `vs mcp` as the server command.
339    Mcp,
340}
341
342#[derive(Debug, Subcommand)]
343pub enum PendingSub {
344    /// Show all pending `vs_prompt_input` entries the daemon is
345    /// holding for fulfillment.
346    #[command(visible_alias = "ls")]
347    List,
348    /// Read the value the daemon is waiting for from the local tty
349    /// (rpassword for `--secret`) and send it to the pending entry.
350    /// On success the parked `vs_prompt_input` call returns and the
351    /// agent observes the filled field.
352    #[command(visible_alias = "f")]
353    Fulfill {
354        /// Pending entry id (from `vs pending list`). If omitted and
355        /// there is exactly one pending entry, that one is used.
356        id: Option<String>,
357    },
358    /// Cancel a pending entry. The parked `vs_prompt_input` call
359    /// returns `BadRequest "cancelled"`.
360    #[command(visible_alias = "c")]
361    Cancel {
362        id: String,
363    },
364}
365
366impl Command {
367    /// Build the wire [`Request`] for this subcommand. Returns `None`
368    /// for commands that the CLI handles locally (none yet).
369    #[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                // `base64` is a CLI-side post-process — the daemon
533                // still returns the on-disk path; dispatch.rs reads
534                // the PNG and base64-encodes it before printing.
535                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                    // CLI side reads value from tty before sending the
689                    // wire request — handled in dispatch.rs. Here we
690                    // just stub a placeholder; dispatch overrides the
691                    // value arg with what the user typed.
692                    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    /// True if this subcommand requires an active session.
707    #[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
722/// Map short-form inspect kind aliases to their long form. Unknown
723/// inputs pass through unchanged so the wire-side parser can reject
724/// or accept them — the CLI does not gatekeep here. The two-letter
725/// short forms are unambiguous within the inspect subcommand set.
726fn 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;