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