Skip to main content

claude_wrapper/command/
query.rs

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