Skip to main content

harness/bob/
mod.rs

1//! `bob` CLI as a [`Harness`].
2//!
3//! The bob adapter: wraps the standalone [`bob_rs`] SDK (detection,
4//! install, keychain, spawn) behind the neutral [`crate::Harness`]
5//! trait, and parses bob's `--output-format stream-json` stdout into the
6//! shared [`crate::RunEvent`] vocabulary via the [`parser`] module.
7//!
8//! Auth: Compose stores bob's API key (in the OS keychain via `bob_rs`),
9//! so `credential().required` is `true` and `supports_login` is `false` —
10//! unlike the Claude/Codex adapters, which own their CLI's login.
11
12use std::sync::{Arc, Mutex};
13
14use bob_rs::{
15    get_readiness, install_bob, spawn_bob, BobApprovalMode, BobChatMode, RunBobOptions,
16    KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE,
17};
18use crate::{
19    normalize_process_event, CredentialSpec, Harness, HarnessCapabilities, HarnessError,
20    HarnessInfo, HarnessReadiness, InstallCallback, RunCallback, RunHandle, RunMode, RunRequest,
21};
22
23pub mod parser;
24
25pub use parser::{normalize_bob_event, parse_bob_line, BobStreamParser};
26
27/// Registry id for the bob harness.
28pub const BOB_HARNESS_ID: &str = "bob";
29
30/// `bob` CLI as a [`Harness`]. Delegates to the [`bob_rs`] SDK;
31/// this is just the neutral face over it.
32#[derive(Debug, Default, Clone)]
33pub struct BobHarness;
34
35impl BobHarness {
36    pub fn new() -> Self {
37        Self
38    }
39}
40
41impl Harness for BobHarness {
42    fn info(&self) -> HarnessInfo {
43        HarnessInfo {
44            id: BOB_HARNESS_ID.to_owned(),
45            display_name: "Bob".to_owned(),
46            description: "IBM's bob agent CLI. Runs locally via Node.js.".to_owned(),
47            requires_install: true,
48            capabilities: HarnessCapabilities {
49                // Compose stores bob's API key, and bob proposes
50                // previewable edits the user approves. It exposes no
51                // model / effort / turn-cap knobs in the picker today.
52                credential_required: true,
53                previews_edits: true,
54                models: Vec::new(),
55                allows_custom_model: false,
56                supports_effort: false,
57                supports_max_turns: false,
58                supports_login: false,
59            },
60        }
61    }
62
63    fn readiness(&self) -> HarnessReadiness {
64        let snapshot = get_readiness();
65        // Preserve the rich bob probe for the UI while presenting a
66        // neutral top-level shape. Serialization can't realistically
67        // fail for this owned struct; fall back to null if it does.
68        let details = serde_json::to_value(&snapshot).unwrap_or(serde_json::Value::Null);
69        HarnessReadiness {
70            harness_id: BOB_HARNESS_ID.to_owned(),
71            ready: snapshot.ready,
72            installed: snapshot.bob.installed,
73            version: snapshot.bob.version.clone(),
74            auth_configured: snapshot.auth.configured,
75            error: snapshot.bob.error.clone(),
76            details,
77        }
78    }
79
80    fn install(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
81        // The closure captures only the `Arc` (Clone + Send + Sync +
82        // 'static), so it satisfies `install_bob`'s `F: FnMut + Send
83        // + Sync + Clone + 'static` bound. bob-rs reports failures as a typed
84        // `BobError`; carry it as the install error's source.
85        install_bob(move |event| (*on_event)(event)).map_err(HarnessError::install)
86    }
87
88    fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError> {
89        let opts = RunBobOptions {
90            prompt: request.prompt,
91            chat_mode: match request.mode {
92                RunMode::Ask => BobChatMode::Ask,
93                // "Edit" maps onto bob's code mode — the one that
94                // proposes file changes.
95                RunMode::Edit => BobChatMode::Code,
96            },
97            // H2 threads the live approval/coin knobs through; bob's
98            // serde defaults are correct for the additive seam.
99            approval_mode: BobApprovalMode::Default,
100            max_coins: 30,
101            cwd: request.cwd,
102            bob_executable: None,
103        };
104        // bob emits its own process events (lifecycle + raw stream-json
105        // stdout lines). Normalize each into zero or more harness-neutral
106        // `RunEvent`s here, so the consumer only ever sees the normalized
107        // shape — the keystone of the abstraction. bob streams its
108        // reasoning inline as `<thinking>…</thinking>` and its answer via
109        // the `attempt_completion` tool, across many lines — so parsing is
110        // stateful. Hold one `BobStreamParser` for the whole run; the
111        // stdout reader thread drives it sequentially, the `Mutex` just
112        // satisfies the `Fn + Send + Sync` callback bound.
113        let parser = Arc::new(Mutex::new(BobStreamParser::default()));
114        let handle = spawn_bob(opts, request.run_id, move |event| {
115            // Recover a poisoned lock rather than panic on the reader thread —
116            // parsing is total, so the held parser is never mid-corruption.
117            let mut parser = parser.lock().unwrap_or_else(|p| p.into_inner());
118            for normalized in normalize_process_event(event, |line| parser.parse_line(line)) {
119                (*on_event)(normalized);
120            }
121        })
122        .map_err(HarnessError::spawn)?;
123        Ok(Box::new(handle))
124    }
125
126    fn credential(&self) -> CredentialSpec {
127        CredentialSpec {
128            label: "Bob API key".to_owned(),
129            keychain_service: KEYCHAIN_SERVICE.to_owned(),
130            keychain_account: KEYCHAIN_ACCOUNT.to_owned(),
131            required: true,
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn bob_info_requires_install() {
142        let info = BobHarness::new().info();
143        assert_eq!(info.id, BOB_HARNESS_ID);
144        assert!(info.requires_install);
145    }
146
147    #[test]
148    fn bob_credential_points_at_the_shared_keychain_slot() {
149        let cred = BobHarness::new().credential();
150        assert_eq!(cred.keychain_service, KEYCHAIN_SERVICE);
151        assert_eq!(cred.keychain_account, KEYCHAIN_ACCOUNT);
152        assert!(cred.required);
153        // `credential_required` capability must agree with the spec — the
154        // frontend gates its preflight on the capability, so they can't drift.
155        assert_eq!(
156            BobHarness::new().info().capabilities.credential_required,
157            cred.required
158        );
159    }
160
161    #[test]
162    fn bob_default_login_is_unsupported() {
163        // bob authenticates via its stored API key, not an interactive
164        // CLI sign-in, so the default `login` stays unsupported.
165        let cb: InstallCallback = Arc::new(|_| {});
166        assert!(BobHarness::new().login(cb).is_err());
167        assert!(!BobHarness::new().info().capabilities.supports_login);
168    }
169}