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