vibesurfer 0.1.12

A real browser for your local AI agent.
Documentation
//! End-to-end CLI dispatch: resolve paths/session, connect to the
//! daemon (auto-spawning if needed), build the wire request, send it,
//! and side-effect on session-open / session-close.
//!
//! Session resolution order (v0.1.7+):
//!   1. `--session=<id>` (or `-S`) — explicit override
//!   2. `VS_SESSION` env var — set by the caller's shell
//!   3. Per-caller saved session at `~/.vibesurfer/callers/<key>` —
//!      keyed by `<parent_pid>-<parent_start_time>` so different
//!      shells / agents get independent sessions automatically
//!   4. Auto-create: if the command needs a session and none of the
//!      above resolved, the CLI implicitly runs `vs_session_open`
//!      first and binds the new id to the caller key
//!
//! The legacy `active-session` pointer file is no longer read or
//! written; concurrent agents would race on it.

use std::path::PathBuf;

use anyhow::{Context as _, Result};

use super::{Cli, Command};
use crate::caller;
use crate::client::{Client, Response};
use crate::paths::Paths;
use crate::spawn;

/// Resolve effective paths from `--home` or `$HOME`.
#[must_use]
pub fn resolve_paths(home_override: Option<&PathBuf>) -> Paths {
    match home_override {
        Some(p) => Paths::at(p.clone()),
        None => Paths::home(),
    }
}

/// Resolve the session id without auto-creating one. Returns `None` if
/// no explicit override / env var is set and the caller has no saved
/// session yet — [`run`] handles the auto-create case for commands
/// that need a session.
pub fn resolve_session(cli: &Cli, paths: &Paths) -> Result<Option<String>> {
    if let Some(s) = &cli.session {
        return Ok(Some(s.clone()));
    }
    if let Ok(s) = std::env::var("VS_SESSION") {
        let trimmed = s.trim();
        if !trimmed.is_empty() {
            return Ok(Some(trimmed.to_string()));
        }
    }
    if let Some(key) = caller::caller_key() {
        let p = paths.caller_session(&key);
        if let Ok(contents) = std::fs::read_to_string(&p) {
            let trimmed = contents.trim();
            if !trimmed.is_empty() {
                return Ok(Some(trimmed.to_string()));
            }
        }
    }
    Ok(None)
}

/// Connect to the daemon, auto-spawning if necessary (unless
/// `--no-spawn`). When the caller passed `--home`, propagate it to
/// the spawned daemon — otherwise auto-spawn writes the socket to
/// the default home and the caller waits forever for it to appear at
/// the requested home.
pub fn connect(cli: &Cli, paths: &Paths) -> Result<Client> {
    let socket = cli.socket.clone().unwrap_or_else(|| paths.socket());
    if !vs_daemon::transport::is_listening(&socket) && !cli.no_spawn {
        let mut extra: Vec<String> = Vec::new();
        if let Some(home) = cli.home.as_ref() {
            extra.push(format!("--home={}", home.display()));
        }
        let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
        spawn::spawn_daemon(&extra_refs)?;
        spawn::wait_for_socket(&socket, std::time::Duration::from_secs(2))?;
    }
    Client::connect(&socket)
}

fn save_caller_session(paths: &Paths, key: &str, session_id: &str) -> Result<()> {
    let path = paths.caller_session(key);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).context("create callers/ directory")?;
    }
    std::fs::write(&path, session_id).context("write caller session file")?;
    Ok(())
}

/// Sentinel written to the caller file by `vs session-close` so a
/// follow-up command does NOT silently auto-create a fresh session.
/// Explicit close means "I'm done" — a subsequent `vs open` should
/// fail loudly, the same way it did pre-v0.1.7. The user re-opens
/// by running `vs session-open` explicitly.
const CLOSED_SENTINEL: &str = "__closed__";

fn mark_caller_closed(paths: &Paths, key: &str) -> Result<()> {
    save_caller_session(paths, key, CLOSED_SENTINEL)
}

