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