Skip to main content

claude_wrapper/command/
query.rs

1//! The `claude -p` query builder.
2//!
3//! [`QueryCommand`] is the crate's workhorse: a builder for oneshot
4//! print-mode queries covering the full `claude -p` flag surface, with
5//! typed output ([`execute`](QueryCommand::execute) /
6//! [`execute_json`](QueryCommand::execute_json)) and, under the `sync`
7//! feature, blocking peers. Spawn-time flags shared with
8//! [`DuplexOptions`](crate::duplex::DuplexOptions) live in a common
9//! internal `SharedSpawnArgs`, so the two builders cannot drift.
10
11use crate::Claude;
12use crate::command::ClaudeCommand;
13use crate::command::spawn_args::{SharedSpawnArgs, shell_quote};
14#[cfg(any(feature = "async", feature = "sync"))]
15use crate::error::Result;
16use crate::exec::{self, CommandOutput};
17use crate::tool_pattern::ToolPattern;
18use crate::types::{Effort, HermeticScope, InputFormat, OutputFormat, PermissionMode};
19
20/// Builder for `claude -p <prompt>` (oneshot print-mode queries).
21///
22/// This is the primary command for programmatic use. It runs a single
23/// prompt through Claude and returns the result.
24///
25/// # Example
26///
27/// ```no_run
28/// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand, OutputFormat};
29///
30/// # async fn example() -> claude_wrapper::Result<()> {
31/// let claude = Claude::builder().build()?;
32///
33/// let output = QueryCommand::new("explain this error: file not found")
34///     .model("sonnet")
35///     .output_format(OutputFormat::Json)
36///     .max_turns(1)
37///     .execute(&claude)
38///     .await?;
39/// # Ok(())
40/// # }
41/// ```
42#[derive(Debug, Clone)]
43pub struct QueryCommand {
44    prompt: String,
45    // Spawn-time knobs shared with DuplexOptions; the flag emission
46    // lives on SharedSpawnArgs so the two builders cannot drift.
47    shared: SharedSpawnArgs,
48    output_format: Option<OutputFormat>,
49    include_partial_messages: bool,
50    input_format: Option<InputFormat>,
51    retry_policy: Option<crate::retry::RetryPolicy>,
52    brief: bool,
53    from_pr: Option<String>,
54    prompt_via_stdin: bool,
55    verbose: bool,
56    prompt_suggestions: bool,
57    replay_user_messages: bool,
58}
59
60impl QueryCommand {
61    /// Create a new query command with the given prompt.
62    #[must_use]
63    pub fn new(prompt: impl Into<String>) -> Self {
64        Self {
65            prompt: prompt.into(),
66            shared: SharedSpawnArgs::default(),
67            output_format: None,
68            include_partial_messages: false,
69            input_format: None,
70            retry_policy: None,
71            brief: false,
72            from_pr: None,
73            prompt_via_stdin: false,
74            verbose: false,
75            prompt_suggestions: false,
76            replay_user_messages: false,
77        }
78    }
79
80    /// Set the model to use (e.g. "sonnet", "opus", or a full model ID).
81    #[must_use]
82    pub fn model(mut self, model: impl Into<String>) -> Self {
83        self.shared.model = Some(model.into());
84        self
85    }
86
87    /// Set a custom system prompt (replaces the default).
88    #[must_use]
89    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
90        self.shared.system_prompt = Some(prompt.into());
91        self
92    }
93
94    /// Append to the default system prompt.
95    #[must_use]
96    pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
97        self.shared.append_system_prompt = Some(prompt.into());
98        self
99    }
100
101    /// Set the output format.
102    #[must_use]
103    pub fn output_format(mut self, format: OutputFormat) -> Self {
104        self.output_format = Some(format);
105        self
106    }
107
108    /// Set the maximum budget in USD.
109    #[must_use]
110    pub fn max_budget_usd(mut self, budget: f64) -> Self {
111        self.shared.max_budget_usd = Some(budget);
112        self
113    }
114
115    /// Set the permission mode.
116    #[must_use]
117    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
118        self.shared.permission_mode = Some(mode);
119        self
120    }
121
122    /// Add allowed tool patterns.
123    ///
124    /// Accepts anything convertible into [`ToolPattern`], including
125    /// bare strings (e.g. `"Bash"`, `"Bash(git log:*)"`,
126    /// `"mcp__my-server__*"`) and values produced by
127    /// [`ToolPattern`]'s constructors.
128    ///
129    /// ```
130    /// use claude_wrapper::{QueryCommand, ToolPattern};
131    ///
132    /// let cmd = QueryCommand::new("hi")
133    ///     .allowed_tools(["Bash", "Read"]) // raw strings still work
134    ///     .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
135    ///     .allowed_tool(ToolPattern::all("Write"));
136    /// ```
137    #[must_use]
138    pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
139    where
140        I: IntoIterator<Item = T>,
141        T: Into<ToolPattern>,
142    {
143        self.shared
144            .allowed_tools
145            .extend(tools.into_iter().map(Into::into));
146        self
147    }
148
149    /// Add a single allowed tool pattern.
150    #[must_use]
151    pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
152        self.shared.allowed_tools.push(tool.into());
153        self
154    }
155
156    /// Add disallowed tool patterns.
157    #[must_use]
158    pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
159    where
160        I: IntoIterator<Item = T>,
161        T: Into<ToolPattern>,
162    {
163        self.shared
164            .disallowed_tools
165            .extend(tools.into_iter().map(Into::into));
166        self
167    }
168
169    /// Add a single disallowed tool pattern.
170    #[must_use]
171    pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
172        self.shared.disallowed_tools.push(tool.into());
173        self
174    }
175
176    /// Add an MCP config file path.
177    #[must_use]
178    pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
179        self.shared.mcp_config.push(path.into());
180        self
181    }
182
183    /// Add an additional directory for tool access.
184    #[must_use]
185    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
186        self.shared.add_dir.push(dir.into());
187        self
188    }
189
190    /// Set the effort level.
191    #[must_use]
192    pub fn effort(mut self, effort: Effort) -> Self {
193        self.shared.effort = Some(effort);
194        self
195    }
196
197    /// Set the maximum number of turns.
198    #[must_use]
199    pub fn max_turns(mut self, turns: u32) -> Self {
200        self.shared.max_turns = Some(turns);
201        self
202    }
203
204    /// Set a JSON schema for structured output validation.
205    #[must_use]
206    pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
207        self.shared.json_schema = Some(schema.into());
208        self
209    }
210
211    /// Continue the most recent conversation.
212    #[must_use]
213    pub fn continue_session(mut self) -> Self {
214        self.shared.continue_session = true;
215        self
216    }
217
218    /// Resume a specific session by ID.
219    #[must_use]
220    pub fn resume(mut self, session_id: impl Into<String>) -> Self {
221        self.shared.resume = Some(session_id.into());
222        self
223    }
224
225    /// Use a specific session ID.
226    #[must_use]
227    pub fn session_id(mut self, id: impl Into<String>) -> Self {
228        self.shared.session_id = Some(id.into());
229        self
230    }
231
232    /// Clear every session-related flag and set `--resume` to the given id.
233    ///
234    /// Used by `Session::execute` to override whatever session flags the
235    /// caller may have set on their command (including a stale `--resume`,
236    /// `--continue`, `--session-id`, or `--fork-session`). Keeping the
237    /// override logic in one place prevents conflicting flags from reaching
238    /// the CLI.
239    #[cfg(all(feature = "json", feature = "async"))]
240    pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
241        self.shared.continue_session = false;
242        self.shared.resume = Some(id.into());
243        self.shared.session_id = None;
244        self.shared.fork_session = false;
245        self
246    }
247
248    /// Set a fallback model for when the primary model is overloaded.
249    #[must_use]
250    pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
251        self.shared.fallback_model = Some(model.into());
252        self
253    }
254
255    /// Disable session persistence (sessions won't be saved to disk).
256    #[must_use]
257    pub fn no_session_persistence(mut self) -> Self {
258        self.shared.no_session_persistence = true;
259        self
260    }
261
262    /// Bypass all permission checks. Only use in sandboxed environments.
263    #[must_use]
264    pub fn dangerously_skip_permissions(mut self) -> Self {
265        self.shared.dangerously_skip_permissions = true;
266        self
267    }
268
269    /// Pin the session to a named subagent (`--agent <name>`).
270    ///
271    /// `name` is resolved by the CLI in this order: inline
272    /// definitions from [`Self::agents_json`], then user-level
273    /// `~/.claude/agents/<name>.md` files, then project-level dirs
274    /// loaded by the active `--setting-sources`.
275    ///
276    /// **Caveat**: as of Claude Code 2.1.143, the CLI silently
277    /// ignores an unknown `name` and falls back to the default
278    /// behavior -- no warning, no error. Callers that want a hard
279    /// "agent must exist" semantics should validate the name out of
280    /// band (e.g. via [`crate::artifacts::AgentsRoot::get`]) before
281    /// passing it here.
282    #[must_use]
283    pub fn agent(mut self, agent: impl Into<String>) -> Self {
284        self.shared.agent = Some(agent.into());
285        self
286    }
287
288    /// Inline subagent definitions for this session
289    /// (`--agents <json>`).
290    ///
291    /// `json` is a JSON object keyed by agent name, with each value
292    /// carrying at least `description` and `prompt`. Inline
293    /// definitions take precedence over on-disk
294    /// `~/.claude/agents/*.md` of the same name. Pass [`Self::agent`]
295    /// to select which one to use as the session's persona.
296    ///
297    /// Example: `{"reviewer": {"description": "Reviews code",
298    /// "prompt": "You are a code reviewer"}}`.
299    #[must_use]
300    pub fn agents_json(mut self, json: impl Into<String>) -> Self {
301        self.shared.agents_json = Some(json.into());
302        self
303    }
304
305    /// Set the list of available built-in tools.
306    ///
307    /// Use `""` to disable all tools, `"default"` for all tools, or
308    /// specific tool names like `["Bash", "Edit", "Read"]`.
309    /// This is different from `allowed_tools` which controls MCP tool permissions.
310    #[must_use]
311    pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
312        self.shared.tools.extend(tools.into_iter().map(Into::into));
313        self
314    }
315
316    /// Add a file resource to download at startup.
317    ///
318    /// Format: `file_id:relative_path` (e.g. `file_abc:doc.txt`).
319    #[must_use]
320    pub fn file(mut self, spec: impl Into<String>) -> Self {
321        self.shared.file.push(spec.into());
322        self
323    }
324
325    /// Include partial message chunks as they arrive.
326    ///
327    /// Only works with `--output-format stream-json`.
328    #[must_use]
329    pub fn include_partial_messages(mut self) -> Self {
330        self.include_partial_messages = true;
331        self
332    }
333
334    /// Set the input format.
335    #[must_use]
336    pub fn input_format(mut self, format: InputFormat) -> Self {
337        self.input_format = Some(format);
338        self
339    }
340
341    /// Only use MCP servers from `--mcp-config`, ignoring all other MCP configurations.
342    #[must_use]
343    pub fn strict_mcp_config(mut self) -> Self {
344        self.shared.strict_mcp_config = true;
345        self
346    }
347
348    /// Path to a settings JSON file or a JSON string.
349    #[must_use]
350    pub fn settings(mut self, settings: impl Into<String>) -> Self {
351        self.shared.settings = Some(settings.into());
352        self
353    }
354
355    /// When resuming, create a new session ID instead of reusing the original.
356    #[must_use]
357    pub fn fork_session(mut self) -> Self {
358        self.shared.fork_session = true;
359        self
360    }
361
362    /// Create a new git worktree for this session, providing an isolated working directory.
363    #[must_use]
364    pub fn worktree(mut self) -> Self {
365        self.shared.worktree = true;
366        self
367    }
368
369    /// Create a new git worktree with an explicit name, providing an
370    /// isolated working directory.
371    ///
372    /// Equivalent to [`Self::worktree`] but emits `--worktree NAME`,
373    /// pinning the worktree's directory/branch name rather than
374    /// letting the CLI auto-generate one.
375    ///
376    /// # Example
377    ///
378    /// ```no_run
379    /// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand};
380    ///
381    /// # async fn example() -> claude_wrapper::Result<()> {
382    /// let claude = Claude::builder().build()?;
383    ///
384    /// let output = QueryCommand::new("refactor the parser")
385    ///     .worktree_named("parser-refactor")
386    ///     .execute(&claude)
387    ///     .await?;
388    /// # Ok(())
389    /// # }
390    /// ```
391    #[must_use]
392    pub fn worktree_named(mut self, name: impl Into<String>) -> Self {
393        self.shared.worktree = true;
394        self.shared.worktree_name = Some(name.into());
395        self
396    }
397
398    /// Enable brief mode, which activates the SendUserMessage tool for agent-to-user communication.
399    #[must_use]
400    pub fn brief(mut self) -> Self {
401        self.brief = true;
402        self
403    }
404
405    /// Enable debug logging with an optional filter (e.g., "api,hooks").
406    #[must_use]
407    pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
408        self.shared.debug_filter = Some(filter.into());
409        self
410    }
411
412    /// Write debug logs to the specified file path.
413    #[must_use]
414    pub fn debug_file(mut self, path: impl Into<String>) -> Self {
415        self.shared.debug_file = Some(path.into());
416        self
417    }
418
419    /// Beta feature headers for API key authentication.
420    #[must_use]
421    pub fn betas(mut self, betas: impl Into<String>) -> Self {
422        self.shared.betas = Some(betas.into());
423        self
424    }
425
426    /// Load plugins from the specified directory for this session.
427    #[must_use]
428    pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
429        self.shared.plugin_dirs.push(dir.into());
430        self
431    }
432
433    /// Fetch a plugin `.zip` from a URL for this session only
434    /// (`--plugin-url`). Repeatable; the URL-based counterpart to
435    /// [`Self::plugin_dir`].
436    #[must_use]
437    pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
438        self.shared.plugin_urls.push(url.into());
439        self
440    }
441
442    /// Comma-separated list of setting sources to load (e.g., "user,project,local").
443    #[must_use]
444    pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
445        self.shared.setting_sources = Some(sources.into());
446        self
447    }
448
449    /// Seal the ambient `~/.claude` config for a reproducible run
450    /// ([`HermeticScope::Full`]).
451    ///
452    /// Equivalent to `hermetic_scoped(HermeticScope::Full)`: drops all
453    /// ambient setting sources (`--setting-sources ""`), restricts MCP
454    /// to `--mcp-config` servers (`--strict-mcp-config`), and moves
455    /// per-machine sections out of the system prompt
456    /// (`--exclude-dynamic-system-prompt-sections`). Provide everything
457    /// the run needs explicitly via [`Self::append_system_prompt`],
458    /// [`Self::mcp_config`], [`Self::add_dir`], and friends.
459    ///
460    /// This is not [`Self::bare`]: a hermetic seal leaves OAuth and
461    /// keychain auth working, whereas `--bare` forces API-key billing.
462    /// A later [`Self::setting_sources`] call overrides the seal scope.
463    #[must_use]
464    pub fn hermetic(mut self) -> Self {
465        self.shared.apply_hermetic(HermeticScope::Full);
466        self
467    }
468
469    /// Seal the ambient `~/.claude` config at an explicit
470    /// [`HermeticScope`].
471    ///
472    /// See [`Self::hermetic`] for the flag set. Use
473    /// [`HermeticScope::Project`] to keep the user's global `~/.claude`
474    /// while sealing project and local ambient config.
475    #[must_use]
476    pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
477        self.shared.apply_hermetic(scope);
478        self
479    }
480
481    /// Create a tmux session for the worktree.
482    #[must_use]
483    pub fn tmux(mut self) -> Self {
484        self.shared.tmux = true;
485        self
486    }
487
488    /// Run in minimal mode (`--bare`).
489    ///
490    /// Skips hooks, LSP, plugin sync, attribution, auto-memory,
491    /// background prefetches, keychain reads, and CLAUDE.md
492    /// auto-discovery. Sets `CLAUDE_CODE_SIMPLE=1` inside the child.
493    /// Anthropic auth is restricted to `ANTHROPIC_API_KEY` or
494    /// `apiKeyHelper` via `--settings`; OAuth and keychain are never
495    /// read. Third-party providers (Bedrock/Vertex/Foundry) use their
496    /// own credentials as normal.
497    ///
498    /// Intended for headless/CI use where you want deterministic
499    /// context: provide everything explicitly via `--system-prompt`,
500    /// `--append-system-prompt`, `--add-dir`, `--mcp-config`,
501    /// `--settings`, `--agents`, and `--plugin-dir`. Skills still
502    /// resolve via explicit `/skill-name` references.
503    #[must_use]
504    pub fn bare(mut self) -> Self {
505        self.shared.bare = true;
506        self
507    }
508
509    /// Disable all slash-command skills (`--disable-slash-commands`).
510    #[must_use]
511    pub fn disable_slash_commands(mut self) -> Self {
512        self.shared.disable_slash_commands = true;
513        self
514    }
515
516    /// Start with all customizations disabled (`--safe-mode`).
517    ///
518    /// Disables CLAUDE.md, skills, plugins, hooks, MCP servers, custom
519    /// commands and agents, output styles, and other customizations for
520    /// troubleshooting a broken configuration. Admin-managed (policy)
521    /// settings still apply. Auth, model selection, built-in tools, and
522    /// permissions work normally. Sets `CLAUDE_CODE_SAFE_MODE=1` inside
523    /// the child.
524    #[must_use]
525    pub fn safe_mode(mut self) -> Self {
526        self.shared.safe_mode = true;
527        self
528    }
529
530    /// Include every hook lifecycle event in the stream-json output
531    /// (`--include-hook-events`). Only meaningful with
532    /// `OutputFormat::StreamJson`.
533    #[must_use]
534    pub fn include_hook_events(mut self) -> Self {
535        self.shared.include_hook_events = true;
536        self
537    }
538
539    /// Move per-machine sections (cwd, env info, memory paths, git
540    /// status) out of the system prompt and into the first user
541    /// message (`--exclude-dynamic-system-prompt-sections`). Improves
542    /// cross-user prompt-cache reuse. Only applies with the default
543    /// system prompt; ignored with `--system-prompt`.
544    #[must_use]
545    pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
546        self.shared.exclude_dynamic_system_prompt_sections = true;
547        self
548    }
549
550    /// Set a display name for this session (`--name`). Shown in the
551    /// prompt box, `/resume` picker, and terminal title.
552    #[must_use]
553    pub fn name(mut self, name: impl Into<String>) -> Self {
554        self.shared.name = Some(name.into());
555        self
556    }
557
558    /// Resume a session linked to a PR by number or URL
559    /// (`--from-pr <value>`).
560    ///
561    /// This wrapper only supports the valued form; the CLI's
562    /// no-value mode opens an interactive picker and would hang a
563    /// headless caller.
564    #[must_use]
565    pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
566        self.from_pr = Some(pr.into());
567        self
568    }
569
570    /// Enable verbose logging (`--verbose`), overriding the CLI's
571    /// configured verbose-mode setting.
572    ///
573    /// Note: [`OutputFormat::StreamJson`] already forces `--verbose`
574    /// on (the CLI requires it alongside `--print`), so this builder
575    /// only changes behavior for the text and JSON output formats.
576    /// Either path emits the flag at most once.
577    #[must_use]
578    pub fn verbose(mut self, value: bool) -> Self {
579        self.verbose = value;
580        self
581    }
582
583    /// Emit a predicted next-user-prompt after each turn
584    /// (`--prompt-suggestions`).
585    ///
586    /// In print/SDK mode the CLI emits a `prompt_suggestion` message
587    /// alongside the normal result. Off by default.
588    #[must_use]
589    pub fn prompt_suggestions(mut self, value: bool) -> Self {
590        self.prompt_suggestions = value;
591        self
592    }
593
594    /// Re-emit user messages from stdin back on stdout
595    /// (`--replay-user-messages`).
596    ///
597    /// Only meaningful for bidirectional stream-json flows: the CLI
598    /// requires both [`InputFormat::StreamJson`] and
599    /// [`OutputFormat::StreamJson`] for this to take effect. Off by
600    /// default.
601    #[must_use]
602    pub fn replay_user_messages(mut self, value: bool) -> Self {
603        self.replay_user_messages = value;
604        self
605    }
606
607    /// Set a per-command retry policy, overriding the client default.
608    ///
609    /// # Example
610    ///
611    /// ```no_run
612    /// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand, RetryPolicy};
613    /// use std::time::Duration;
614    ///
615    /// # async fn example() -> claude_wrapper::Result<()> {
616    /// let claude = Claude::builder().build()?;
617    ///
618    /// let output = QueryCommand::new("explain quicksort")
619    ///     .retry(RetryPolicy::new()
620    ///         .max_attempts(5)
621    ///         .initial_backoff(Duration::from_secs(2))
622    ///         .exponential()
623    ///         .retry_on_timeout(true))
624    ///     .execute(&claude)
625    ///     .await?;
626    /// # Ok(())
627    /// # }
628    /// ```
629    #[must_use]
630    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
631        self.retry_policy = Some(policy);
632        self
633    }
634
635    /// Return the full command as a string that could be run in a shell.
636    ///
637    /// Constructs a command string using the binary path from the Claude instance,
638    /// the client's global args, and the arguments from this query -- the same
639    /// assembly the exec path performs, so the preview matches what actually
640    /// runs. Arguments containing spaces or special shell characters are
641    /// shell-quoted to be safe for shell execution.
642    ///
643    /// # Example
644    ///
645    /// ```no_run
646    /// use claude_wrapper::{Claude, QueryCommand};
647    ///
648    /// # async fn example() -> claude_wrapper::Result<()> {
649    /// let claude = Claude::builder().build()?;
650    ///
651    /// let cmd = QueryCommand::new("explain quicksort")
652    ///     .model("sonnet");
653    ///
654    /// let command_str = cmd.to_command_string(&claude);
655    /// println!("Would run: {}", command_str);
656    /// # Ok(())
657    /// # }
658    /// ```
659    pub fn to_command_string(&self, claude: &Claude) -> String {
660        let args = exec::full_command_args(claude, self.build_args());
661        let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
662        format!("{} {}", claude.binary().display(), quoted_args.join(" "))
663    }
664
665    /// Execute the query with an explicit cancellation signal.
666    ///
667    /// Cancellation and the client timeout terminate the owned process group
668    /// and reap the direct child before returning. Retry does not apply.
669    #[cfg(feature = "async")]
670    pub async fn execute_cancellable<C>(&self, claude: &Claude, cancel: C) -> Result<CommandOutput>
671    where
672        C: std::future::Future<Output = ()> + Send,
673    {
674        if self.prompt_via_stdin {
675            exec::run_claude_with_stdin_prompt_cancellable(
676                claude,
677                self.build_args(),
678                self.prompt.clone(),
679                cancel,
680            )
681            .await
682        } else {
683            exec::run_claude_cancellable(claude, self.args(), cancel).await
684        }
685    }
686
687    /// Execute the query and parse the JSON result.
688    ///
689    /// This is a convenience method that sets `OutputFormat::Json` and
690    /// deserializes the response into a [`QueryResult`](crate::types::QueryResult).
691    #[cfg(all(feature = "json", feature = "async"))]
692    pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
693        let args = self.build_args_with_forced_json();
694
695        let output = if self.prompt_via_stdin {
696            // Retry is skipped for stdin mode: the stdin pipe is consumed
697            // after the first attempt and cannot be rewound.
698            exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await?
699        } else {
700            exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?
701        };
702
703        serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
704            message: format!("failed to parse query result: {e}"),
705            source: e,
706        })
707    }
708
709    /// Execute the query cancellably and parse the JSON result.
710    ///
711    /// Process cleanup is complete before a cancellation, timeout, or stdin
712    /// communication error is returned.
713    #[cfg(all(feature = "json", feature = "async"))]
714    pub async fn execute_json_cancellable<C>(
715        &self,
716        claude: &Claude,
717        cancel: C,
718    ) -> Result<crate::types::QueryResult>
719    where
720        C: std::future::Future<Output = ()> + Send,
721    {
722        let args = self.build_args_with_forced_json();
723        let output = if self.prompt_via_stdin {
724            exec::run_claude_with_stdin_prompt_cancellable(
725                claude,
726                args,
727                self.prompt.clone(),
728                cancel,
729            )
730            .await?
731        } else {
732            exec::run_claude_cancellable(claude, args, cancel).await?
733        };
734
735        serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
736            message: format!("failed to parse query result: {e}"),
737            source: e,
738        })
739    }
740
741    /// Blocking analog of [`QueryCommand::execute`] that honours the
742    /// configured [`RetryPolicy`](crate::retry::RetryPolicy).
743    ///
744    /// Overrides the blanket
745    /// [`ClaudeCommandSyncExt::execute_sync`](crate::ClaudeCommandSyncExt)
746    /// impl so retries still fire on the sync path.
747    #[cfg(feature = "sync")]
748    pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
749        if self.prompt_via_stdin {
750            // Retry is skipped for stdin mode: the stdin pipe is consumed
751            // after the first attempt and cannot be rewound.
752            exec::run_claude_with_stdin_prompt_sync(claude, self.build_args(), self.prompt.clone())
753        } else {
754            exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
755        }
756    }
757
758    /// Blocking mirror of [`QueryCommand::execute_json`].
759    #[cfg(all(feature = "sync", feature = "json"))]
760    pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
761        let args = self.build_args_with_forced_json();
762
763        let output = if self.prompt_via_stdin {
764            // Retry is skipped for stdin mode: the stdin pipe is consumed
765            // after the first attempt and cannot be rewound.
766            exec::run_claude_with_stdin_prompt_sync(claude, args, self.prompt.clone())?
767        } else {
768            exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?
769        };
770
771        serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
772            message: format!("failed to parse query result: {e}"),
773            source: e,
774        })
775    }
776
777    /// Route the prompt through stdin rather than argv.
778    ///
779    /// When set, the prompt body does not appear in the spawned
780    /// process's argument list (`ps`, `/proc/PID/cmdline`, APM
781    /// agents). Use this for any prompt that contains sensitive
782    /// content: private code, internal design notes, orchestrator
783    /// dispatch specs.
784    ///
785    /// Requires that `claude --print` read from stdin when no
786    /// positional prompt is supplied (verified as of claude 2.1.x).
787    ///
788    /// Note: retry is skipped when stdin mode is active -- the stdin
789    /// pipe is consumed after the first attempt and cannot be rewound.
790    ///
791    /// # Example
792    /// ```no_run
793    /// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand};
794    /// # async fn example() -> claude_wrapper::Result<()> {
795    /// let claude = Claude::builder().build()?;
796    /// let out = QueryCommand::new("my secret prompt")
797    ///     .prompt_via_stdin(true)
798    ///     .execute(&claude)
799    ///     .await?;
800    /// # Ok(()) }
801    /// ```
802    #[must_use]
803    pub fn prompt_via_stdin(mut self, value: bool) -> Self {
804        self.prompt_via_stdin = value;
805        self
806    }
807
808    #[cfg(all(feature = "json", feature = "async"))]
809    pub(crate) fn stdin_prompt(&self) -> Option<&str> {
810        self.prompt_via_stdin.then_some(self.prompt.as_str())
811    }
812
813    /// Like [`Self::build_args`], but if `output_format` is unset on
814    /// this command, force it to `json`. The naive approach -- call
815    /// `build_args` then `args.push("--output-format")` -- breaks
816    /// because `build_args` already appended `--` and the prompt at
817    /// the end, so the late flag becomes positional and is eaten as
818    /// part of the prompt. We clone-and-set instead so the flag
819    /// lands in its proper slot before `--`.
820    #[allow(dead_code)] // unused without `json` plus `async` or `sync`; tests always call it
821    fn build_args_with_forced_json(&self) -> Vec<String> {
822        if self.output_format.is_some() {
823            return self.build_args();
824        }
825        let mut effective = self.clone();
826        effective.output_format = Some(OutputFormat::Json);
827        effective.build_args()
828    }
829
830    fn build_args(&self) -> Vec<String> {
831        let mut args = vec!["--print".to_string()];
832
833        if let Some(ref format) = self.output_format {
834            args.push("--output-format".to_string());
835            args.push(format.as_arg().to_string());
836        }
837
838        // --verbose: explicit opt-in via `.verbose(true)`, or forced
839        // for stream-json (CLI v2.1.72+ requires it with --print).
840        // Emitted once so the two paths can't double up the flag.
841        if self.verbose || matches!(self.output_format, Some(OutputFormat::StreamJson)) {
842            args.push("--verbose".to_string());
843        }
844
845        self.shared.append_to(&mut args);
846
847        if self.include_partial_messages {
848            args.push("--include-partial-messages".to_string());
849        }
850
851        if let Some(ref format) = self.input_format {
852            args.push("--input-format".to_string());
853            args.push(format.as_arg().to_string());
854        }
855
856        if self.brief {
857            args.push("--brief".to_string());
858        }
859
860        if self.prompt_suggestions {
861            args.push("--prompt-suggestions".to_string());
862        }
863
864        if self.replay_user_messages {
865            args.push("--replay-user-messages".to_string());
866        }
867
868        if let Some(ref pr) = self.from_pr {
869            args.push("--from-pr".to_string());
870            args.push(pr.clone());
871        }
872
873        // Separator to prevent flags like --allowed-tools from consuming the prompt.
874        // When prompt_via_stdin is set, the prompt is sent via stdin after spawn
875        // rather than appearing in argv (avoids ps/APM/crash-dump leakage).
876        if !self.prompt_via_stdin {
877            args.push("--".to_string());
878            args.push(self.prompt.clone());
879        }
880
881        args
882    }
883}
884
885impl ClaudeCommand for QueryCommand {
886    type Output = CommandOutput;
887
888    fn args(&self) -> Vec<String> {
889        self.build_args()
890    }
891
892    #[cfg(feature = "async")]
893    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
894        if self.prompt_via_stdin {
895            // Retry is skipped for stdin mode: the stdin pipe is consumed
896            // after the first attempt and cannot be rewound.
897            let args = self.build_args(); // prompt not in args
898            exec::run_claude_with_stdin_prompt(claude, args, self.prompt.clone()).await
899        } else {
900            exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
901        }
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    #[test]
910    fn test_basic_query_args() {
911        let cmd = QueryCommand::new("hello world");
912        let args = cmd.args();
913        assert_eq!(args, vec!["--print", "--", "hello world"]);
914    }
915
916    #[test]
917    fn prompt_via_stdin_omits_prompt_from_args() {
918        let cmd = QueryCommand::new("secret payload").prompt_via_stdin(true);
919        let args = cmd.args();
920        assert!(
921            !args.contains(&"secret payload".to_string()),
922            "prompt must not appear in args when prompt_via_stdin is set"
923        );
924        assert!(
925            !args.contains(&"--".to_string()),
926            "-- separator must be absent when prompt_via_stdin is set"
927        );
928    }
929
930    #[test]
931    fn prompt_via_stdin_false_keeps_prompt_in_args() {
932        let cmd = QueryCommand::new("visible prompt").prompt_via_stdin(false);
933        let args = cmd.args();
934        assert!(
935            args.contains(&"visible prompt".to_string()),
936            "prompt must still appear in args when prompt_via_stdin is false"
937        );
938        assert!(
939            args.contains(&"--".to_string()),
940            "-- separator must be present when prompt_via_stdin is false"
941        );
942    }
943
944    #[test]
945    #[cfg(feature = "async")] // uses tokio + the async-only execute()
946    #[ignore = "requires a real claude binary"]
947    fn prompt_via_stdin_integration() {
948        // Verify round-trip: prompt sent via stdin produces a valid response.
949        // Run with: cargo test --lib -p claude-wrapper -- --ignored prompt_via_stdin_integration
950        use crate::{Claude, ClaudeCommand};
951        let rt = tokio::runtime::Runtime::new().unwrap();
952        rt.block_on(async {
953            let claude = Claude::builder().build().unwrap();
954            let out = QueryCommand::new("reply with: STDIN_OK")
955                .prompt_via_stdin(true)
956                .execute(&claude)
957                .await
958                .unwrap();
959            assert!(
960                !out.stdout.is_empty(),
961                "expected non-empty output from stdin-mode query"
962            );
963        });
964    }
965
966    #[test]
967    fn build_args_with_forced_json_inserts_flag_before_separator() {
968        // Regression: prior to this fix, execute_json appended
969        // --output-format json AFTER build_args's `-- prompt` tail,
970        // so the flag was treated as positional and eaten as part
971        // of the prompt. With the fix the flag must land BEFORE the
972        // `--` separator.
973        let cmd = QueryCommand::new("hello");
974        let args = cmd.build_args_with_forced_json();
975
976        // The trailing pair must still be the separator + prompt.
977        assert_eq!(
978            &args[args.len() - 2..],
979            &["--".to_string(), "hello".to_string()],
980        );
981
982        // --output-format json must appear BEFORE `--`.
983        let sep = args.iter().position(|a| a == "--").expect("`--` present");
984        let fmt = args
985            .iter()
986            .position(|a| a == "--output-format")
987            .expect("--output-format present");
988        assert!(
989            fmt < sep,
990            "--output-format must come before `--` separator; got {args:?}"
991        );
992        assert_eq!(args[fmt + 1], "json");
993    }
994
995    #[test]
996    fn build_args_with_forced_json_respects_explicit_format() {
997        // If the caller already set output_format on the builder,
998        // the helper must NOT override it.
999        let cmd = QueryCommand::new("hello").output_format(OutputFormat::Text);
1000        let args = cmd.build_args_with_forced_json();
1001        let fmt = args
1002            .iter()
1003            .position(|a| a == "--output-format")
1004            .expect("--output-format present");
1005        assert_eq!(args[fmt + 1], "text");
1006        // Just one occurrence -- not double-pushed.
1007        assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
1008    }
1009
1010    #[test]
1011    #[allow(deprecated)] // exercises PermissionMode::BypassPermissions directly; prefer dangerous::DangerousClient in new code
1012    fn test_full_query_args() {
1013        let cmd = QueryCommand::new("explain this")
1014            .model("sonnet")
1015            .system_prompt("be concise")
1016            .output_format(OutputFormat::Json)
1017            .max_budget_usd(0.50)
1018            .permission_mode(PermissionMode::BypassPermissions)
1019            .allowed_tools(["Bash", "Read"])
1020            .mcp_config("/tmp/mcp.json")
1021            .effort(Effort::High)
1022            .max_turns(3)
1023            .no_session_persistence();
1024
1025        let args = cmd.args();
1026        assert!(args.contains(&"--print".to_string()));
1027        assert!(args.contains(&"--model".to_string()));
1028        assert!(args.contains(&"sonnet".to_string()));
1029        assert!(args.contains(&"--system-prompt".to_string()));
1030        assert!(args.contains(&"--output-format".to_string()));
1031        assert!(args.contains(&"json".to_string()));
1032        // json format should NOT include --verbose (only stream-json needs it)
1033        assert!(!args.contains(&"--verbose".to_string()));
1034        assert!(args.contains(&"--max-budget-usd".to_string()));
1035        assert!(args.contains(&"--permission-mode".to_string()));
1036        assert!(args.contains(&"bypassPermissions".to_string()));
1037        assert!(args.contains(&"--allowed-tools".to_string()));
1038        assert!(args.contains(&"Bash,Read".to_string()));
1039        assert!(args.contains(&"--effort".to_string()));
1040        assert!(args.contains(&"high".to_string()));
1041        assert!(args.contains(&"--max-turns".to_string()));
1042        assert!(args.contains(&"--no-session-persistence".to_string()));
1043        // Prompt is last, preceded by -- separator
1044        assert_eq!(args.last().unwrap(), "explain this");
1045        assert_eq!(args[args.len() - 2], "--");
1046    }
1047
1048    #[test]
1049    fn typed_patterns_render_in_allowed_tools() {
1050        use crate::ToolPattern;
1051
1052        let cmd = QueryCommand::new("hi")
1053            .allowed_tool(ToolPattern::tool("Read"))
1054            .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
1055            .allowed_tool(ToolPattern::all("Write"))
1056            .allowed_tool(ToolPattern::mcp("srv", "*"));
1057
1058        let args = cmd.args();
1059        let joined = args
1060            .iter()
1061            .position(|a| a == "--allowed-tools")
1062            .map(|i| &args[i + 1])
1063            .unwrap();
1064        assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
1065    }
1066
1067    #[test]
1068    fn disallowed_tool_singular_appends() {
1069        use crate::ToolPattern;
1070
1071        let cmd = QueryCommand::new("hi")
1072            .disallowed_tool("Write")
1073            .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
1074
1075        let args = cmd.args();
1076        let joined = args
1077            .iter()
1078            .position(|a| a == "--disallowed-tools")
1079            .map(|i| &args[i + 1])
1080            .unwrap();
1081        assert_eq!(joined, "Write,Bash(rm*)");
1082    }
1083
1084    #[test]
1085    fn mixed_string_and_typed_patterns_both_accepted() {
1086        use crate::ToolPattern;
1087
1088        // Smoke test for API ergonomics: one plural call with mixed
1089        // inputs should compile even though the builder is generic
1090        // over T: Into<ToolPattern>.
1091        let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
1092        let cmd = QueryCommand::new("hi").allowed_tools(strs);
1093        assert!(cmd.args().contains(&"--allowed-tools".to_string()));
1094    }
1095
1096    #[test]
1097    fn new_bool_flags_emit_correct_cli_args() {
1098        let args = QueryCommand::new("hi")
1099            .bare()
1100            .disable_slash_commands()
1101            .include_hook_events()
1102            .exclude_dynamic_system_prompt_sections()
1103            .args();
1104        assert!(args.contains(&"--bare".to_string()));
1105        assert!(args.contains(&"--disable-slash-commands".to_string()));
1106        assert!(args.contains(&"--include-hook-events".to_string()));
1107        assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1108    }
1109
1110    #[test]
1111    fn name_flag_renders_with_value() {
1112        let args = QueryCommand::new("hi").name("my session").args();
1113        let pos = args.iter().position(|a| a == "--name").unwrap();
1114        assert_eq!(args[pos + 1], "my session");
1115    }
1116
1117    #[test]
1118    fn from_pr_flag_renders_with_value() {
1119        let args = QueryCommand::new("hi").from_pr("42").args();
1120        let pos = args.iter().position(|a| a == "--from-pr").unwrap();
1121        assert_eq!(args[pos + 1], "42");
1122    }
1123
1124    #[test]
1125    fn new_bool_flags_default_to_off() {
1126        let args = QueryCommand::new("hi").args();
1127        assert!(!args.contains(&"--bare".to_string()));
1128        assert!(!args.contains(&"--disable-slash-commands".to_string()));
1129        assert!(!args.contains(&"--include-hook-events".to_string()));
1130        assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1131        assert!(!args.contains(&"--name".to_string()));
1132    }
1133
1134    #[test]
1135    fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1136        // Regression: --allowed-tools was consuming the prompt as a tool name
1137        // when the prompt appeared after it without a -- separator.
1138        let cmd = QueryCommand::new("fix the bug")
1139            .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1140            .output_format(OutputFormat::StreamJson);
1141        let args = cmd.args();
1142        // -- separator must appear before the prompt
1143        let sep_pos = args.iter().position(|a| a == "--").unwrap();
1144        let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1145        assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1146        // --allowed-tools value must appear before the separator
1147        let tools_pos = args
1148            .iter()
1149            .position(|a| a.contains("Bash(cargo *)"))
1150            .unwrap();
1151        assert!(
1152            tools_pos < sep_pos,
1153            "allowed-tools must come before -- separator"
1154        );
1155    }
1156
1157    #[test]
1158    fn test_stream_json_includes_verbose() {
1159        let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1160        let args = cmd.args();
1161        assert!(args.contains(&"--output-format".to_string()));
1162        assert!(args.contains(&"stream-json".to_string()));
1163        assert!(args.contains(&"--verbose".to_string()));
1164    }
1165
1166    #[test]
1167    fn verbose_flag_emitted_when_set() {
1168        let args = QueryCommand::new("test").verbose(true).args();
1169        assert!(args.contains(&"--verbose".to_string()));
1170    }
1171
1172    #[test]
1173    fn verbose_absent_by_default_and_when_false() {
1174        assert!(
1175            !QueryCommand::new("test")
1176                .args()
1177                .contains(&"--verbose".to_string())
1178        );
1179        assert!(
1180            !QueryCommand::new("test")
1181                .verbose(false)
1182                .args()
1183                .contains(&"--verbose".to_string())
1184        );
1185    }
1186
1187    #[test]
1188    fn verbose_not_duplicated_with_stream_json() {
1189        // stream-json forces --verbose; an explicit .verbose(true) must
1190        // not push a second copy.
1191        let cmd = QueryCommand::new("test")
1192            .verbose(true)
1193            .output_format(OutputFormat::StreamJson);
1194        let count = cmd.args().iter().filter(|a| *a == "--verbose").count();
1195        assert_eq!(count, 1, "--verbose must appear exactly once");
1196    }
1197
1198    #[test]
1199    fn prompt_suggestions_flag_emitted_when_set() {
1200        let args = QueryCommand::new("test").prompt_suggestions(true).args();
1201        assert!(args.contains(&"--prompt-suggestions".to_string()));
1202        // Must land before the `--` separator, not be eaten as part of
1203        // the prompt.
1204        let sep = args.iter().position(|a| a == "--").unwrap();
1205        let flag = args
1206            .iter()
1207            .position(|a| a == "--prompt-suggestions")
1208            .unwrap();
1209        assert!(flag < sep, "--prompt-suggestions must precede `--`");
1210    }
1211
1212    #[test]
1213    fn prompt_suggestions_absent_by_default_and_when_false() {
1214        assert!(
1215            !QueryCommand::new("test")
1216                .args()
1217                .contains(&"--prompt-suggestions".to_string())
1218        );
1219        assert!(
1220            !QueryCommand::new("test")
1221                .prompt_suggestions(false)
1222                .args()
1223                .contains(&"--prompt-suggestions".to_string())
1224        );
1225    }
1226
1227    #[test]
1228    fn replay_user_messages_flag_emitted_when_set() {
1229        let args = QueryCommand::new("test").replay_user_messages(true).args();
1230        assert!(args.contains(&"--replay-user-messages".to_string()));
1231    }
1232
1233    #[test]
1234    fn replay_user_messages_absent_by_default_and_when_false() {
1235        assert!(
1236            !QueryCommand::new("test")
1237                .args()
1238                .contains(&"--replay-user-messages".to_string())
1239        );
1240        assert!(
1241            !QueryCommand::new("test")
1242                .replay_user_messages(false)
1243                .args()
1244                .contains(&"--replay-user-messages".to_string())
1245        );
1246    }
1247
1248    #[test]
1249    fn test_to_command_string_simple() {
1250        let claude = Claude::builder()
1251            .binary("/usr/local/bin/claude")
1252            .build()
1253            .unwrap();
1254
1255        let cmd = QueryCommand::new("hello");
1256        let command_str = cmd.to_command_string(&claude);
1257
1258        assert!(command_str.starts_with("/usr/local/bin/claude"));
1259        assert!(command_str.contains("--print"));
1260        assert!(command_str.contains("hello"));
1261    }
1262
1263    #[test]
1264    fn test_to_command_string_with_spaces() {
1265        let claude = Claude::builder()
1266            .binary("/usr/local/bin/claude")
1267            .build()
1268            .unwrap();
1269
1270        let cmd = QueryCommand::new("hello world").model("sonnet");
1271        let command_str = cmd.to_command_string(&claude);
1272
1273        assert!(command_str.starts_with("/usr/local/bin/claude"));
1274        assert!(command_str.contains("--print"));
1275        // Prompt with spaces should be quoted
1276        assert!(command_str.contains("'hello world'"));
1277        assert!(command_str.contains("--model"));
1278        assert!(command_str.contains("sonnet"));
1279    }
1280
1281    #[test]
1282    fn test_to_command_string_with_special_chars() {
1283        let claude = Claude::builder()
1284            .binary("/usr/local/bin/claude")
1285            .build()
1286            .unwrap();
1287
1288        let cmd = QueryCommand::new("test $VAR and `cmd`");
1289        let command_str = cmd.to_command_string(&claude);
1290
1291        // Arguments with special shell characters should be quoted
1292        assert!(command_str.contains("'test $VAR and `cmd`'"));
1293    }
1294
1295    #[test]
1296    fn test_to_command_string_with_single_quotes() {
1297        let claude = Claude::builder()
1298            .binary("/usr/local/bin/claude")
1299            .build()
1300            .unwrap();
1301
1302        let cmd = QueryCommand::new("it's");
1303        let command_str = cmd.to_command_string(&claude);
1304
1305        // Single quotes should be escaped in shell
1306        assert!(command_str.contains("'it'\\''s'"));
1307    }
1308
1309    #[test]
1310    fn to_command_string_includes_global_args() {
1311        // The exec path prepends the client's global args; the preview
1312        // must show them too (#705).
1313        let claude = Claude::builder()
1314            .binary("/usr/local/bin/claude")
1315            .arg("--debug")
1316            .build()
1317            .unwrap();
1318
1319        let command_str = QueryCommand::new("hello").to_command_string(&claude);
1320
1321        assert!(
1322            command_str.starts_with("/usr/local/bin/claude --debug --print"),
1323            "global args must precede command args; got {command_str}"
1324        );
1325    }
1326
1327    #[test]
1328    fn test_worktree_flag() {
1329        let cmd = QueryCommand::new("test").worktree();
1330        let args = cmd.args();
1331        assert!(args.contains(&"--worktree".to_string()));
1332    }
1333
1334    #[test]
1335    fn test_worktree_named() {
1336        let cmd = QueryCommand::new("test").worktree_named("feature-x");
1337        let args = cmd.args();
1338        assert!(
1339            args.windows(2).any(|w| w == ["--worktree", "feature-x"]),
1340            "missing --worktree feature-x in {args:?}"
1341        );
1342    }
1343
1344    #[test]
1345    fn test_brief_flag() {
1346        let cmd = QueryCommand::new("test").brief();
1347        let args = cmd.args();
1348        assert!(args.contains(&"--brief".to_string()));
1349    }
1350
1351    #[test]
1352    fn test_debug_filter() {
1353        let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1354        let args = cmd.args();
1355        assert!(args.contains(&"--debug".to_string()));
1356        assert!(args.contains(&"api,hooks".to_string()));
1357    }
1358
1359    #[test]
1360    fn test_debug_file() {
1361        let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1362        let args = cmd.args();
1363        assert!(args.contains(&"--debug-file".to_string()));
1364        assert!(args.contains(&"/tmp/debug.log".to_string()));
1365    }
1366
1367    #[test]
1368    fn test_betas() {
1369        let cmd = QueryCommand::new("test").betas("feature-x");
1370        let args = cmd.args();
1371        assert!(args.contains(&"--betas".to_string()));
1372        assert!(args.contains(&"feature-x".to_string()));
1373    }
1374
1375    #[test]
1376    fn test_plugin_dir_single() {
1377        let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1378        let args = cmd.args();
1379        assert!(args.contains(&"--plugin-dir".to_string()));
1380        assert!(args.contains(&"/plugins/foo".to_string()));
1381    }
1382
1383    #[test]
1384    fn test_plugin_dir_multiple() {
1385        let cmd = QueryCommand::new("test")
1386            .plugin_dir("/plugins/foo")
1387            .plugin_dir("/plugins/bar");
1388        let args = cmd.args();
1389        let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1390        assert_eq!(plugin_dir_count, 2);
1391        assert!(args.contains(&"/plugins/foo".to_string()));
1392        assert!(args.contains(&"/plugins/bar".to_string()));
1393    }
1394
1395    #[test]
1396    fn test_plugin_url_single() {
1397        let cmd = QueryCommand::new("test").plugin_url("https://example.com/p.zip");
1398        let args = cmd.args();
1399        assert!(args.contains(&"--plugin-url".to_string()));
1400        assert!(args.contains(&"https://example.com/p.zip".to_string()));
1401    }
1402
1403    #[test]
1404    fn test_plugin_url_multiple() {
1405        let cmd = QueryCommand::new("test")
1406            .plugin_url("https://example.com/a.zip")
1407            .plugin_url("https://example.com/b.zip");
1408        let args = cmd.args();
1409        let plugin_url_count = args.iter().filter(|a| *a == "--plugin-url").count();
1410        assert_eq!(plugin_url_count, 2);
1411        assert!(args.contains(&"https://example.com/a.zip".to_string()));
1412        assert!(args.contains(&"https://example.com/b.zip".to_string()));
1413    }
1414
1415    #[test]
1416    fn test_safe_mode_flag() {
1417        let cmd = QueryCommand::new("test").safe_mode();
1418        let args = cmd.args();
1419        assert!(args.contains(&"--safe-mode".to_string()));
1420    }
1421
1422    #[test]
1423    fn test_safe_mode_absent_by_default() {
1424        let cmd = QueryCommand::new("test");
1425        let args = cmd.args();
1426        assert!(!args.contains(&"--safe-mode".to_string()));
1427    }
1428
1429    #[test]
1430    fn hermetic_emits_full_seal_flags() {
1431        let args = QueryCommand::new("test").hermetic().args();
1432        assert!(
1433            args.windows(2)
1434                .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
1435            "got {args:?}"
1436        );
1437        assert!(args.contains(&"--strict-mcp-config".to_string()));
1438        assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1439        assert!(!args.contains(&"--bare".to_string()));
1440    }
1441
1442    #[test]
1443    fn hermetic_scoped_project_keeps_user() {
1444        let args = QueryCommand::new("test")
1445            .hermetic_scoped(HermeticScope::Project)
1446            .args();
1447        assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
1448        assert!(args.contains(&"--strict-mcp-config".to_string()));
1449    }
1450
1451    #[test]
1452    fn setting_sources_overrides_hermetic_scope() {
1453        // The escape hatch: a later setting_sources call wins over the
1454        // scope the hermetic preset chose.
1455        let args = QueryCommand::new("test")
1456            .hermetic()
1457            .setting_sources("user,project")
1458            .args();
1459        assert!(
1460            args.windows(2)
1461                .any(|w| w == ["--setting-sources", "user,project"]),
1462            "got {args:?}"
1463        );
1464        assert_eq!(
1465            args.iter().filter(|a| *a == "--setting-sources").count(),
1466            1,
1467            "--setting-sources must not be duplicated"
1468        );
1469    }
1470
1471    #[test]
1472    fn test_setting_sources() {
1473        let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1474        let args = cmd.args();
1475        assert!(args.contains(&"--setting-sources".to_string()));
1476        assert!(args.contains(&"user,project,local".to_string()));
1477    }
1478
1479    #[test]
1480    fn test_tmux_flag() {
1481        let cmd = QueryCommand::new("test").tmux();
1482        let args = cmd.args();
1483        assert!(args.contains(&"--tmux".to_string()));
1484    }
1485}