Skip to main content

forge_sdk/
options.rs

1//! Configuration for spawning a `Client`.
2//!
3//! SDK's `ClaudeAgentOptions`.
4
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use crate::hooks::Hooks;
9use std::collections::HashMap;
10
11use crate::mcp::McpServer;
12use crate::permissions::CanUseToolCallback;
13// Pure-data option enums live in forge-primitives.
14use forge_primitives::subagents::SubagentEffort;
15pub use forge_primitives::{PermissionMode, SdkPluginConfig, SystemPromptKind};
16
17/// Per-line callback used by [`Options::tee_inbound`] and
18/// [`Options::tee_outbound`] to capture the wire bytes the SDK
19/// exchanges with the `claude` subprocess. The callback receives
20/// lines without a trailing newline.
21pub type WireTee = Arc<dyn Fn(&str) + Send + Sync>;
22
23/// Predicate used by [`Options::auto_approve_tool`] to short-circuit
24/// the permission flow for trusted tool names (e.g. forge's own
25/// in-process MCP server prefixes).
26pub type AutoApproveToolPredicate = Arc<dyn Fn(&str) -> bool + Send + Sync>;
27
28/// Configuration for one `Client` invocation.
29///
30/// Construct via [`OptionsBuilder`] rather than populating directly.
31#[derive(Clone)]
32pub struct Options {
33    /// Path or name of the `claude` binary to spawn.
34    pub binary: String,
35    /// Working directory for the subprocess. None → inherit current cwd.
36    pub cwd: Option<PathBuf>,
37    /// Session id to resume. Passed as `--resume <id>`. None → new session.
38    pub resume: Option<String>,
39    /// Model name. Passed as `--model <name>`. None → binary default.
40    pub model: Option<String>,
41    /// Permission flow.
42    pub permission_mode: PermissionMode,
43    /// Optional permission callback. When set, the `claude` binary asks
44    /// this callback before invoking any tool.
45    pub can_use_tool: Option<Arc<dyn CanUseToolCallback>>,
46    /// Optional auto-approve predicate. When the CLI asks via
47    /// `can_use_tool` whether a particular tool can run, forge-sdk
48    /// consults this predicate first; tools that match are
49    /// auto-approved without invoking the `can_use_tool` callback.
50    /// Caller (forge-workspace via forge-agent) typically supplies a
51    /// closure that auto-approves any tool whose name starts with the
52    /// `mcp__<server>__` prefix of one of its in-process MCP servers
53    /// (e.g. `mcp__forge__agents__whoami`) - those servers are
54    /// already opted into by the user's forge.toml so a permission
55    /// prompt would just be noise.
56    pub auto_approve_tool: Option<AutoApproveToolPredicate>,
57    /// In-process MCP servers. Each entry maps a server name (used in the
58    /// `mcp__<server>__<tool>` prefix the model sees) to a built
59    /// [`McpServer`].
60    pub mcp_servers: Vec<(String, McpServer)>,
61    /// External MCP servers - stdio / SSE / HTTP. Mirrors the non-SDK
62    /// variants of the CLI's `ClaudeAgentOptions.mcp_servers`
63    ///. Registered alongside in-process servers in
64    /// the `--mcp-config` JSON.
65    pub external_mcp_servers: std::collections::HashMap<String, forge_primitives::McpServerConfig>,
66    /// Registered hooks. Empty by default.
67    pub hooks: Hooks,
68    /// Tool names the model is allowed to invoke. Passed to the CLI as
69    /// `--allowedTools <comma,list>`. Empty means "no explicit allowlist".
70    pub allowed_tools: Vec<String>,
71    /// Orthogonal permission-prompt tool. When set, passed as
72    /// `--permission-prompt-tool <name>`.
73    pub permission_prompt_tool_name: Option<String>,
74    /// Minimum `claude` binary version required. Default `>= 2.0.0`.
75    /// When `Some`, `Client::spawn` runs `<binary> --version` once
76    /// and checks the reported major version is at least the first
77    /// component.
78    pub minimum_cli_version: Option<String>,
79    /// System prompt configuration. `None` = inherit CLI default.
80    /// `Some` emits `--system-prompt`, `--system-prompt-file`, or
81    /// `--append-system-prompt` depending on variant.
82    pub system_prompt: Option<SystemPromptKind>,
83    /// Turn limit. `--max-turns <n>`.
84    pub max_turns: Option<u64>,
85    /// Explicit session id for a new session (distinct from `resume`).
86    /// `--session-id <id>`.
87    pub session_id: Option<String>,
88    /// Local plugins. Wire shape: `list[SdkPluginConfig]`.
89    pub plugins: Vec<SdkPluginConfig>,
90    /// Environment variables added to the subprocess env.
91    pub env: HashMap<String, String>,
92    /// Arbitrary forward flags - `{"flag": Some("v")}` emits
93    /// `--flag v`, `{"flag": None}` emits a bare `--flag`.
94    pub extra_args: HashMap<String, Option<String>>,
95    /// Reasoning-effort hint. `--effort <level>` - the CLI's `effort`
96    /// is a literal-or-integer carried via [`SubagentEffort`] (named
97    /// for its origin on the subagent declaration shape, but the
98    /// session-level effort uses the same wire enum).
99    pub effort: Option<SubagentEffort>,
100    /// Inbound wire tee. When set, the SDK invokes
101    /// `callback(line)` for every stream-json line read from the
102    /// subprocess stdout BEFORE decoding. Used by
103    /// `forge-test-harness` to capture the raw wire bytes for
104    /// conformance baselines. The callback receives lines without a
105    /// trailing newline.
106    pub tee_inbound: Option<WireTee>,
107    /// Outbound wire tee. When set, the SDK invokes
108    /// `callback(line)` for every stream-json line about to be
109    /// written to the subprocess stdin. Counterpart to
110    /// [`tee_inbound`](Self::tee_inbound). The callback receives
111    /// lines without a trailing newline (the SDK strips it before
112    /// invoking).
113    pub tee_outbound: Option<WireTee>,
114}
115
116impl Default for Options {
117    fn default() -> Self {
118        Self {
119            binary: "claude".into(),
120            cwd: None,
121            resume: None,
122            model: None,
123            permission_mode: PermissionMode::Ask,
124            can_use_tool: None,
125            auto_approve_tool: None,
126            mcp_servers: Vec::new(),
127            external_mcp_servers: HashMap::new(),
128            hooks: Hooks::default(),
129            allowed_tools: Vec::new(),
130            permission_prompt_tool_name: None,
131            minimum_cli_version: Some("2.0.0".into()),
132            system_prompt: None,
133            max_turns: None,
134            session_id: None,
135            plugins: Vec::new(),
136            env: HashMap::new(),
137            extra_args: HashMap::new(),
138            effort: None,
139            tee_inbound: None,
140            tee_outbound: None,
141        }
142    }
143}
144
145impl std::fmt::Debug for Options {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        f.debug_struct("Options")
148            .field("binary", &self.binary)
149            .field("cwd", &self.cwd)
150            .field("resume", &self.resume)
151            .field("model", &self.model)
152            .field("permission_mode", &self.permission_mode)
153            .field("can_use_tool", &self.can_use_tool.as_ref().map(|_| "<callback>"))
154            .field("auto_approve_tool", &self.auto_approve_tool.as_ref().map(|_| "<predicate>"))
155            .field("mcp_servers", &format!("<{} servers>", self.mcp_servers.len()))
156            .field(
157                "external_mcp_servers",
158                &format!("<{} external>", self.external_mcp_servers.len()),
159            )
160            .field("hooks", &self.hooks)
161            .field("allowed_tools", &self.allowed_tools)
162            .field("permission_prompt_tool_name", &self.permission_prompt_tool_name)
163            .field("minimum_cli_version", &self.minimum_cli_version)
164            .field("system_prompt", &self.system_prompt)
165            .field("max_turns", &self.max_turns)
166            .field("session_id", &self.session_id)
167            .field("plugins", &self.plugins)
168            .field("env", &format!("<{} vars>", self.env.len()))
169            .field("extra_args", &format!("<{} flags>", self.extra_args.len()))
170            .field("effort", &self.effort)
171            .field("tee_inbound", &self.tee_inbound.as_ref().map(|_| "<callback>"))
172            .field("tee_outbound", &self.tee_outbound.as_ref().map(|_| "<callback>"))
173            .finish()
174    }
175}
176
177/// Builder for [`Options`].
178#[derive(Clone, Default)]
179pub struct OptionsBuilder {
180    inner: Options,
181}
182
183impl std::fmt::Debug for OptionsBuilder {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        f.debug_struct("OptionsBuilder").field("inner", &self.inner).finish()
186    }
187}
188
189impl OptionsBuilder {
190    /// Start from defaults.
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    /// Override the `claude` binary path or name.
196    pub fn binary(mut self, binary: impl Into<String>) -> Self {
197        self.inner.binary = binary.into();
198        self
199    }
200
201    /// Set the working directory for the subprocess.
202    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
203        self.inner.cwd = Some(cwd.into());
204        self
205    }
206
207    /// Resume an existing session.
208    pub fn resume(mut self, session_id: impl Into<String>) -> Self {
209        self.inner.resume = Some(session_id.into());
210        self
211    }
212
213    /// Override the model.
214    pub fn model(mut self, model: impl Into<String>) -> Self {
215        self.inner.model = Some(model.into());
216        self
217    }
218
219    /// Set the permission mode.
220    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
221        self.inner.permission_mode = mode;
222        self
223    }
224
225    /// Register a permission callback. Any type implementing
226    /// [`CanUseToolCallback`] works - including plain async functions
227    /// via the blanket impl.
228    pub fn can_use_tool<C>(mut self, callback: C) -> Self
229    where
230        C: CanUseToolCallback + 'static,
231    {
232        self.inner.can_use_tool = Some(Arc::new(callback));
233        self
234    }
235
236    /// Auto-approve any tool whose name satisfies the predicate.
237    /// forge-sdk consults this before invoking `can_use_tool`; the
238    /// permission flow short-circuits with an allow decision for
239    /// matches. Caller typically passes a closure that checks
240    /// `mcp__<server>__` prefixes for its in-process MCP servers.
241    pub fn auto_approve_tool<F>(mut self, predicate: F) -> Self
242    where
243        F: Fn(&str) -> bool + Send + Sync + 'static,
244    {
245        self.inner.auto_approve_tool = Some(Arc::new(predicate));
246        self
247    }
248
249    /// Register an in-process MCP server under the given name. The model
250    /// sees tools as `mcp__<name>__<tool>`.
251    pub fn mcp_server(mut self, name: impl Into<String>, server: McpServer) -> Self {
252        self.inner.mcp_servers.push((name.into(), server));
253        self
254    }
255
256    /// Attach hooks.
257    pub fn hooks(mut self, hooks: Hooks) -> Self {
258        self.inner.hooks = hooks;
259        self
260    }
261
262    /// Set the `--allowedTools` list explicitly.
263    pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
264    where
265        I: IntoIterator<Item = S>,
266        S: Into<String>,
267    {
268        self.inner.allowed_tools = tools.into_iter().map(Into::into).collect();
269        self
270    }
271
272    /// Set an orthogonal permission-prompt tool name (CLI flag
273    /// `--permission-prompt-tool`). Alternative to `can_use_tool` - the
274    /// CLI invokes the named tool (typically via MCP) instead of routing
275    /// permission requests to the SDK callback.
276    pub fn permission_prompt_tool_name(mut self, name: impl Into<String>) -> Self {
277        self.inner.permission_prompt_tool_name = Some(name.into());
278        self
279    }
280
281    /// Set the system prompt.
282    pub fn system_prompt(mut self, sp: SystemPromptKind) -> Self {
283        self.inner.system_prompt = Some(sp);
284        self
285    }
286
287    /// Cap the turn count.
288    pub fn max_turns(mut self, n: u64) -> Self {
289        self.inner.max_turns = Some(n);
290        self
291    }
292
293    /// Set an explicit session id for a new session (distinct from `resume`).
294    pub fn session_id(mut self, id: impl Into<String>) -> Self {
295        self.inner.session_id = Some(id.into());
296        self
297    }
298
299    /// Replace the whole plugin list.
300    pub fn plugins(mut self, plugins: Vec<SdkPluginConfig>) -> Self {
301        self.inner.plugins = plugins;
302        self
303    }
304
305    /// Add one env var to the subprocess environment.
306    pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
307        self.inner.env.insert(k.into(), v.into());
308        self
309    }
310
311    /// Add one extra argv flag (pass `None` for bare flags).
312    pub fn extra_arg(mut self, flag: impl Into<String>, value: Option<String>) -> Self {
313        self.inner.extra_args.insert(flag.into(), value);
314        self
315    }
316
317    /// Set the reasoning-effort hint.
318    pub fn effort(mut self, e: SubagentEffort) -> Self {
319        self.inner.effort = Some(e);
320        self
321    }
322
323    /// Attach an inbound wire-tee callback. Receives every
324    /// stream-json line read from the subprocess stdout (without
325    /// trailing newline) before the SDK decodes it. Used by
326    /// `forge-test-harness` to capture conformance baselines.
327    pub fn tee_inbound(mut self, cb: impl Fn(&str) + Send + Sync + 'static) -> Self {
328        self.inner.tee_inbound = Some(Arc::new(cb));
329        self
330    }
331
332    /// Attach an outbound wire-tee callback. Receives every
333    /// stream-json line about to be written to the subprocess stdin
334    /// (without trailing newline). Counterpart to
335    /// [`tee_inbound`](Self::tee_inbound).
336    pub fn tee_outbound(mut self, cb: impl Fn(&str) + Send + Sync + 'static) -> Self {
337        self.inner.tee_outbound = Some(Arc::new(cb));
338        self
339    }
340
341    /// Finalise and return the `Options`.
342    pub fn build(self) -> Options {
343        self.inner
344    }
345}
346
347#[cfg(test)]
348mod tests_skills_option {
349    // Test-mod `use super::*;` brings the parent's full surface in; not every test consumes every item.
350    #[allow(unused_imports)]
351    use super::*;
352
353    use crate::OptionsBuilder;
354
355    #[test]
356    fn allowed_tools_round_trip() {
357        let opts = OptionsBuilder::new().allowed_tools(["Read", "Grep"]).build();
358        assert_eq!(opts.allowed_tools, vec!["Read".to_string(), "Grep".into()]);
359    }
360}
361
362#[cfg(test)]
363mod tests_options_build {
364    // Test-mod `use super::*;` brings the parent's full surface in; not every test consumes every item.
365    #[allow(unused_imports)]
366    use super::*;
367
368    use std::path::PathBuf;
369
370    use crate::{OptionsBuilder, PermissionMode};
371
372    #[test]
373    fn default_options() {
374        let opts = OptionsBuilder::new().build();
375        assert_eq!(opts.binary, "claude");
376        assert!(opts.cwd.is_none());
377        assert!(opts.resume.is_none());
378        assert_eq!(opts.permission_mode, PermissionMode::Ask);
379        assert!(opts.model.is_none());
380    }
381
382    #[test]
383    fn builder_sets_model_and_cwd() {
384        let opts = OptionsBuilder::new().model("claude-opus-4-5").cwd("/tmp/project").build();
385        assert_eq!(opts.model.as_deref(), Some("claude-opus-4-5"));
386        assert_eq!(opts.cwd, Some(PathBuf::from("/tmp/project")));
387    }
388
389    #[test]
390    fn builder_sets_resume_session() {
391        let opts = OptionsBuilder::new().resume("sess_abc").build();
392        assert_eq!(opts.resume.as_deref(), Some("sess_abc"));
393    }
394
395    #[test]
396    fn builder_sets_permission_mode() {
397        let opts = OptionsBuilder::new().permission_mode(PermissionMode::AcceptEdits).build();
398        assert_eq!(opts.permission_mode, PermissionMode::AcceptEdits);
399    }
400
401    #[test]
402    fn builder_sets_custom_binary() {
403        let opts = OptionsBuilder::new().binary("/usr/local/bin/claude").build();
404        assert_eq!(opts.binary, "/usr/local/bin/claude");
405    }
406
407    #[test]
408    fn builder_stores_can_use_tool_callback() {
409        use forge_primitives::{PermissionDecision, ToolPermissionContext};
410
411        let opts = OptionsBuilder::new()
412            .can_use_tool(|_ctx: ToolPermissionContext| async move { PermissionDecision::allow() })
413            .build();
414        assert!(opts.can_use_tool.is_some());
415    }
416
417    #[test]
418    fn auto_approve_tool_predicate_exact_prefix_match() {
419        // The control_dispatch fast-path invokes the predicate with
420        // the raw tool name. Verify a prefix-based predicate matches
421        // exactly the names the caller intends and nothing else.
422        let opts = OptionsBuilder::new()
423            .auto_approve_tool(|name: &str| name.starts_with("mcp__forge__"))
424            .build();
425        let pred = opts.auto_approve_tool.expect("predicate stored");
426        assert!(pred("mcp__forge__agents__whoami"));
427        assert!(pred("mcp__forge__agents__send_message"));
428        assert!(pred("mcp__forge__agents__list"));
429        // The lead-only verbs live under the same `mcp__forge__`
430        // namespace - auto-approve must cover them with one predicate.
431        assert!(pred("mcp__forge__agents__spawn"));
432        assert!(pred("mcp__forge__agents__capacity"));
433        assert!(pred("mcp__forge__agents__update"));
434        assert!(pred("mcp__forge__agents__despawn"));
435        // Cron tools share the same namespace - one predicate covers them.
436        assert!(pred("mcp__forge__cron__create"));
437        assert!(pred("mcp__forge__cron__list"));
438        assert!(pred("mcp__forge__cron__delete"));
439        // Review-conversation tools share the same namespace, so a future
440        // narrowing of this prefix must not silently start prompting for
441        // them - pin them here.
442        assert!(pred("mcp__forge__review__list"));
443        assert!(pred("mcp__forge__review__get"));
444        assert!(pred("mcp__forge__review__reply"));
445        assert!(pred("mcp__forge__review__resolve"));
446        assert!(pred("mcp__forge__"));
447        // Sibling prefixes must NOT match (no partial-string fuzz).
448        assert!(!pred("mcp__forgery__steal_secrets"));
449        assert!(!pred("mcp__forge"));
450        assert!(!pred("Bash"));
451        assert!(!pred("Read"));
452        // Empty string mustn't accidentally match.
453        assert!(!pred(""));
454    }
455
456    #[test]
457    fn auto_approve_tool_default_is_none() {
458        let opts = OptionsBuilder::new().build();
459        assert!(opts.auto_approve_tool.is_none());
460    }
461}