Skip to main content

muse_codes/
cli.rs

1//! Builder for spawning headless `muse exec --json` runs.
2
3use crate::error::{Error, Result};
4use std::path::PathBuf;
5use std::process::Stdio;
6
7/// Provider mode for a run.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Provider {
10    /// The Meta provider (default; requires credentials — `muse login`,
11    /// `META_API_KEY`, or `~/.config/muse/auth.json`).
12    Meta,
13    /// Credential-free echo provider — exercises the full event stream
14    /// without model calls. What this crate's committed captures use.
15    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/// String-collecting stand-in for `Command::arg`/`args` so argv assembly
28/// is pure and testable without resolving the binary.
29#[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/// Session git-worktree mode (`--worktree`).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum WorktreeMode {
52    Off,
53    /// Create a fresh worktree (base ref via
54    /// [`MuseExecBuilder::worktree_base`], default `HEAD`).
55    Create,
56    /// Use an existing worktree (path via
57    /// [`MuseExecBuilder::worktree_existing`]).
58    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/// Builder for one `muse exec --json` invocation.
72///
73/// Covers the full `muse exec` flag surface (Muse Code 0.1.0), verified
74/// flag-by-flag against the real binary. Constraints the CLI enforces are
75/// noted on each method (several flags are Meta-provider-only; the echo
76/// provider rejects them at startup with a usage error).
77#[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    /// An empty-prompt builder resolving `muse` from `PATH`; use
122    /// [`MuseExecBuilder::new`] (or [`MuseExecBuilder::prompt_file`]) to
123    /// supply the prompt.
124    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    /// Use a specific binary instead of `muse` from `PATH`.
178    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    /// Built-in preset (`native-basic`, `miniswe`).
189    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    /// Run under a caller-supplied session id (`--session-id`), the basis of
200    /// multi-turn continuity: each turn is its own process, and passing the
201    /// same id makes the CLI continue that session rather than start a new
202    /// one. The id is adopted verbatim as the `stream.id` on every emitted
203    /// record.
204    ///
205    /// Supplying your own id is also what makes
206    /// [`MuseRecord`](crate::MuseRecord) identity safe to key on: record
207    /// `id`s are UUID-shaped counters that repeat across sessions, so the
208    /// only unique handle is the composite `(stream.id, id)` — and that is
209    /// trustworthy precisely because `stream.id` is yours. (When omitted,
210    /// the CLI mints a random v4 of its own.)
211    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    /// Meta reasoning effort (`none|minimal|low|medium|high|xhigh|ultra`).
217    /// Not supported with [`Provider::Echo`].
218    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    /// Read the prompt from a file (`--prompt-file`) instead of passing it
229    /// as an argument; the positional prompt is omitted when set.
230    pub fn prompt_file(mut self, path: impl Into<PathBuf>) -> Self {
231        self.prompt_file = Some(path.into());
232        self
233    }
234
235    /// Read the provider API key from stdin (`--api-key-stdin`).
236    /// Meta-provider-only (the echo provider rejects it at startup). When
237    /// set, the child's stdin is piped instead of null — the caller writes
238    /// the key (newline-terminated) and closes it.
239    pub fn api_key_stdin(mut self, enabled: bool) -> Self {
240        self.api_key_stdin = enabled;
241        self
242    }
243
244    /// Meta API parallel tool calls: `true` → `--parallel-tool-calls`,
245    /// `false` → `--no-parallel-tool-calls`. Meta-provider-only (the echo
246    /// provider rejects both at startup).
247    pub fn parallel_tool_calls(mut self, enabled: bool) -> Self {
248        self.parallel_tool_calls = Some(enabled);
249        self
250    }
251
252    /// One ephemeral agent-definition overlay as JSON (`--agents`).
253    /// Accepted by `exec` even though only the top-level help lists it
254    /// (verified against the binary).
255    pub fn agents(mut self, json: impl Into<String>) -> Self {
256        self.agents = Some(json.into());
257        self
258    }
259
260    /// Attach a local image file (`--image`, repeatable). Requires an
261    /// image-capable provider — the echo provider rejects it at startup.
262    pub fn image(mut self, path: impl Into<PathBuf>) -> Self {
263        self.images.push(path.into());
264        self
265    }
266
267    /// Root policy-gated workspace tools at this path (`--workspace`).
268    pub fn workspace(mut self, path: impl Into<PathBuf>) -> Self {
269        self.workspace = Some(path.into());
270        self
271    }
272
273    /// Session git worktree mode (`--worktree off|create|existing`).
274    pub fn worktree(mut self, mode: WorktreeMode) -> Self {
275        self.worktree = Some(mode);
276        self
277    }
278
279    /// Base ref for [`WorktreeMode::Create`] (`--worktree-base`, default
280    /// `HEAD`).
281    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    /// Existing worktree path for [`WorktreeMode::Existing`]
287    /// (`--worktree-existing`).
288    pub fn worktree_existing(mut self, path: impl Into<PathBuf>) -> Self {
289        self.worktree_existing = Some(path.into());
290        self
291    }
292
293    /// Context compaction strategy id (`--context-compaction-strategy`,
294    /// e.g. `summary-preserved-suffix/v1`). Kept as a string: the ids are
295    /// versioned and the set will drift.
296    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    /// Soft compaction threshold fraction
302    /// (`--context-compaction-soft-threshold`).
303    pub fn context_compaction_soft_threshold(mut self, fraction: f64) -> Self {
304        self.context_compaction_soft_threshold = Some(fraction);
305        self
306    }
307
308    /// Hard compaction threshold fraction
309    /// (`--context-compaction-hard-threshold`).
310    pub fn context_compaction_hard_threshold(mut self, fraction: f64) -> Self {
311        self.context_compaction_hard_threshold = Some(fraction);
312        self
313    }
314
315    /// Cap the number of model steps (`--max-model-steps`).
316    pub fn max_model_steps(mut self, steps: u64) -> Self {
317        self.max_model_steps = Some(steps);
318        self
319    }
320
321    /// Cap tool output bytes fed back to the model
322    /// (`--max-tool-output-bytes`).
323    pub fn max_tool_output_bytes(mut self, bytes: u64) -> Self {
324        self.max_tool_output_bytes = Some(bytes);
325        self
326    }
327
328    /// Allow switching the workspace mid-run (`--allow-workspace-switch`).
329    /// The CLI requires [`MuseExecBuilder::session_id`] alongside it
330    /// (verified: rejected at startup otherwise).
331    pub fn allow_workspace_switch(mut self, enabled: bool) -> Self {
332        self.allow_workspace_switch = enabled;
333        self
334    }
335
336    /// Offer `request_user_input` and auto-cancel prompts in headless runs
337    /// (`--user-input-auto-resolve`).
338    pub fn user_input_auto_resolve(mut self, enabled: bool) -> Self {
339        self.user_input_auto_resolve = enabled;
340        self
341    }
342
343    /// Compatibility flag for subagent worktree isolation
344    /// (`--subagent-worktree-isolation`); the capability defaults on.
345    pub fn subagent_worktree_isolation(mut self, enabled: bool) -> Self {
346        self.subagent_worktree_isolation = enabled;
347        self
348    }
349
350    /// Disable web tools for this run (`--disable-web-tools`).
351    pub fn disable_web_tools(mut self, disabled: bool) -> Self {
352        self.disable_web_tools = disabled;
353        self
354    }
355
356    /// Exclude foreign personal rules and skills
357    /// (`--no-foreign-personal-context`).
358    pub fn no_foreign_personal_context(mut self, excluded: bool) -> Self {
359        self.no_foreign_personal_context = excluded;
360        self
361    }
362
363    /// Do not persist session event logs to disk (`--no-session-log`).
364    /// Conflicts with [`MuseExecBuilder::session_id`]: the CLI rejects the
365    /// pair at startup ("a session id needs retained logging" — verified),
366    /// which also means multi-turn continuity requires the log.
367    pub fn no_session_log(mut self, disabled: bool) -> Self {
368        self.no_session_log = disabled;
369        self
370    }
371
372    /// Disable approval and sandbox and trust this workspace for the run
373    /// (`--yolo`).
374    pub fn yolo(mut self, enabled: bool) -> Self {
375        self.yolo = enabled;
376        self
377    }
378
379    /// Load this workspace's skills and rules for the run
380    /// (`--trust-workspace`).
381    pub fn trust_workspace(mut self, trusted: bool) -> Self {
382        self.trust_workspace = trusted;
383        self
384    }
385
386    /// Disable tool approval prompts for the run (`--disable-approval`).
387    pub fn disable_approval(mut self, disabled: bool) -> Self {
388        self.disable_approval = disabled;
389        self
390    }
391
392    /// Disable shell filesystem/network sandboxing for the run
393    /// (`--disable-sandbox`).
394    pub fn disable_sandbox(mut self, disabled: bool) -> Self {
395        self.disable_sandbox = disabled;
396        self
397    }
398
399    /// Sandbox network mode (`--sandbox-network`, default `proxy-only`).
400    /// Kept as a string: the help names no closed set of modes.
401    pub fn sandbox_network(mut self, mode: impl Into<String>) -> Self {
402        self.sandbox_network = Some(mode.into());
403        self
404    }
405
406    /// Disable non-shell workspace filesystem writes (`--disable-write`).
407    pub fn disable_write(mut self, disabled: bool) -> Self {
408        self.disable_write = disabled;
409        self
410    }
411
412    /// Disable workspace shell execution (`--disable-shell`).
413    pub fn disable_shell(mut self, disabled: bool) -> Self {
414        self.disable_shell = disabled;
415        self
416    }
417
418    /// Use the legacy shell tool instead of managed bash
419    /// (`--enable-shell-tool`).
420    pub fn enable_shell_tool(mut self, enabled: bool) -> Self {
421        self.enable_shell_tool = enabled;
422        self
423    }
424
425    /// Raw argument passthrough for flags this builder does not model
426    /// (mirrors codex-codes). Appended AFTER every typed flag and BEFORE
427    /// the positional prompt, so callers relaying user-supplied tokens
428    /// (e.g. a launch dialog's extra-args box) need no flag parser of
429    /// their own. Prefer the typed setters when one exists.
430    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    /// The full argv after the binary name — assembly is separated from
450    /// binary resolution so it can be exercised (and unit-tested) on hosts
451    /// without a `muse` install.
452    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        // `--prompt-file` replaces the positional prompt.
562        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    /// Resolve the binary and assemble the command with piped stdio.
571    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        // `--api-key-stdin` needs a writable stdin; the caller writes the
578        // key and closes it. Otherwise stdin stays null.
579        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    /// Spawn the run.
597    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        // Pure assembly — no `muse` install needed, so these run on any CI
608        // host.
609        builder.assembled_args()
610    }
611
612    /// Every flag lands on the command line exactly as the CLI spells it —
613    /// the full `muse exec` surface, so a missing arm here is a missing
614    /// flag.
615    #[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    /// `--no-parallel-tool-calls` is the false arm of one knob, not a
715    /// separate builder method.
716    #[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    /// `--prompt-file` replaces the positional prompt entirely.
724    #[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    /// Raw passthrough lands after typed flags, before the prompt — the
733    /// position a launch dialog's freeform tokens must occupy.
734    #[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    /// Nothing optional leaks into a minimal invocation.
756    #[test]
757    fn minimal_invocation_stays_minimal() {
758        let got = args(&MuseExecBuilder::new("hi"));
759        assert_eq!(got, ["exec", "--json", "hi"]);
760    }
761}