Skip to main content

claude_wrapper/command/
query.rs

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