use crate::check::semver_at_least;
use crate::error::BobError;
use crate::keychain::resolve_api_key;
use crate::BOB_MIN_NODE_VERSION;
use cli_stream::{resolve_program, spawn_streaming, ProcessEvent, ProcessHandle};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BobChatMode {
Plan,
Code,
Advanced,
Ask,
}
impl BobChatMode {
pub fn as_cli_value(self) -> &'static str {
match self {
Self::Plan => "plan",
Self::Code => "code",
Self::Advanced => "advanced",
Self::Ask => "ask",
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BobApprovalMode {
Default,
AutoEdit,
Yolo,
}
impl BobApprovalMode {
pub fn as_cli_value(self) -> &'static str {
match self {
Self::Default => "default",
Self::AutoEdit => "auto_edit",
Self::Yolo => "yolo",
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RunBobOptions {
pub prompt: String,
#[serde(default = "default_chat_mode")]
pub chat_mode: BobChatMode,
#[serde(default = "default_approval_mode")]
pub approval_mode: BobApprovalMode,
#[serde(default = "default_max_coins")]
pub max_coins: u32,
pub cwd: Option<PathBuf>,
#[serde(default)]
pub bob_executable: Option<PathBuf>,
#[serde(default)]
pub extra_args: Vec<String>,
#[serde(default)]
pub resume: Option<String>,
}
fn default_chat_mode() -> BobChatMode { BobChatMode::Ask }
fn default_approval_mode() -> BobApprovalMode { BobApprovalMode::Default }
fn default_max_coins() -> u32 { 30 }
pub fn spawn_bob<F>(
opts: RunBobOptions,
run_id: String,
callback: F,
) -> Result<ProcessHandle, BobError>
where
F: FnMut(ProcessEvent) + Send + Sync + Clone + 'static,
{
let args = build_args(&opts);
let api_key = resolve_api_key().map(|(value, _)| value).unwrap_or_default();
let program: PathBuf = resolve_program(
opts.bob_executable.clone().unwrap_or_else(|| PathBuf::from("bob")),
);
ensure_node_compatible(&program)?;
let cwd = opts.cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
spawn_bob_raw(program, args, api_key, cwd, run_id, callback)
}
fn node_for_program(program: &Path) -> Option<PathBuf> {
if let Some(dir) = program.parent().filter(|p| !p.as_os_str().is_empty()) {
let sibling = dir.join("node");
if sibling.is_file() {
return Some(sibling);
}
}
let resolved = resolve_program(PathBuf::from("node"));
resolved.is_absolute().then_some(resolved)
}
fn ensure_node_compatible(program: &Path) -> Result<(), BobError> {
if program.parent().map_or(true, |p| p.as_os_str().is_empty()) {
return Ok(());
}
let Some(node) = node_for_program(program) else {
return Err(BobError::NodeIncompatible {
minimum: BOB_MIN_NODE_VERSION.to_owned(),
detail: "no `node` found on PATH".to_owned(),
});
};
let Some(version) = probe_node_version(&node) else {
return Ok(());
};
if semver_at_least(&version, BOB_MIN_NODE_VERSION) {
Ok(())
} else {
Err(BobError::NodeIncompatible {
minimum: BOB_MIN_NODE_VERSION.to_owned(),
detail: format!("found {version} at {}", node.display()),
})
}
}
fn probe_node_version(node: &Path) -> Option<String> {
let output = Command::new(node)
.arg("--version")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_owned();
(!version.is_empty()).then_some(version)
}
pub fn spawn_bob_raw<F>(
program: PathBuf,
args: Vec<String>,
api_key: String,
cwd: PathBuf,
run_id: String,
callback: F,
) -> Result<ProcessHandle, BobError>
where
F: FnMut(ProcessEvent) + Send + Sync + Clone + 'static,
{
let handle = spawn_streaming(
program,
args,
vec![("BOBSHELL_API_KEY".to_owned(), api_key)],
cwd,
run_id,
callback,
)?; Ok(handle)
}
fn build_args(opts: &RunBobOptions) -> Vec<String> {
let mut args = vec![
opts.prompt.clone(),
"--chat-mode".to_owned(),
opts.chat_mode.as_cli_value().to_owned(),
"--output-format".to_owned(),
"stream-json".to_owned(),
"--approval-mode".to_owned(),
opts.approval_mode.as_cli_value().to_owned(),
"--accept-license".to_owned(),
"--max-coins".to_owned(),
opts.max_coins.to_string(),
];
if let Some(session_id) = &opts.resume {
args.push("--resume".to_owned());
args.push(session_id.clone());
}
args.extend(opts.extra_args.iter().cloned());
args
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(extra_args: Vec<String>) -> RunBobOptions {
RunBobOptions {
prompt: "hi".to_owned(),
chat_mode: BobChatMode::Ask,
approval_mode: BobApprovalMode::Default,
max_coins: 30,
cwd: None,
bob_executable: None,
extra_args,
resume: None,
}
}
#[test]
fn build_args_appends_extra_args_after_bobs_own() {
let args = build_args(&opts(vec!["--foo".to_owned(), "bar".to_owned()]));
assert_eq!(args.first().map(String::as_str), Some("hi"));
assert!(args.contains(&"stream-json".to_owned()));
assert!(args.ends_with(&["--foo".to_owned(), "bar".to_owned()]));
}
#[test]
fn build_args_with_no_extra_is_unchanged() {
let args = build_args(&opts(Vec::new()));
assert_eq!(args.last().map(String::as_str), Some("30"));
}
#[test]
fn build_args_resume_adds_session_flag() {
let mut o = opts(Vec::new());
o.resume = Some("sess-7".to_owned());
let args = build_args(&o);
let i = args.iter().position(|a| a == "--resume").expect("--resume");
assert_eq!(args.get(i + 1).map(String::as_str), Some("sess-7"));
assert_eq!(args.first().map(String::as_str), Some("hi"));
}
#[cfg(unix)]
fn fake_toolchain(dir: &Path, version: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let exec = std::fs::Permissions::from_mode(0o755);
let node = dir.join("node");
std::fs::write(&node, format!("#!/bin/sh\necho {version}\n")).unwrap();
std::fs::set_permissions(&node, exec.clone()).unwrap();
let bob = dir.join("bob");
std::fs::write(&bob, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&bob, exec).unwrap();
bob
}
#[cfg(unix)]
#[test]
fn node_preflight_rejects_a_too_old_sibling_node() {
let dir = tempfile::tempdir().unwrap();
let bob = fake_toolchain(dir.path(), "v20.19.2");
let err = ensure_node_compatible(&bob).expect_err("v20 must be rejected");
match err {
BobError::NodeIncompatible { minimum, detail } => {
assert_eq!(minimum, BOB_MIN_NODE_VERSION);
assert!(detail.contains("v20.19.2"), "detail names the bad version: {detail}");
}
other => panic!("expected NodeIncompatible, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn node_preflight_accepts_a_satisfying_sibling_node() {
let dir = tempfile::tempdir().unwrap();
let bob = fake_toolchain(dir.path(), &format!("v{BOB_MIN_NODE_VERSION}"));
ensure_node_compatible(&bob).expect("minimum version passes");
let bob = fake_toolchain(dir.path(), "v24.13.0");
ensure_node_compatible(&bob).expect("newer version passes");
}
#[test]
fn node_preflight_skips_an_unresolved_bare_program() {
ensure_node_compatible(Path::new("bob")).expect("bare name passes through");
}
}