/// End-to-end dispatch. Auto-opens a session for the caller if needed
/// and binds session-open / session-close side effects to the caller
/// key so concurrent agents in different shells stay isolated.
pub fn run(cli: &Cli) -> Result<Response> {
    let paths = resolve_paths(cli.home.as_ref());
    let mut session_id = resolve_session(cli, &paths)?;
    let mut client = connect(cli, &paths)?;
    let caller_key = caller::caller_key();

    // Explicit close sentinel means "user said they were done" — don't
    // auto-reopen on a follow-up command. Treat it as no session so the
    // daemon returns the same `NotFound` error the pre-v0.1.7 active-
    // session file used to surface.
    let explicit_close = matches!(session_id.as_deref(), Some(CLOSED_SENTINEL));
    if explicit_close {
        session_id = None;
    }

    // Auto-open: if this command needs a session and none was resolved,
    // open one transparently and remember it for the caller. Excluded:
    // SessionOpen (about to open anyway), and the explicit-close case
    // (the user told us to stop).
    if session_id.is_none()
        && !explicit_close
        && cli.command.needs_session()
        && !matches!(cli.command, Command::SessionOpen { .. })
    {
        let open_req = vs_protocol::Request::new("vs_session_open");
        let open_resp = client.call(&open_req).context("auto session-open")?;
        if let vs_protocol::Envelope::Success(_) = &open_resp.envelope {
            if let Some(line) = open_resp.body.first() {
                let id = line.trim().to_string();
                if let Some(key) = caller_key.as_ref() {
                    let _ = save_caller_session(&paths, key, &id);
                }
                session_id = Some(id);
            }
        }
    }

    // Local prompt primitives: read from the user's tty before any
    // wire call. The value (PromptInput) or confirmation (PromptConfirm)
    // is collected by the CLI in the user's terminal; the agent that
    // invoked vs prompt-input never sees the bytes.
    match &cli.command {
        Command::PromptInput {
            page,
            r,
            message,
            secret,
            token,
            group,
        } => {
            let value = read_user_input(message, *secret)?;
            let mut req = vs_protocol::Request::new("vs_act")
                .arg(page.clone())
                .arg(r.to_string())
                .arg("fill".to_string())
                .arg(value)
                .flag_value("session", session_id.clone().unwrap_or_default())
                .flag_value("token", token.clone());
            if let Some(g) = group {
                req = req.flag_value("group", g.clone());
            }
            return client.call(&req).context("daemon call");
        }
        Command::PromptConfirm { page: _, message } => {
            read_user_confirm(message)?;
            return Ok(Response {
                envelope: vs_protocol::Envelope::Success(vs_protocol::StateToken([0u8; 8])),
                body: Vec::new(),
                warnings: Vec::new(),
            });
        }
        Command::Pending { sub: super::PendingSub::Fulfill { id } } => {
            return run_pending_fulfill(&mut client, id.clone());
        }
        _ => {}
    }

    let req = cli.command.to_request(session_id.as_deref())?;
    let mut resp = client.call(&req).context("daemon call")?;

    match (&cli.command, &resp.envelope) {
        (Command::SessionOpen { .. }, vs_protocol::Envelope::Success(_)) => {
            if let (Some(line), Some(key)) = (resp.body.first(), caller_key.as_ref()) {
                let _ = save_caller_session(&paths, key, line.trim());
            }
        }
        (Command::SessionClose, vs_protocol::Envelope::Success(_)) => {
            if let Some(key) = caller_key.as_ref() {
                let _ = mark_caller_closed(&paths, key);
            }
        }
        (
            Command::Capture { base64: true, .. },
            vs_protocol::Envelope::Success(_),
        ) => {
            // The body's first line is the on-disk PNG path. Read it
            // and replace the body with `base64=<bytes>` plus the
            // original `path=…` so MCP-driven agents can ship pixels
            // inline without losing the disk artifact.
            if let Some(path_line) = resp.body.first().cloned() {
                let path = std::path::PathBuf::from(path_line.trim());
                if let Ok(bytes) = std::fs::read(&path) {
                    use base64::engine::general_purpose::STANDARD;
                    use base64::Engine as _;
                    let b64 = STANDARD.encode(&bytes);
                    resp.body = vec![format!("base64={b64}"), format!("path={}", path.display())];
                }
            }
        }
        _ => {}
    }
    Ok(resp)
}

/// Prompt the user (via tty) and return the value. When `secret` is
/// true, terminal echo is disabled and the input is read through
/// `rpassword`.
fn read_user_input(message: &str, secret: bool) -> Result<String> {
    use std::io::Write as _;
    let mut stderr = std::io::stderr();
    if secret {
        // rpassword writes its own prompt and reads from /dev/tty so
        // it works even when stdin is redirected.
        let v = rpassword::prompt_password(format!("{message} ")).context("read secret")?;
        Ok(v)
    } else {
        write!(stderr, "{message} ").ok();
        stderr.flush().ok();
        let mut buf = String::new();
        std::io::stdin().read_line(&mut buf).context("read line")?;
        // Strip the trailing newline; leading whitespace stays.
        Ok(buf.trim_end_matches(['\r', '\n']).to_string())
    }
}

/// Block until the user presses Enter at the tty. EOF / Ctrl-D abort.
fn read_user_confirm(message: &str) -> Result<()> {
    use std::io::Write as _;
    let mut stderr = std::io::stderr();
    write!(stderr, "{message} [Enter to confirm, Ctrl-C to abort] ").ok();
    stderr.flush().ok();
    let mut buf = String::new();
    let n = std::io::stdin()
        .read_line(&mut buf)
        .context("read confirm")?;
    if n == 0 {
        anyhow::bail!("ABORTED: stdin closed before confirm");
    }
    Ok(())
}

/// Resolve a pending entry id, read the value from the local tty,
/// and send the fulfill RPC. Extracted from `run()` so clippy's
/// `too_many_lines` lint stays satisfied.
fn run_pending_fulfill(client: &mut Client, id: Option<String>) -> Result<Response> {
    let resolved_id = if let Some(s) = id {
        s
    } else {
        let list_req = vs_protocol::Request::new("vs_pending_list");
        let list_resp = client.call(&list_req).context("pending list")?;
        let ids: Vec<&str> = list_resp
            .body
            .iter()
            .filter_map(|l| l.split('\t').next())
            .filter(|s| !s.is_empty())
            .collect();
        match ids.len() {
            0 => anyhow::bail!("no pending entries to fulfill"),
            1 => ids[0].to_string(),
            n => anyhow::bail!("{n} pending entries — pass an explicit id"),
        }
    };
    let peek_req = vs_protocol::Request::new("vs_pending_peek").arg(resolved_id.clone());
    let peek_resp = client.call(&peek_req).context("pending peek")?;
    let line = peek_resp.body.first().cloned().unwrap_or_default();
    let parts: Vec<&str> = line.split('\t').collect();
    let message = parts.get(4).copied().unwrap_or("value");
    let secret = parts.get(3).copied() == Some("1");
    let value = read_user_input(message, secret)?;
    let req = vs_protocol::Request::new("vs_pending_fulfill")
        .arg(resolved_id)
        .arg(value);
    client.call(&req).context("daemon call")
}