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    /// Run the daemon in this process. The `vs` binary doubles as the
218    /// daemon — `vs serve` is what auto-spawn re-execs when the socket
219    /// is missing. SIGINT shuts down cleanly.
220    Serve {
221        /// Send SIGTERM to the running daemon (PID file at
222        /// `~/.vibesurfer/daemon.pid`) and wait for the socket to
223        /// disappear. Returns immediately if no daemon is running.
224        #[arg(long)]
225        stop: bool,
226    },
227    /// Run the MCP (Model Context Protocol) server over stdio.
228    /// Speaks JSON-RPC 2.0; each of the 19 vibesurfer primitives is
229    /// exposed as one MCP tool. Wire to Claude Desktop / Claude Code
230    /// by configuring `vs mcp` as the server command.
231    Mcp,
232}
233
234impl Command {
235    /// Build the wire [`Request`] for this subcommand. Returns `None`
236    /// for commands that the CLI handles locally (none yet).
237    #[allow(clippy::too_many_lines)]
238    pub fn to_request(&self, session_id: Option<&str>) -> Result<Request> {
239        Ok(match self {
240            Self::SessionOpen { policy } => {
241                let mut r = Request::new("vs_session_open");
242                if let Some(p) = policy {
243                    r = r.flag_value("policy", p.clone());
244                }
245                r
246            }
247            Self::SessionClose => {
248                let s = require_session(session_id)?;
249                Request::new("vs_session_close").arg(s)
250            }
251            Self::Open { url } => {
252                let s = require_session(session_id)?;
253                Request::new("vs_open")
254                    .arg(url.clone())
255                    .flag_value("session", s)
256            }
257            Self::Close { page } => {
258                let s = require_session(session_id)?;
259                Request::new("vs_close")
260                    .arg(page.clone())
261                    .flag_value("session", s)
262            }
263            Self::View { page, full } => {
264                let s = require_session(session_id)?;
265                let mut r = Request::new("vs_view")
266                    .arg(page.clone())
267                    .flag_value("session", s);
268                if *full {
269                    r = r.flag("full");
270                }
271                r
272            }
273            Self::Read { page, r } => {
274                let s = require_session(session_id)?;
275                Request::new("vs_read")
276                    .arg(page.clone())
277                    .arg(r.to_string())
278                    .flag_value("session", s)
279            }
280            Self::Act {
281                page,
282                r,
283                op,
284                value,
285                token,
286                group,
287            } => {
288                let s = require_session(session_id)?;
289                let mut req = Request::new("vs_act")
290                    .arg(page.clone())
291                    .arg(r.to_string())
292                    .arg(op.clone());
293                if let Some(v) = value {
294                    req = req.arg(v.clone());
295                }
296                req = req
297                    .flag_value("session", s)
298                    .flag_value("token", token.clone());
299                if let Some(g) = group {
300                    req = req.flag_value("group", g.clone());
301                }
302                req
303            }
304            Self::Find { query } => {
305                let s = require_session(session_id)?;
306                Request::new("vs_find")
307                    .arg(query.clone())
308                    .flag_value("session", s)
309            }
310            Self::Wait {
311                page,
312                cond,
313                value,
314                timeout,
315            } => {
316                let s = require_session(session_id)?;
317                let mut req = Request::new("vs_wait").arg(page.clone()).arg(cond.clone());
318                if let Some(v) = value {
319                    req = req.arg(v.clone());
320                }
321                req.flag_value("session", s)
322                    .flag_value("timeout", format!("{timeout}ms"))
323            }
324            Self::Status => {
325                let mut r = Request::new("vs_status");
326                if let Some(s) = session_id {
327                    r = r.flag_value("session", s.to_string());
328                }
329                r
330            }
331            Self::Extract {
332                page,
333                schema,
334                token,
335            } => {
336                let s = require_session(session_id)?;
337                Request::new("vs_extract")
338                    .arg(page.clone())
339                    .arg(schema.clone())
340                    .flag_value("session", s)
341                    .flag_value("token", token.clone())
342            }
343            Self::Mark {
344                page,
345                r,
346                name,
347                token,
348            } => {
349                let s = require_session(session_id)?;
350                Request::new("vs_mark")
351                    .arg(page.clone())
352                    .arg(r.to_string())
353                    .arg(name.clone())
354                    .flag_value("session", s)
355                    .flag_value("token", token.clone())
356            }
357            Self::Annotate { target, key, value } => {
358                let s = require_session(session_id)?;
359                let mut req = Request::new("vs_annotate")
360                    .arg(target.clone())
361                    .arg(key.clone());
362                if let Some(v) = value {
363                    req = req.arg(v.clone());
364                }
365                req.flag_value("session", s)
366            }
367            Self::Log {
368                page,
369                group,
370                since,
371                limit,
372            } => {
373                let s = require_session(session_id)?;
374                let mut req = Request::new("vs_log").flag_value("session", s);
375                if let Some(p) = page {
376                    req = req.flag_value("page", p.clone());
377                }
378                if let Some(g) = group {
379                    req = req.flag_value("group", g.clone());
380                }
381                if let Some(t) = since {
382                    req = req.flag_value("since", t.to_string());
383                }
384                if let Some(l) = limit {
385                    req = req.flag_value("limit", l.to_string());
386                }
387                req
388            }
389            Self::Skill { sub, name } => {
390                let s = require_session(session_id)?;
391                let mut req = Request::new("vs_skill").flag_value("session", s);
392                let sub = sub.as_deref().unwrap_or("list");
393                req = req.arg(sub.to_string());
394                if let Some(n) = name {
395                    req = req.arg(n.clone());
396                }
397                req
398            }
399            Self::Capture { page, r, full_page } => {
400                let s = require_session(session_id)?;
401                let mut req = Request::new("vs_capture")
402                    .arg(page.clone())
403                    .flag_value("session", s);
404                if let Some(rr) = r {
405                    req = req.arg(rr.to_string());
406                }
407                if *full_page {
408                    req = req.flag("full-page");
409                }
410                req
411            }
412            Self::Viewport { page, spec, dpr } => {
413                let s = require_session(session_id)?;
414                Request::new("vs_viewport")
415                    .arg(page.clone())
416                    .arg(spec.clone())
417                    .flag_value("session", s)
418                    .flag_value("dpr", dpr.to_string())
419            }
420            Self::Layout { page, refs } => {
421                let s = require_session(session_id)?;
422                let mut req = Request::new("vs_layout").arg(page.clone());
423                for r in refs {
424                    req = req.arg(r.to_string());
425                }
426                req.flag_value("session", s)
427            }
428            Self::Auth { sub, rest } => {
429                let s = require_session(session_id)?;
430                let mut req = Request::new("vs_auth")
431                    .arg(sub.clone())
432                    .flag_value("session", s);
433                for r in rest {
434                    req = req.arg(r.clone());
435                }
436                req
437            }
438            Self::Inspect {
439                page,
440                kind,
441                rest,
442                since,
443                level,
444                status,
445                max,
446                full,
447                unsafe_log,
448            } => {
449                let s = require_session(session_id)?;
450                let kind_long = normalize_inspect_kind(kind);
451                let mut req = Request::new("vs_inspect")
452                    .arg(kind_long.to_string())
453                    .arg(page.clone());
454                for r in rest {
455                    req = req.arg(r.clone());
456                }
457                req = req.flag_value("session", s);
458                if let Some(v) = since {
459                    req = req.flag_value("since", v.clone());
460                }
461                if let Some(v) = level {
462                    req = req.flag_value("level", v.clone());
463                }
464                if let Some(v) = status {
465                    req = req.flag_value("status", v.clone());
466                }
467                if let Some(v) = max {
468                    req = req.flag_value("max", v.clone());
469                }
470                if *full {
471                    req = req.flag("full");
472                }
473                if *unsafe_log {
474                    req = req.flag("unsafe-log");
475                }
476                req
477            }
478            Self::Serve { .. } => {
479                anyhow::bail!("vs_serve is local; route via main, not the wire dispatcher");
480            }
481            Self::Mcp => {
482                anyhow::bail!("vs_mcp is local; route via main, not the wire dispatcher");
483            }
484        })
485    }
486
487    /// True if this subcommand requires an active session.
488    #[must_use]
489    pub fn needs_session(&self) -> bool {
490        !matches!(
491            self,
492            Self::SessionOpen { .. } | Self::Status | Self::Serve { .. } | Self::Mcp
493        )
494    }
495}
496
497fn require_session(session: Option<&str>) -> Result<String> {
498    session
499        .map(str::to_string)
500        .context("no active session — run `vs session-open` or pass `--session=<id>`")
501}
502
503/// Map short-form inspect kind aliases to their long form. Unknown
504/// inputs pass through unchanged so the wire-side parser can reject
505/// or accept them — the CLI does not gatekeep here. The two-letter
506/// short forms are unambiguous within the inspect subcommand set.
507fn normalize_inspect_kind(kind: &str) -> &str {
508    match kind {
509        "co" => "console",
510        "n" => "network",
511        "req" => "request",
512        "e" => "eval",
513        "s" => "storage",
514        "scr" => "scripts",
515        "src" => "script",
516        "d" => "dom",
517        "p" => "performance",
518        "ce" => "cookie-events",
519        other => other,
520    }
521}
522
523mod dispatch;
524mod render;
525
526pub use dispatch::{connect, resolve_paths, resolve_session, run};
527pub use render::render;