1use crate::error::{Error, Result};
4use std::path::PathBuf;
5use std::process::Stdio;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Provider {
10 Meta,
13 Echo,
16}
17
18impl Provider {
19 fn as_str(self) -> &'static str {
20 match self {
21 Provider::Meta => "meta",
22 Provider::Echo => "echo",
23 }
24 }
25}
26
27#[derive(Default)]
30struct ArgSink(Vec<String>);
31
32impl ArgSink {
33 fn arg(&mut self, a: impl AsRef<std::ffi::OsStr>) -> &mut Self {
34 self.0.push(a.as_ref().to_string_lossy().into_owned());
35 self
36 }
37 fn args<I, S>(&mut self, items: I) -> &mut Self
38 where
39 I: IntoIterator<Item = S>,
40 S: AsRef<std::ffi::OsStr>,
41 {
42 for a in items {
43 self.arg(a);
44 }
45 self
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum WorktreeMode {
52 Off,
53 Create,
56 Existing,
59}
60
61impl WorktreeMode {
62 fn as_str(self) -> &'static str {
63 match self {
64 WorktreeMode::Off => "off",
65 WorktreeMode::Create => "create",
66 WorktreeMode::Existing => "existing",
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
78pub struct MuseExecBuilder {
79 binary: String,
80 prompt: String,
81 prompt_file: Option<PathBuf>,
82 api_key_stdin: bool,
83 provider: Option<Provider>,
84 preset: Option<String>,
85 model: Option<String>,
86 session_id: Option<String>,
87 reasoning_effort: Option<String>,
88 parallel_tool_calls: Option<bool>,
89 base_url: Option<String>,
90 agents: Option<String>,
91 images: Vec<PathBuf>,
92 workspace: Option<PathBuf>,
93 worktree: Option<WorktreeMode>,
94 worktree_base: Option<String>,
95 worktree_existing: Option<PathBuf>,
96 context_compaction_strategy: Option<String>,
97 context_compaction_soft_threshold: Option<f64>,
98 context_compaction_hard_threshold: Option<f64>,
99 max_model_steps: Option<u64>,
100 max_tool_output_bytes: Option<u64>,
101 allow_workspace_switch: bool,
102 user_input_auto_resolve: bool,
103 subagent_worktree_isolation: bool,
104 disable_web_tools: bool,
105 no_foreign_personal_context: bool,
106 no_session_log: bool,
107 yolo: bool,
108 trust_workspace: bool,
109 disable_approval: bool,
110 disable_sandbox: bool,
111 sandbox_network: Option<String>,
112 disable_write: bool,
113 disable_shell: bool,
114 enable_shell_tool: bool,
115 extra_args: Vec<String>,
116 working_directory: Option<PathBuf>,
117 envs: Vec<(String, String)>,
118}
119
120impl Default for MuseExecBuilder {
121 fn default() -> Self {
125 Self {
126 binary: "muse".to_string(),
127 prompt: String::new(),
128 prompt_file: None,
129 api_key_stdin: false,
130 provider: None,
131 preset: None,
132 model: None,
133 session_id: None,
134 reasoning_effort: None,
135 parallel_tool_calls: None,
136 base_url: None,
137 agents: None,
138 images: Vec::new(),
139 workspace: None,
140 worktree: None,
141 worktree_base: None,
142 worktree_existing: None,
143 context_compaction_strategy: None,
144 context_compaction_soft_threshold: None,
145 context_compaction_hard_threshold: None,
146 max_model_steps: None,
147 max_tool_output_bytes: None,
148 allow_workspace_switch: false,
149 user_input_auto_resolve: false,
150 subagent_worktree_isolation: false,
151 disable_web_tools: false,
152 no_foreign_personal_context: false,
153 no_session_log: false,
154 yolo: false,
155 trust_workspace: false,
156 disable_approval: false,
157 disable_sandbox: false,
158 sandbox_network: None,
159 disable_write: false,
160 disable_shell: false,
161 enable_shell_tool: false,
162 extra_args: Vec::new(),
163 working_directory: None,
164 envs: Vec::new(),
165 }
166 }
167}
168
169impl MuseExecBuilder {
170 pub fn new(prompt: impl Into<String>) -> Self {
171 Self {
172 prompt: prompt.into(),
173 ..Self::default()
174 }
175 }
176
177 pub fn binary(mut self, path: impl Into<String>) -> Self {
179 self.binary = path.into();
180 self
181 }
182
183 pub fn provider(mut self, provider: Provider) -> Self {
184 self.provider = Some(provider);
185 self
186 }
187
188 pub fn preset(mut self, preset: impl Into<String>) -> Self {
190 self.preset = Some(preset.into());
191 self
192 }
193
194 pub fn model(mut self, model: impl Into<String>) -> Self {
195 self.model = Some(model.into());
196 self
197 }
198
199 pub fn session_id(mut self, session_id: impl Into<String>) -> Self {
212 self.session_id = Some(session_id.into());
213 self
214 }
215
216 pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
219 self.reasoning_effort = Some(effort.into());
220 self
221 }
222
223 pub fn base_url(mut self, url: impl Into<String>) -> Self {
224 self.base_url = Some(url.into());
225 self
226 }
227
228 pub fn prompt_file(mut self, path: impl Into<PathBuf>) -> Self {
231 self.prompt_file = Some(path.into());
232 self
233 }
234
235 pub fn api_key_stdin(mut self, enabled: bool) -> Self {
240 self.api_key_stdin = enabled;
241 self
242 }
243
244 pub fn parallel_tool_calls(mut self, enabled: bool) -> Self {
248 self.parallel_tool_calls = Some(enabled);
249 self
250 }
251
252 pub fn agents(mut self, json: impl Into<String>) -> Self {
256 self.agents = Some(json.into());
257 self
258 }
259
260 pub fn image(mut self, path: impl Into<PathBuf>) -> Self {
263 self.images.push(path.into());
264 self
265 }
266
267 pub fn workspace(mut self, path: impl Into<PathBuf>) -> Self {
269 self.workspace = Some(path.into());
270 self
271 }
272
273 pub fn worktree(mut self, mode: WorktreeMode) -> Self {
275 self.worktree = Some(mode);
276 self
277 }
278
279 pub fn worktree_base(mut self, git_ref: impl Into<String>) -> Self {
282 self.worktree_base = Some(git_ref.into());
283 self
284 }
285
286 pub fn worktree_existing(mut self, path: impl Into<PathBuf>) -> Self {
289 self.worktree_existing = Some(path.into());
290 self
291 }
292
293 pub fn context_compaction_strategy(mut self, id: impl Into<String>) -> Self {
297 self.context_compaction_strategy = Some(id.into());
298 self
299 }
300
301 pub fn context_compaction_soft_threshold(mut self, fraction: f64) -> Self {
304 self.context_compaction_soft_threshold = Some(fraction);
305 self
306 }
307
308 pub fn context_compaction_hard_threshold(mut self, fraction: f64) -> Self {
311 self.context_compaction_hard_threshold = Some(fraction);
312 self
313 }
314
315 pub fn max_model_steps(mut self, steps: u64) -> Self {
317 self.max_model_steps = Some(steps);
318 self
319 }
320
321 pub fn max_tool_output_bytes(mut self, bytes: u64) -> Self {
324 self.max_tool_output_bytes = Some(bytes);
325 self
326 }
327
328 pub fn allow_workspace_switch(mut self, enabled: bool) -> Self {
332 self.allow_workspace_switch = enabled;
333 self
334 }
335
336 pub fn user_input_auto_resolve(mut self, enabled: bool) -> Self {
339 self.user_input_auto_resolve = enabled;
340 self
341 }
342
343 pub fn subagent_worktree_isolation(mut self, enabled: bool) -> Self {
346 self.subagent_worktree_isolation = enabled;
347 self
348 }
349
350 pub fn disable_web_tools(mut self, disabled: bool) -> Self {
352 self.disable_web_tools = disabled;
353 self
354 }
355
356 pub fn no_foreign_personal_context(mut self, excluded: bool) -> Self {
359 self.no_foreign_personal_context = excluded;
360 self
361 }
362
363 pub fn no_session_log(mut self, disabled: bool) -> Self {
368 self.no_session_log = disabled;
369 self
370 }
371
372 pub fn yolo(mut self, enabled: bool) -> Self {
375 self.yolo = enabled;
376 self
377 }
378
379 pub fn trust_workspace(mut self, trusted: bool) -> Self {
382 self.trust_workspace = trusted;
383 self
384 }
385
386 pub fn disable_approval(mut self, disabled: bool) -> Self {
388 self.disable_approval = disabled;
389 self
390 }
391
392 pub fn disable_sandbox(mut self, disabled: bool) -> Self {
395 self.disable_sandbox = disabled;
396 self
397 }
398
399 pub fn sandbox_network(mut self, mode: impl Into<String>) -> Self {
402 self.sandbox_network = Some(mode.into());
403 self
404 }
405
406 pub fn disable_write(mut self, disabled: bool) -> Self {
408 self.disable_write = disabled;
409 self
410 }
411
412 pub fn disable_shell(mut self, disabled: bool) -> Self {
414 self.disable_shell = disabled;
415 self
416 }
417
418 pub fn enable_shell_tool(mut self, enabled: bool) -> Self {
421 self.enable_shell_tool = enabled;
422 self
423 }
424
425 pub fn extra_args<I, S>(mut self, args: I) -> Self
431 where
432 I: IntoIterator<Item = S>,
433 S: Into<String>,
434 {
435 self.extra_args.extend(args.into_iter().map(Into::into));
436 self
437 }
438
439 pub fn working_directory(mut self, dir: impl Into<PathBuf>) -> Self {
440 self.working_directory = Some(dir.into());
441 self
442 }
443
444 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
445 self.envs.push((key.into(), value.into()));
446 self
447 }
448
449 fn assembled_args(&self) -> Vec<String> {
453 let mut cmd = ArgSink::default();
454 cmd.arg("exec").arg("--json");
455 if let Some(p) = self.provider {
456 cmd.args(["--provider", p.as_str()]);
457 }
458 if let Some(p) = &self.preset {
459 cmd.args(["--preset", p]);
460 }
461 if let Some(m) = &self.model {
462 cmd.args(["--model", m]);
463 }
464 if let Some(s) = &self.session_id {
465 cmd.args(["--session-id", s]);
466 }
467 if let Some(e) = &self.reasoning_effort {
468 cmd.args(["--reasoning-effort", e]);
469 }
470 if let Some(enabled) = self.parallel_tool_calls {
471 cmd.arg(if enabled {
472 "--parallel-tool-calls"
473 } else {
474 "--no-parallel-tool-calls"
475 });
476 }
477 if let Some(u) = &self.base_url {
478 cmd.args(["--base-url", u]);
479 }
480 if let Some(a) = &self.agents {
481 cmd.args(["--agents", a]);
482 }
483 for image in &self.images {
484 cmd.arg("--image").arg(image);
485 }
486 if let Some(w) = &self.workspace {
487 cmd.arg("--workspace").arg(w);
488 }
489 if let Some(mode) = self.worktree {
490 cmd.args(["--worktree", mode.as_str()]);
491 }
492 if let Some(base) = &self.worktree_base {
493 cmd.args(["--worktree-base", base]);
494 }
495 if let Some(path) = &self.worktree_existing {
496 cmd.arg("--worktree-existing").arg(path);
497 }
498 if let Some(s) = &self.context_compaction_strategy {
499 cmd.args(["--context-compaction-strategy", s]);
500 }
501 if let Some(f) = self.context_compaction_soft_threshold {
502 cmd.args(["--context-compaction-soft-threshold", &f.to_string()]);
503 }
504 if let Some(f) = self.context_compaction_hard_threshold {
505 cmd.args(["--context-compaction-hard-threshold", &f.to_string()]);
506 }
507 if let Some(n) = self.max_model_steps {
508 cmd.args(["--max-model-steps", &n.to_string()]);
509 }
510 if let Some(n) = self.max_tool_output_bytes {
511 cmd.args(["--max-tool-output-bytes", &n.to_string()]);
512 }
513 if self.api_key_stdin {
514 cmd.arg("--api-key-stdin");
515 }
516 if self.allow_workspace_switch {
517 cmd.arg("--allow-workspace-switch");
518 }
519 if self.user_input_auto_resolve {
520 cmd.arg("--user-input-auto-resolve");
521 }
522 if self.subagent_worktree_isolation {
523 cmd.arg("--subagent-worktree-isolation");
524 }
525 if self.disable_web_tools {
526 cmd.arg("--disable-web-tools");
527 }
528 if self.no_foreign_personal_context {
529 cmd.arg("--no-foreign-personal-context");
530 }
531 if self.no_session_log {
532 cmd.arg("--no-session-log");
533 }
534 if self.yolo {
535 cmd.arg("--yolo");
536 }
537 if self.trust_workspace {
538 cmd.arg("--trust-workspace");
539 }
540 if self.disable_approval {
541 cmd.arg("--disable-approval");
542 }
543 if self.disable_sandbox {
544 cmd.arg("--disable-sandbox");
545 }
546 if let Some(mode) = &self.sandbox_network {
547 cmd.args(["--sandbox-network", mode]);
548 }
549 if self.disable_write {
550 cmd.arg("--disable-write");
551 }
552 if self.disable_shell {
553 cmd.arg("--disable-shell");
554 }
555 if self.enable_shell_tool {
556 cmd.arg("--enable-shell-tool");
557 }
558 for arg in &self.extra_args {
559 cmd.arg(arg);
560 }
561 if let Some(file) = &self.prompt_file {
563 cmd.arg("--prompt-file").arg(file);
564 } else {
565 cmd.arg(&self.prompt);
566 }
567 cmd.0
568 }
569
570 pub fn build_command(&self) -> Result<tokio::process::Command> {
572 let program = which::which(&self.binary).map_err(|_| Error::BinaryNotFound {
573 name: self.binary.clone(),
574 })?;
575 let mut cmd = tokio::process::Command::new(program);
576 cmd.args(self.assembled_args());
577 cmd.stdin(if self.api_key_stdin {
580 Stdio::piped()
581 } else {
582 Stdio::null()
583 })
584 .stdout(Stdio::piped())
585 .stderr(Stdio::piped())
586 .kill_on_drop(true);
587 if let Some(dir) = &self.working_directory {
588 cmd.current_dir(dir);
589 }
590 for (k, v) in &self.envs {
591 cmd.env(k, v);
592 }
593 Ok(cmd)
594 }
595
596 pub async fn spawn(&self) -> Result<tokio::process::Child> {
598 Ok(self.build_command()?.spawn()?)
599 }
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605
606 fn args(builder: &MuseExecBuilder) -> Vec<String> {
607 builder.assembled_args()
610 }
611
612 #[test]
616 fn full_flag_surface_assembles() {
617 let b = MuseExecBuilder::new("do the thing")
618 .provider(Provider::Meta)
619 .preset("native-basic")
620 .model("m-1")
621 .session_id("s-1")
622 .reasoning_effort("high")
623 .parallel_tool_calls(true)
624 .base_url("http://localhost:1")
625 .agents("{}")
626 .image("/tmp/a.png")
627 .image("/tmp/b.png")
628 .workspace("/ws")
629 .worktree(WorktreeMode::Create)
630 .worktree_base("main")
631 .worktree_existing("/wt")
632 .context_compaction_strategy("summary-preserved-suffix/v1")
633 .context_compaction_soft_threshold(0.7)
634 .context_compaction_hard_threshold(0.9)
635 .max_model_steps(5)
636 .max_tool_output_bytes(1000)
637 .api_key_stdin(true)
638 .allow_workspace_switch(true)
639 .user_input_auto_resolve(true)
640 .subagent_worktree_isolation(true)
641 .disable_web_tools(true)
642 .no_foreign_personal_context(true)
643 .no_session_log(true)
644 .yolo(true)
645 .trust_workspace(true)
646 .disable_approval(true)
647 .disable_sandbox(true)
648 .sandbox_network("proxy-only")
649 .disable_write(true)
650 .disable_shell(true)
651 .enable_shell_tool(true);
652 let got = args(&b);
653 let want: Vec<&str> = vec![
654 "exec",
655 "--json",
656 "--provider",
657 "meta",
658 "--preset",
659 "native-basic",
660 "--model",
661 "m-1",
662 "--session-id",
663 "s-1",
664 "--reasoning-effort",
665 "high",
666 "--parallel-tool-calls",
667 "--base-url",
668 "http://localhost:1",
669 "--agents",
670 "{}",
671 "--image",
672 "/tmp/a.png",
673 "--image",
674 "/tmp/b.png",
675 "--workspace",
676 "/ws",
677 "--worktree",
678 "create",
679 "--worktree-base",
680 "main",
681 "--worktree-existing",
682 "/wt",
683 "--context-compaction-strategy",
684 "summary-preserved-suffix/v1",
685 "--context-compaction-soft-threshold",
686 "0.7",
687 "--context-compaction-hard-threshold",
688 "0.9",
689 "--max-model-steps",
690 "5",
691 "--max-tool-output-bytes",
692 "1000",
693 "--api-key-stdin",
694 "--allow-workspace-switch",
695 "--user-input-auto-resolve",
696 "--subagent-worktree-isolation",
697 "--disable-web-tools",
698 "--no-foreign-personal-context",
699 "--no-session-log",
700 "--yolo",
701 "--trust-workspace",
702 "--disable-approval",
703 "--disable-sandbox",
704 "--sandbox-network",
705 "proxy-only",
706 "--disable-write",
707 "--disable-shell",
708 "--enable-shell-tool",
709 "do the thing",
710 ];
711 assert_eq!(got, want);
712 }
713
714 #[test]
717 fn parallel_tool_calls_false_emits_the_no_flag() {
718 let got = args(&MuseExecBuilder::new("p").parallel_tool_calls(false));
719 assert!(got.contains(&"--no-parallel-tool-calls".to_string()));
720 assert!(!got.contains(&"--parallel-tool-calls".to_string()));
721 }
722
723 #[test]
725 fn prompt_file_replaces_the_positional_prompt() {
726 let got = args(&MuseExecBuilder::new("ignored").prompt_file("/tmp/p.txt"));
727 assert_eq!(got.last().map(String::as_str), Some("/tmp/p.txt"));
728 assert!(got.contains(&"--prompt-file".to_string()));
729 assert!(!got.contains(&"ignored".to_string()));
730 }
731
732 #[test]
735 fn extra_args_sit_between_typed_flags_and_the_prompt() {
736 let got = args(
737 &MuseExecBuilder::new("go")
738 .model("m-1")
739 .extra_args(["--reasoning-effort", "low"]),
740 );
741 assert_eq!(
742 got,
743 [
744 "exec",
745 "--json",
746 "--model",
747 "m-1",
748 "--reasoning-effort",
749 "low",
750 "go"
751 ]
752 );
753 }
754
755 #[test]
757 fn minimal_invocation_stays_minimal() {
758 let got = args(&MuseExecBuilder::new("hi"));
759 assert_eq!(got, ["exec", "--json", "hi"]);
760 }
761}