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