Skip to main content

bob_rs/
run.rs

1//! Build the bob CLI argv and spawn it via the shared streaming engine.
2//!
3//! Both `bob-api` (browser preview HTTP) and `src-tauri` (desktop IPC)
4//! consume this. The generic subprocess engine (`spawn_streaming`, the
5//! process-event type, the run handle) lives in `agent-harness`; this
6//! module is the bob-specific layer on top — the chat-mode / approval
7//! flags, `RunBobOptions`, and injecting bob's `BOBSHELL_API_KEY`.
8
9use crate::error::BobError;
10use crate::keychain::resolve_api_key;
11use cli_stream::{spawn_streaming, ProcessEvent, ProcessHandle};
12use serde::{Deserialize, Serialize};
13use std::path::PathBuf;
14
15// --- Wire shapes (bob-specific) -------------------------------------
16
17/// Bob chat mode CLI flag. `--chat-mode <value>` accepts the snake_case
18/// forms below.
19#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21pub enum BobChatMode {
22    Plan,
23    Code,
24    Advanced,
25    Ask,
26}
27
28impl BobChatMode {
29    pub fn as_cli_value(self) -> &'static str {
30        match self {
31            Self::Plan => "plan",
32            Self::Code => "code",
33            Self::Advanced => "advanced",
34            Self::Ask => "ask",
35        }
36    }
37}
38
39/// Bob's approval flow. `default` prompts the user via bob's UI; `yolo`
40/// skips prompts. We only use `default` and `yolo` today (the legacy
41/// `auto_edit` mode kept for back-compat with the existing Tauri command
42/// surface).
43#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum BobApprovalMode {
46    Default,
47    AutoEdit,
48    Yolo,
49}
50
51impl BobApprovalMode {
52    pub fn as_cli_value(self) -> &'static str {
53        match self {
54            Self::Default => "default",
55            Self::AutoEdit => "auto_edit",
56            Self::Yolo => "yolo",
57        }
58    }
59}
60
61/// Options for a single bob run. Built by both the axum endpoint (from
62/// JSON body) and the Tauri command (from invoke args).
63#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "camelCase")]
65pub struct RunBobOptions {
66    pub prompt: String,
67    #[serde(default = "default_chat_mode")]
68    pub chat_mode: BobChatMode,
69    #[serde(default = "default_approval_mode")]
70    pub approval_mode: BobApprovalMode,
71    #[serde(default = "default_max_coins")]
72    pub max_coins: u32,
73    /// Working directory the bob process runs in. Defaults to the
74    /// caller's cwd. For workspace-scoped runs, pass the workspace path
75    /// so bob's tool calls land inside that workspace.
76    pub cwd: Option<PathBuf>,
77    /// Override the bob executable path. Mainly for tests + when the
78    /// caller has already resolved bob (e.g. Tauri's locator). Defaults
79    /// to `bob` on PATH.
80    #[serde(default)]
81    pub bob_executable: Option<PathBuf>,
82    /// Extra CLI args the caller appends verbatim after bob's own argv —
83    /// the same host-controlled passthrough the other adapters expose, so
84    /// a client can apply a flag uniformly across harnesses. Default empty.
85    #[serde(default)]
86    pub extra_args: Vec<String>,
87    /// Session id to **resume** (`-r <id>`) instead of starting fresh — continue
88    /// a prior conversation so bob supplies the history rather than the caller
89    /// replaying a transcript. `None` → a new session. Default `None`.
90    #[serde(default)]
91    pub resume: Option<String>,
92}
93
94fn default_chat_mode() -> BobChatMode { BobChatMode::Ask }
95fn default_approval_mode() -> BobApprovalMode { BobApprovalMode::Default }
96fn default_max_coins() -> u32 { 30 }
97
98// --- Spawn ----------------------------------------------------------
99
100/// Spawn bob and stream output through `callback` until the child exits.
101/// Returns a [`ProcessHandle`] immediately — the reader + wait threads
102/// continue in the background.
103///
104/// `run_id` is opaque to bob-rs; the caller chooses the identifier and
105/// uses it to correlate events with the handle.
106pub fn spawn_bob<F>(
107    opts: RunBobOptions,
108    run_id: String,
109    callback: F,
110) -> Result<ProcessHandle, BobError>
111where
112    F: FnMut(ProcessEvent) + Send + Sync + Clone + 'static,
113{
114    let args = build_args(&opts);
115    let api_key = resolve_api_key().map(|(value, _)| value).unwrap_or_default();
116    let program: PathBuf = opts.bob_executable.clone().unwrap_or_else(|| PathBuf::from("bob"));
117    let cwd = opts.cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
118    spawn_bob_raw(program, args, api_key, cwd, run_id, callback)
119}
120
121/// Lower-level spawn for callers that have already built the argv,
122/// resolved the bob executable path, and loaded the API key themselves
123/// (the Tauri runner, which carries its own locator + workspace-aware
124/// argv builder). Thin bob-specific wrapper over
125/// [`cli_stream::spawn_streaming`]: sets bob's `BOBSHELL_API_KEY` env
126/// var, otherwise identical.
127pub fn spawn_bob_raw<F>(
128    program: PathBuf,
129    args: Vec<String>,
130    api_key: String,
131    cwd: PathBuf,
132    run_id: String,
133    callback: F,
134) -> Result<ProcessHandle, BobError>
135where
136    F: FnMut(ProcessEvent) + Send + Sync + Clone + 'static,
137{
138    let handle = spawn_streaming(
139        program,
140        args,
141        vec![("BOBSHELL_API_KEY".to_owned(), api_key)],
142        cwd,
143        run_id,
144        callback,
145    )?; // cli_stream::StreamError → BobError::Stream
146    Ok(handle)
147}
148
149/// Build the bob CLI argv. Mirrors the structure used by both the Vite
150/// `bobRunPlugin` and the Tauri `build_bob_command`.
151fn build_args(opts: &RunBobOptions) -> Vec<String> {
152    let mut args = vec![
153        opts.prompt.clone(),
154        "--chat-mode".to_owned(),
155        opts.chat_mode.as_cli_value().to_owned(),
156        "--output-format".to_owned(),
157        "stream-json".to_owned(),
158        "--approval-mode".to_owned(),
159        opts.approval_mode.as_cli_value().to_owned(),
160        "--accept-license".to_owned(),
161        "--max-coins".to_owned(),
162        opts.max_coins.to_string(),
163    ];
164    // Continue a prior session instead of starting fresh (bob accepts the
165    // session UUID, per `--resume {number|uuid|latest}`).
166    if let Some(session_id) = &opts.resume {
167        args.push("--resume".to_owned());
168        args.push(session_id.clone());
169    }
170    // Host passthrough, appended verbatim after bob's own argv.
171    args.extend(opts.extra_args.iter().cloned());
172    args
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn opts(extra_args: Vec<String>) -> RunBobOptions {
180        RunBobOptions {
181            prompt: "hi".to_owned(),
182            chat_mode: BobChatMode::Ask,
183            approval_mode: BobApprovalMode::Default,
184            max_coins: 30,
185            cwd: None,
186            bob_executable: None,
187            extra_args,
188            resume: None,
189        }
190    }
191
192    #[test]
193    fn build_args_appends_extra_args_after_bobs_own() {
194        let args = build_args(&opts(vec!["--foo".to_owned(), "bar".to_owned()]));
195        // bob's own argv stays intact (prompt positional first, format flag present)…
196        assert_eq!(args.first().map(String::as_str), Some("hi"));
197        assert!(args.contains(&"stream-json".to_owned()));
198        // …and the host's flags are appended verbatim at the end.
199        assert!(args.ends_with(&["--foo".to_owned(), "bar".to_owned()]));
200    }
201
202    #[test]
203    fn build_args_with_no_extra_is_unchanged() {
204        let args = build_args(&opts(Vec::new()));
205        assert_eq!(args.last().map(String::as_str), Some("30"));
206    }
207
208    #[test]
209    fn build_args_resume_adds_session_flag() {
210        let mut o = opts(Vec::new());
211        o.resume = Some("sess-7".to_owned());
212        let args = build_args(&o);
213        let i = args.iter().position(|a| a == "--resume").expect("--resume");
214        assert_eq!(args.get(i + 1).map(String::as_str), Some("sess-7"));
215        // The prompt positional is still first.
216        assert_eq!(args.first().map(String::as_str), Some("hi"));
217    }
218}