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    /// Set the agent for the session.
317    #[must_use]
318    pub fn agent(mut self, agent: impl Into<String>) -> Self {
319        self.agent = Some(agent.into());
320        self
321    }
322
323    /// Set custom agents as a JSON object.
324    ///
325    /// Example: `{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}`
326    #[must_use]
327    pub fn agents_json(mut self, json: impl Into<String>) -> Self {
328        self.agents_json = Some(json.into());
329        self
330    }
331
332    /// Set the list of available built-in tools.
333    ///
334    /// Use `""` to disable all tools, `"default"` for all tools, or
335    /// specific tool names like `["Bash", "Edit", "Read"]`.
336    /// This is different from `allowed_tools` which controls MCP tool permissions.
337    #[must_use]
338    pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
339        self.tools.extend(tools.into_iter().map(Into::into));
340        self
341    }
342
343    /// Add a file resource to download at startup.
344    ///
345    /// Format: `file_id:relative_path` (e.g. `file_abc:doc.txt`).
346    #[must_use]
347    pub fn file(mut self, spec: impl Into<String>) -> Self {
348        self.file.push(spec.into());
349        self
350    }
351
352    /// Include partial message chunks as they arrive.
353    ///
354    /// Only works with `--output-format stream-json`.
355    #[must_use]
356    pub fn include_partial_messages(mut self) -> Self {
357        self.include_partial_messages = true;
358        self
359    }
360
361    /// Set the input format.
362    #[must_use]
363    pub fn input_format(mut self, format: InputFormat) -> Self {
364        self.input_format = Some(format);
365        self
366    }
367
368    /// Only use MCP servers from `--mcp-config`, ignoring all other MCP configurations.
369    #[must_use]
370    pub fn strict_mcp_config(mut self) -> Self {
371        self.strict_mcp_config = true;
372        self
373    }
374
375    /// Path to a settings JSON file or a JSON string.
376    #[must_use]
377    pub fn settings(mut self, settings: impl Into<String>) -> Self {
378        self.settings = Some(settings.into());
379        self
380    }
381
382    /// When resuming, create a new session ID instead of reusing the original.
383    #[must_use]
384    pub fn fork_session(mut self) -> Self {
385        self.fork_session = true;
386        self
387    }
388
389    /// Create a new git worktree for this session, providing an isolated working directory.
390    #[must_use]
391    pub fn worktree(mut self) -> Self {
392        self.worktree = true;
393        self
394    }
395
396    /// Enable brief mode, which activates the SendUserMessage tool for agent-to-user communication.
397    #[must_use]
398    pub fn brief(mut self) -> Self {
399        self.brief = true;
400        self
401    }
402
403    /// Enable debug logging with an optional filter (e.g., "api,hooks").
404    #[must_use]
405    pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
406        self.debug_filter = Some(filter.into());
407        self
408    }
409
410    /// Write debug logs to the specified file path.
411    #[must_use]
412    pub fn debug_file(mut self, path: impl Into<String>) -> Self {
413        self.debug_file = Some(path.into());
414        self
415    }
416
417    /// Beta feature headers for API key authentication.
418    #[must_use]
419    pub fn betas(mut self, betas: impl Into<String>) -> Self {
420        self.betas = Some(betas.into());
421        self
422    }
423
424    /// Load plugins from the specified directory for this session.
425    #[must_use]
426    pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
427        self.plugin_dirs.push(dir.into());
428        self
429    }
430
431    /// Comma-separated list of setting sources to load (e.g., "user,project,local").
432    #[must_use]
433    pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
434        self.setting_sources = Some(sources.into());
435        self
436    }
437
438    /// Create a tmux session for the worktree.
439    #[must_use]
440    pub fn tmux(mut self) -> Self {
441        self.tmux = true;
442        self
443    }
444
445    /// Run in minimal mode (`--bare`).
446    ///
447    /// Skips hooks, LSP, plugin sync, attribution, auto-memory,
448    /// background prefetches, keychain reads, and CLAUDE.md
449    /// auto-discovery. Sets `CLAUDE_CODE_SIMPLE=1` inside the child.
450    /// Anthropic auth is restricted to `ANTHROPIC_API_KEY` or
451    /// `apiKeyHelper` via `--settings`; OAuth and keychain are never
452    /// read. Third-party providers (Bedrock/Vertex/Foundry) use their
453    /// own credentials as normal.
454    ///
455    /// Intended for headless/CI use where you want deterministic
456    /// context: provide everything explicitly via `--system-prompt`,
457    /// `--append-system-prompt`, `--add-dir`, `--mcp-config`,
458    /// `--settings`, `--agents`, and `--plugin-dir`. Skills still
459    /// resolve via explicit `/skill-name` references.
460    #[must_use]
461    pub fn bare(mut self) -> Self {
462        self.bare = true;
463        self
464    }
465
466    /// Disable all slash-command skills (`--disable-slash-commands`).
467    #[must_use]
468    pub fn disable_slash_commands(mut self) -> Self {
469        self.disable_slash_commands = true;
470        self
471    }
472
473    /// Include every hook lifecycle event in the stream-json output
474    /// (`--include-hook-events`). Only meaningful with
475    /// `OutputFormat::StreamJson`.
476    #[must_use]
477    pub fn include_hook_events(mut self) -> Self {
478        self.include_hook_events = true;
479        self
480    }
481
482    /// Move per-machine sections (cwd, env info, memory paths, git
483    /// status) out of the system prompt and into the first user
484    /// message (`--exclude-dynamic-system-prompt-sections`). Improves
485    /// cross-user prompt-cache reuse. Only applies with the default
486    /// system prompt; ignored with `--system-prompt`.
487    #[must_use]
488    pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
489        self.exclude_dynamic_system_prompt_sections = true;
490        self
491    }
492
493    /// Set a display name for this session (`--name`). Shown in the
494    /// prompt box, `/resume` picker, and terminal title.
495    #[must_use]
496    pub fn name(mut self, name: impl Into<String>) -> Self {
497        self.name = Some(name.into());
498        self
499    }
500
501    /// Resume a session linked to a PR by number or URL
502    /// (`--from-pr <value>`).
503    ///
504    /// This wrapper only supports the valued form; the CLI's
505    /// no-value mode opens an interactive picker and would hang a
506    /// headless caller.
507    #[must_use]
508    pub fn from_pr(mut self, pr: impl Into<String>) -> Self {
509        self.from_pr = Some(pr.into());
510        self
511    }
512
513    /// Set a per-command retry policy, overriding the client default.
514    ///
515    /// # Example
516    ///
517    /// ```no_run
518    /// use claude_wrapper::{Claude, ClaudeCommand, QueryCommand, RetryPolicy};
519    /// use std::time::Duration;
520    ///
521    /// # async fn example() -> claude_wrapper::Result<()> {
522    /// let claude = Claude::builder().build()?;
523    ///
524    /// let output = QueryCommand::new("explain quicksort")
525    ///     .retry(RetryPolicy::new()
526    ///         .max_attempts(5)
527    ///         .initial_backoff(Duration::from_secs(2))
528    ///         .exponential()
529    ///         .retry_on_timeout(true))
530    ///     .execute(&claude)
531    ///     .await?;
532    /// # Ok(())
533    /// # }
534    /// ```
535    #[must_use]
536    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
537        self.retry_policy = Some(policy);
538        self
539    }
540
541    /// Return the full command as a string that could be run in a shell.
542    ///
543    /// Constructs a command string using the binary path from the Claude instance
544    /// and the arguments from this query. Arguments containing spaces or special
545    /// shell characters are shell-quoted to be safe for shell execution.
546    ///
547    /// # Example
548    ///
549    /// ```no_run
550    /// use claude_wrapper::{Claude, QueryCommand};
551    ///
552    /// # async fn example() -> claude_wrapper::Result<()> {
553    /// let claude = Claude::builder().build()?;
554    ///
555    /// let cmd = QueryCommand::new("explain quicksort")
556    ///     .model("sonnet");
557    ///
558    /// let command_str = cmd.to_command_string(&claude);
559    /// println!("Would run: {}", command_str);
560    /// # Ok(())
561    /// # }
562    /// ```
563    pub fn to_command_string(&self, claude: &Claude) -> String {
564        let args = self.build_args();
565        let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
566        format!("{} {}", claude.binary().display(), quoted_args.join(" "))
567    }
568
569    /// Execute the query and parse the JSON result.
570    ///
571    /// This is a convenience method that sets `OutputFormat::Json` and
572    /// deserializes the response into a [`QueryResult`](crate::types::QueryResult).
573    #[cfg(all(feature = "json", feature = "async"))]
574    pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
575        // Build args with JSON output format forced
576        let mut args = self.build_args();
577
578        // Override output format to json if not already set
579        if self.output_format.is_none() {
580            args.push("--output-format".to_string());
581            args.push("json".to_string());
582        }
583
584        let output = exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?;
585
586        serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
587            message: format!("failed to parse query result: {e}"),
588            source: e,
589        })
590    }
591
592    /// Blocking analog of [`QueryCommand::execute`] that honours the
593    /// configured [`RetryPolicy`](crate::retry::RetryPolicy).
594    ///
595    /// Overrides the blanket
596    /// [`ClaudeCommandSyncExt::execute_sync`](crate::ClaudeCommandSyncExt)
597    /// impl so retries still fire on the sync path.
598    #[cfg(feature = "sync")]
599    pub fn execute_sync(&self, claude: &Claude) -> Result<CommandOutput> {
600        exec::run_claude_with_retry_sync(claude, self.args(), self.retry_policy.as_ref())
601    }
602
603    /// Blocking mirror of [`QueryCommand::execute_json`].
604    #[cfg(all(feature = "sync", feature = "json"))]
605    pub fn execute_json_sync(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
606        let mut args = self.build_args();
607
608        if self.output_format.is_none() {
609            args.push("--output-format".to_string());
610            args.push("json".to_string());
611        }
612
613        let output = exec::run_claude_with_retry_sync(claude, args, self.retry_policy.as_ref())?;
614
615        serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
616            message: format!("failed to parse query result: {e}"),
617            source: e,
618        })
619    }
620
621    fn build_args(&self) -> Vec<String> {
622        let mut args = vec!["--print".to_string()];
623
624        if let Some(ref model) = self.model {
625            args.push("--model".to_string());
626            args.push(model.clone());
627        }
628
629        if let Some(ref prompt) = self.system_prompt {
630            args.push("--system-prompt".to_string());
631            args.push(prompt.clone());
632        }
633
634        if let Some(ref prompt) = self.append_system_prompt {
635            args.push("--append-system-prompt".to_string());
636            args.push(prompt.clone());
637        }
638
639        if let Some(ref format) = self.output_format {
640            args.push("--output-format".to_string());
641            args.push(format.as_arg().to_string());
642            // CLI v2.1.72+ requires --verbose when using stream-json with --print
643            if matches!(format, OutputFormat::StreamJson) {
644                args.push("--verbose".to_string());
645            }
646        }
647
648        if let Some(budget) = self.max_budget_usd {
649            args.push("--max-budget-usd".to_string());
650            args.push(budget.to_string());
651        }
652
653        if let Some(ref mode) = self.permission_mode {
654            args.push("--permission-mode".to_string());
655            args.push(mode.as_arg().to_string());
656        }
657
658        if !self.allowed_tools.is_empty() {
659            args.push("--allowed-tools".to_string());
660            args.push(join_patterns(&self.allowed_tools));
661        }
662
663        if !self.disallowed_tools.is_empty() {
664            args.push("--disallowed-tools".to_string());
665            args.push(join_patterns(&self.disallowed_tools));
666        }
667
668        for config in &self.mcp_config {
669            args.push("--mcp-config".to_string());
670            args.push(config.clone());
671        }
672
673        for dir in &self.add_dir {
674            args.push("--add-dir".to_string());
675            args.push(dir.clone());
676        }
677
678        if let Some(ref effort) = self.effort {
679            args.push("--effort".to_string());
680            args.push(effort.as_arg().to_string());
681        }
682
683        if let Some(turns) = self.max_turns {
684            args.push("--max-turns".to_string());
685            args.push(turns.to_string());
686        }
687
688        if let Some(ref schema) = self.json_schema {
689            args.push("--json-schema".to_string());
690            args.push(schema.clone());
691        }
692
693        if self.continue_session {
694            args.push("--continue".to_string());
695        }
696
697        if let Some(ref session_id) = self.resume {
698            args.push("--resume".to_string());
699            args.push(session_id.clone());
700        }
701
702        if let Some(ref id) = self.session_id {
703            args.push("--session-id".to_string());
704            args.push(id.clone());
705        }
706
707        if let Some(ref model) = self.fallback_model {
708            args.push("--fallback-model".to_string());
709            args.push(model.clone());
710        }
711
712        if self.no_session_persistence {
713            args.push("--no-session-persistence".to_string());
714        }
715
716        if self.dangerously_skip_permissions {
717            args.push("--dangerously-skip-permissions".to_string());
718        }
719
720        if let Some(ref agent) = self.agent {
721            args.push("--agent".to_string());
722            args.push(agent.clone());
723        }
724
725        if let Some(ref agents) = self.agents_json {
726            args.push("--agents".to_string());
727            args.push(agents.clone());
728        }
729
730        if !self.tools.is_empty() {
731            args.push("--tools".to_string());
732            args.push(self.tools.join(","));
733        }
734
735        for spec in &self.file {
736            args.push("--file".to_string());
737            args.push(spec.clone());
738        }
739
740        if self.include_partial_messages {
741            args.push("--include-partial-messages".to_string());
742        }
743
744        if let Some(ref format) = self.input_format {
745            args.push("--input-format".to_string());
746            args.push(format.as_arg().to_string());
747        }
748
749        if self.strict_mcp_config {
750            args.push("--strict-mcp-config".to_string());
751        }
752
753        if let Some(ref settings) = self.settings {
754            args.push("--settings".to_string());
755            args.push(settings.clone());
756        }
757
758        if self.fork_session {
759            args.push("--fork-session".to_string());
760        }
761
762        if self.worktree {
763            args.push("--worktree".to_string());
764        }
765
766        if self.brief {
767            args.push("--brief".to_string());
768        }
769
770        if let Some(ref filter) = self.debug_filter {
771            args.push("--debug".to_string());
772            args.push(filter.clone());
773        }
774
775        if let Some(ref path) = self.debug_file {
776            args.push("--debug-file".to_string());
777            args.push(path.clone());
778        }
779
780        if let Some(ref betas) = self.betas {
781            args.push("--betas".to_string());
782            args.push(betas.clone());
783        }
784
785        for dir in &self.plugin_dirs {
786            args.push("--plugin-dir".to_string());
787            args.push(dir.clone());
788        }
789
790        if let Some(ref sources) = self.setting_sources {
791            args.push("--setting-sources".to_string());
792            args.push(sources.clone());
793        }
794
795        if self.tmux {
796            args.push("--tmux".to_string());
797        }
798
799        if self.bare {
800            args.push("--bare".to_string());
801        }
802
803        if self.disable_slash_commands {
804            args.push("--disable-slash-commands".to_string());
805        }
806
807        if self.include_hook_events {
808            args.push("--include-hook-events".to_string());
809        }
810
811        if self.exclude_dynamic_system_prompt_sections {
812            args.push("--exclude-dynamic-system-prompt-sections".to_string());
813        }
814
815        if let Some(ref name) = self.name {
816            args.push("--name".to_string());
817            args.push(name.clone());
818        }
819
820        if let Some(ref pr) = self.from_pr {
821            args.push("--from-pr".to_string());
822            args.push(pr.clone());
823        }
824
825        // Separator to prevent flags like --allowed-tools from consuming the prompt.
826        args.push("--".to_string());
827        args.push(self.prompt.clone());
828
829        args
830    }
831}
832
833impl ClaudeCommand for QueryCommand {
834    type Output = CommandOutput;
835
836    fn args(&self) -> Vec<String> {
837        self.build_args()
838    }
839
840    #[cfg(feature = "async")]
841    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
842        exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
843    }
844}
845
846/// Shell-quote an argument if it contains spaces or special characters.
847fn shell_quote(arg: &str) -> String {
848    // Check if the argument needs quoting (contains whitespace or shell metacharacters)
849    if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
850        // Use single quotes and escape any existing single quotes
851        format!("'{}'", arg.replace("'", "'\\''"))
852    } else {
853        arg.to_string()
854    }
855}
856
857fn join_patterns(patterns: &[ToolPattern]) -> String {
858    let mut out = String::new();
859    for (i, p) in patterns.iter().enumerate() {
860        if i > 0 {
861            out.push(',');
862        }
863        out.push_str(p.as_str());
864    }
865    out
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871
872    #[test]
873    fn test_basic_query_args() {
874        let cmd = QueryCommand::new("hello world");
875        let args = cmd.args();
876        assert_eq!(args, vec!["--print", "--", "hello world"]);
877    }
878
879    #[test]
880    #[allow(deprecated)] // exercises PermissionMode::BypassPermissions directly; prefer dangerous::DangerousClient in new code
881    fn test_full_query_args() {
882        let cmd = QueryCommand::new("explain this")
883            .model("sonnet")
884            .system_prompt("be concise")
885            .output_format(OutputFormat::Json)
886            .max_budget_usd(0.50)
887            .permission_mode(PermissionMode::BypassPermissions)
888            .allowed_tools(["Bash", "Read"])
889            .mcp_config("/tmp/mcp.json")
890            .effort(Effort::High)
891            .max_turns(3)
892            .no_session_persistence();
893
894        let args = cmd.args();
895        assert!(args.contains(&"--print".to_string()));
896        assert!(args.contains(&"--model".to_string()));
897        assert!(args.contains(&"sonnet".to_string()));
898        assert!(args.contains(&"--system-prompt".to_string()));
899        assert!(args.contains(&"--output-format".to_string()));
900        assert!(args.contains(&"json".to_string()));
901        // json format should NOT include --verbose (only stream-json needs it)
902        assert!(!args.contains(&"--verbose".to_string()));
903        assert!(args.contains(&"--max-budget-usd".to_string()));
904        assert!(args.contains(&"--permission-mode".to_string()));
905        assert!(args.contains(&"bypassPermissions".to_string()));
906        assert!(args.contains(&"--allowed-tools".to_string()));
907        assert!(args.contains(&"Bash,Read".to_string()));
908        assert!(args.contains(&"--effort".to_string()));
909        assert!(args.contains(&"high".to_string()));
910        assert!(args.contains(&"--max-turns".to_string()));
911        assert!(args.contains(&"--no-session-persistence".to_string()));
912        // Prompt is last, preceded by -- separator
913        assert_eq!(args.last().unwrap(), "explain this");
914        assert_eq!(args[args.len() - 2], "--");
915    }
916
917    #[test]
918    fn typed_patterns_render_in_allowed_tools() {
919        use crate::ToolPattern;
920
921        let cmd = QueryCommand::new("hi")
922            .allowed_tool(ToolPattern::tool("Read"))
923            .allowed_tool(ToolPattern::tool_with_args("Bash", "git log:*"))
924            .allowed_tool(ToolPattern::all("Write"))
925            .allowed_tool(ToolPattern::mcp("srv", "*"));
926
927        let args = cmd.args();
928        let joined = args
929            .iter()
930            .position(|a| a == "--allowed-tools")
931            .map(|i| &args[i + 1])
932            .unwrap();
933        assert_eq!(joined, "Read,Bash(git log:*),Write(*),mcp__srv__*");
934    }
935
936    #[test]
937    fn disallowed_tool_singular_appends() {
938        use crate::ToolPattern;
939
940        let cmd = QueryCommand::new("hi")
941            .disallowed_tool("Write")
942            .disallowed_tool(ToolPattern::tool_with_args("Bash", "rm*"));
943
944        let args = cmd.args();
945        let joined = args
946            .iter()
947            .position(|a| a == "--disallowed-tools")
948            .map(|i| &args[i + 1])
949            .unwrap();
950        assert_eq!(joined, "Write,Bash(rm*)");
951    }
952
953    #[test]
954    fn mixed_string_and_typed_patterns_both_accepted() {
955        use crate::ToolPattern;
956
957        // Smoke test for API ergonomics: one plural call with mixed
958        // inputs should compile even though the builder is generic
959        // over T: Into<ToolPattern>.
960        let strs: Vec<ToolPattern> = vec!["Bash".into(), ToolPattern::all("Read")];
961        let cmd = QueryCommand::new("hi").allowed_tools(strs);
962        assert!(cmd.args().contains(&"--allowed-tools".to_string()));
963    }
964
965    #[test]
966    fn new_bool_flags_emit_correct_cli_args() {
967        let args = QueryCommand::new("hi")
968            .bare()
969            .disable_slash_commands()
970            .include_hook_events()
971            .exclude_dynamic_system_prompt_sections()
972            .args();
973        assert!(args.contains(&"--bare".to_string()));
974        assert!(args.contains(&"--disable-slash-commands".to_string()));
975        assert!(args.contains(&"--include-hook-events".to_string()));
976        assert!(args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
977    }
978
979    #[test]
980    fn name_flag_renders_with_value() {
981        let args = QueryCommand::new("hi").name("my session").args();
982        let pos = args.iter().position(|a| a == "--name").unwrap();
983        assert_eq!(args[pos + 1], "my session");
984    }
985
986    #[test]
987    fn from_pr_flag_renders_with_value() {
988        let args = QueryCommand::new("hi").from_pr("42").args();
989        let pos = args.iter().position(|a| a == "--from-pr").unwrap();
990        assert_eq!(args[pos + 1], "42");
991    }
992
993    #[test]
994    fn new_bool_flags_default_to_off() {
995        let args = QueryCommand::new("hi").args();
996        assert!(!args.contains(&"--bare".to_string()));
997        assert!(!args.contains(&"--disable-slash-commands".to_string()));
998        assert!(!args.contains(&"--include-hook-events".to_string()));
999        assert!(!args.contains(&"--exclude-dynamic-system-prompt-sections".to_string()));
1000        assert!(!args.contains(&"--name".to_string()));
1001    }
1002
1003    #[test]
1004    fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
1005        // Regression: --allowed-tools was consuming the prompt as a tool name
1006        // when the prompt appeared after it without a -- separator.
1007        let cmd = QueryCommand::new("fix the bug")
1008            .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
1009            .output_format(OutputFormat::StreamJson);
1010        let args = cmd.args();
1011        // -- separator must appear before the prompt
1012        let sep_pos = args.iter().position(|a| a == "--").unwrap();
1013        let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
1014        assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
1015        // --allowed-tools value must appear before the separator
1016        let tools_pos = args
1017            .iter()
1018            .position(|a| a.contains("Bash(cargo *)"))
1019            .unwrap();
1020        assert!(
1021            tools_pos < sep_pos,
1022            "allowed-tools must come before -- separator"
1023        );
1024    }
1025
1026    #[test]
1027    fn test_stream_json_includes_verbose() {
1028        let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
1029        let args = cmd.args();
1030        assert!(args.contains(&"--output-format".to_string()));
1031        assert!(args.contains(&"stream-json".to_string()));
1032        assert!(args.contains(&"--verbose".to_string()));
1033    }
1034
1035    #[test]
1036    fn test_to_command_string_simple() {
1037        let claude = Claude::builder()
1038            .binary("/usr/local/bin/claude")
1039            .build()
1040            .unwrap();
1041
1042        let cmd = QueryCommand::new("hello");
1043        let command_str = cmd.to_command_string(&claude);
1044
1045        assert!(command_str.starts_with("/usr/local/bin/claude"));
1046        assert!(command_str.contains("--print"));
1047        assert!(command_str.contains("hello"));
1048    }
1049
1050    #[test]
1051    fn test_to_command_string_with_spaces() {
1052        let claude = Claude::builder()
1053            .binary("/usr/local/bin/claude")
1054            .build()
1055            .unwrap();
1056
1057        let cmd = QueryCommand::new("hello world").model("sonnet");
1058        let command_str = cmd.to_command_string(&claude);
1059
1060        assert!(command_str.starts_with("/usr/local/bin/claude"));
1061        assert!(command_str.contains("--print"));
1062        // Prompt with spaces should be quoted
1063        assert!(command_str.contains("'hello world'"));
1064        assert!(command_str.contains("--model"));
1065        assert!(command_str.contains("sonnet"));
1066    }
1067
1068    #[test]
1069    fn test_to_command_string_with_special_chars() {
1070        let claude = Claude::builder()
1071            .binary("/usr/local/bin/claude")
1072            .build()
1073            .unwrap();
1074
1075        let cmd = QueryCommand::new("test $VAR and `cmd`");
1076        let command_str = cmd.to_command_string(&claude);
1077
1078        // Arguments with special shell characters should be quoted
1079        assert!(command_str.contains("'test $VAR and `cmd`'"));
1080    }
1081
1082    #[test]
1083    fn test_to_command_string_with_single_quotes() {
1084        let claude = Claude::builder()
1085            .binary("/usr/local/bin/claude")
1086            .build()
1087            .unwrap();
1088
1089        let cmd = QueryCommand::new("it's");
1090        let command_str = cmd.to_command_string(&claude);
1091
1092        // Single quotes should be escaped in shell
1093        assert!(command_str.contains("'it'\\''s'"));
1094    }
1095
1096    #[test]
1097    fn test_worktree_flag() {
1098        let cmd = QueryCommand::new("test").worktree();
1099        let args = cmd.args();
1100        assert!(args.contains(&"--worktree".to_string()));
1101    }
1102
1103    #[test]
1104    fn test_brief_flag() {
1105        let cmd = QueryCommand::new("test").brief();
1106        let args = cmd.args();
1107        assert!(args.contains(&"--brief".to_string()));
1108    }
1109
1110    #[test]
1111    fn test_debug_filter() {
1112        let cmd = QueryCommand::new("test").debug_filter("api,hooks");
1113        let args = cmd.args();
1114        assert!(args.contains(&"--debug".to_string()));
1115        assert!(args.contains(&"api,hooks".to_string()));
1116    }
1117
1118    #[test]
1119    fn test_debug_file() {
1120        let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
1121        let args = cmd.args();
1122        assert!(args.contains(&"--debug-file".to_string()));
1123        assert!(args.contains(&"/tmp/debug.log".to_string()));
1124    }
1125
1126    #[test]
1127    fn test_betas() {
1128        let cmd = QueryCommand::new("test").betas("feature-x");
1129        let args = cmd.args();
1130        assert!(args.contains(&"--betas".to_string()));
1131        assert!(args.contains(&"feature-x".to_string()));
1132    }
1133
1134    #[test]
1135    fn test_plugin_dir_single() {
1136        let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
1137        let args = cmd.args();
1138        assert!(args.contains(&"--plugin-dir".to_string()));
1139        assert!(args.contains(&"/plugins/foo".to_string()));
1140    }
1141
1142    #[test]
1143    fn test_plugin_dir_multiple() {
1144        let cmd = QueryCommand::new("test")
1145            .plugin_dir("/plugins/foo")
1146            .plugin_dir("/plugins/bar");
1147        let args = cmd.args();
1148        let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
1149        assert_eq!(plugin_dir_count, 2);
1150        assert!(args.contains(&"/plugins/foo".to_string()));
1151        assert!(args.contains(&"/plugins/bar".to_string()));
1152    }
1153
1154    #[test]
1155    fn test_setting_sources() {
1156        let cmd = QueryCommand::new("test").setting_sources("user,project,local");
1157        let args = cmd.args();
1158        assert!(args.contains(&"--setting-sources".to_string()));
1159        assert!(args.contains(&"user,project,local".to_string()));
1160    }
1161
1162    #[test]
1163    fn test_tmux_flag() {
1164        let cmd = QueryCommand::new("test").tmux();
1165        let args = cmd.args();
1166        assert!(args.contains(&"--tmux".to_string()));
1167    }
1168
1169    // ─── shell_quote unit tests (#455) ───
1170
1171    #[test]
1172    fn shell_quote_plain_word_is_unchanged() {
1173        assert_eq!(shell_quote("simple"), "simple");
1174        assert_eq!(shell_quote(""), "");
1175        assert_eq!(shell_quote("file.rs"), "file.rs");
1176    }
1177
1178    #[test]
1179    fn shell_quote_whitespace_gets_single_quoted() {
1180        assert_eq!(shell_quote("hello world"), "'hello world'");
1181        assert_eq!(shell_quote("a\tb"), "'a\tb'");
1182    }
1183
1184    #[test]
1185    fn shell_quote_metacharacters_get_quoted() {
1186        assert_eq!(shell_quote("a|b"), "'a|b'");
1187        assert_eq!(shell_quote("$VAR"), "'$VAR'");
1188        assert_eq!(shell_quote("a;b"), "'a;b'");
1189        assert_eq!(shell_quote("(x)"), "'(x)'");
1190    }
1191
1192    #[test]
1193    fn shell_quote_embedded_single_quote_is_escaped() {
1194        assert_eq!(shell_quote("it's"), "'it'\\''s'");
1195    }
1196
1197    #[test]
1198    fn shell_quote_double_quote_gets_single_quoted() {
1199        assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
1200    }
1201}