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