Skip to main content

claude_wrapper/command/
query.rs

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