Skip to main content

claude_wrapper/command/
query.rs

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