1use crate::Claude;
2use crate::command::ClaudeCommand;
3use crate::error::Result;
4use crate::exec::{self, CommandOutput};
5use crate::types::{Effort, InputFormat, OutputFormat, PermissionMode};
6
7#[derive(Debug, Clone)]
30pub struct QueryCommand {
31 prompt: String,
32 model: Option<String>,
33 system_prompt: Option<String>,
34 append_system_prompt: Option<String>,
35 output_format: Option<OutputFormat>,
36 max_budget_usd: Option<f64>,
37 permission_mode: Option<PermissionMode>,
38 allowed_tools: Vec<String>,
39 disallowed_tools: Vec<String>,
40 mcp_config: Vec<String>,
41 add_dir: Vec<String>,
42 effort: Option<Effort>,
43 max_turns: Option<u32>,
44 json_schema: Option<String>,
45 continue_session: bool,
46 resume: Option<String>,
47 session_id: Option<String>,
48 fallback_model: Option<String>,
49 no_session_persistence: bool,
50 dangerously_skip_permissions: bool,
51 agent: Option<String>,
52 agents_json: Option<String>,
53 tools: Vec<String>,
54 file: Vec<String>,
55 include_partial_messages: bool,
56 input_format: Option<InputFormat>,
57 strict_mcp_config: bool,
58 settings: Option<String>,
59 fork_session: bool,
60 retry_policy: Option<crate::retry::RetryPolicy>,
61 worktree: bool,
62 brief: bool,
63 debug_filter: Option<String>,
64 debug_file: Option<String>,
65 betas: Option<String>,
66 plugin_dirs: Vec<String>,
67 setting_sources: Option<String>,
68 tmux: bool,
69}
70
71impl QueryCommand {
72 #[must_use]
74 pub fn new(prompt: impl Into<String>) -> Self {
75 Self {
76 prompt: prompt.into(),
77 model: None,
78 system_prompt: None,
79 append_system_prompt: None,
80 output_format: None,
81 max_budget_usd: None,
82 permission_mode: None,
83 allowed_tools: Vec::new(),
84 disallowed_tools: Vec::new(),
85 mcp_config: Vec::new(),
86 add_dir: Vec::new(),
87 effort: None,
88 max_turns: None,
89 json_schema: None,
90 continue_session: false,
91 resume: None,
92 session_id: None,
93 fallback_model: None,
94 no_session_persistence: false,
95 dangerously_skip_permissions: false,
96 agent: None,
97 agents_json: None,
98 tools: Vec::new(),
99 file: Vec::new(),
100 include_partial_messages: false,
101 input_format: None,
102 strict_mcp_config: false,
103 settings: None,
104 fork_session: false,
105 retry_policy: None,
106 worktree: false,
107 brief: false,
108 debug_filter: None,
109 debug_file: None,
110 betas: None,
111 plugin_dirs: Vec::new(),
112 setting_sources: None,
113 tmux: false,
114 }
115 }
116
117 #[must_use]
119 pub fn model(mut self, model: impl Into<String>) -> Self {
120 self.model = Some(model.into());
121 self
122 }
123
124 #[must_use]
126 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
127 self.system_prompt = Some(prompt.into());
128 self
129 }
130
131 #[must_use]
133 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
134 self.append_system_prompt = Some(prompt.into());
135 self
136 }
137
138 #[must_use]
140 pub fn output_format(mut self, format: OutputFormat) -> Self {
141 self.output_format = Some(format);
142 self
143 }
144
145 #[must_use]
147 pub fn max_budget_usd(mut self, budget: f64) -> Self {
148 self.max_budget_usd = Some(budget);
149 self
150 }
151
152 #[must_use]
154 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
155 self.permission_mode = Some(mode);
156 self
157 }
158
159 #[must_use]
161 pub fn allowed_tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
162 self.allowed_tools.extend(tools.into_iter().map(Into::into));
163 self
164 }
165
166 #[must_use]
168 pub fn allowed_tool(mut self, tool: impl Into<String>) -> Self {
169 self.allowed_tools.push(tool.into());
170 self
171 }
172
173 #[must_use]
175 pub fn disallowed_tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
176 self.disallowed_tools
177 .extend(tools.into_iter().map(Into::into));
178 self
179 }
180
181 #[must_use]
183 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
184 self.mcp_config.push(path.into());
185 self
186 }
187
188 #[must_use]
190 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
191 self.add_dir.push(dir.into());
192 self
193 }
194
195 #[must_use]
197 pub fn effort(mut self, effort: Effort) -> Self {
198 self.effort = Some(effort);
199 self
200 }
201
202 #[must_use]
204 pub fn max_turns(mut self, turns: u32) -> Self {
205 self.max_turns = Some(turns);
206 self
207 }
208
209 #[must_use]
211 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
212 self.json_schema = Some(schema.into());
213 self
214 }
215
216 #[must_use]
218 pub fn continue_session(mut self) -> Self {
219 self.continue_session = true;
220 self
221 }
222
223 #[must_use]
225 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
226 self.resume = Some(session_id.into());
227 self
228 }
229
230 #[must_use]
232 pub fn session_id(mut self, id: impl Into<String>) -> Self {
233 self.session_id = Some(id.into());
234 self
235 }
236
237 #[cfg(feature = "json")]
245 pub(crate) fn replace_session(mut self, id: impl Into<String>) -> Self {
246 self.continue_session = false;
247 self.resume = Some(id.into());
248 self.session_id = None;
249 self.fork_session = false;
250 self
251 }
252
253 #[must_use]
255 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
256 self.fallback_model = Some(model.into());
257 self
258 }
259
260 #[must_use]
262 pub fn no_session_persistence(mut self) -> Self {
263 self.no_session_persistence = true;
264 self
265 }
266
267 #[must_use]
269 pub fn dangerously_skip_permissions(mut self) -> Self {
270 self.dangerously_skip_permissions = true;
271 self
272 }
273
274 #[must_use]
276 pub fn agent(mut self, agent: impl Into<String>) -> Self {
277 self.agent = Some(agent.into());
278 self
279 }
280
281 #[must_use]
285 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
286 self.agents_json = Some(json.into());
287 self
288 }
289
290 #[must_use]
296 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
297 self.tools.extend(tools.into_iter().map(Into::into));
298 self
299 }
300
301 #[must_use]
305 pub fn file(mut self, spec: impl Into<String>) -> Self {
306 self.file.push(spec.into());
307 self
308 }
309
310 #[must_use]
314 pub fn include_partial_messages(mut self) -> Self {
315 self.include_partial_messages = true;
316 self
317 }
318
319 #[must_use]
321 pub fn input_format(mut self, format: InputFormat) -> Self {
322 self.input_format = Some(format);
323 self
324 }
325
326 #[must_use]
328 pub fn strict_mcp_config(mut self) -> Self {
329 self.strict_mcp_config = true;
330 self
331 }
332
333 #[must_use]
335 pub fn settings(mut self, settings: impl Into<String>) -> Self {
336 self.settings = Some(settings.into());
337 self
338 }
339
340 #[must_use]
342 pub fn fork_session(mut self) -> Self {
343 self.fork_session = true;
344 self
345 }
346
347 #[must_use]
349 pub fn worktree(mut self) -> Self {
350 self.worktree = true;
351 self
352 }
353
354 #[must_use]
356 pub fn brief(mut self) -> Self {
357 self.brief = true;
358 self
359 }
360
361 #[must_use]
363 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
364 self.debug_filter = Some(filter.into());
365 self
366 }
367
368 #[must_use]
370 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
371 self.debug_file = Some(path.into());
372 self
373 }
374
375 #[must_use]
377 pub fn betas(mut self, betas: impl Into<String>) -> Self {
378 self.betas = Some(betas.into());
379 self
380 }
381
382 #[must_use]
384 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
385 self.plugin_dirs.push(dir.into());
386 self
387 }
388
389 #[must_use]
391 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
392 self.setting_sources = Some(sources.into());
393 self
394 }
395
396 #[must_use]
398 pub fn tmux(mut self) -> Self {
399 self.tmux = true;
400 self
401 }
402
403 #[must_use]
426 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
427 self.retry_policy = Some(policy);
428 self
429 }
430
431 pub fn to_command_string(&self, claude: &Claude) -> String {
454 let args = self.build_args();
455 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
456 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
457 }
458
459 #[cfg(feature = "json")]
464 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
465 let mut args = self.build_args();
467
468 if self.output_format.is_none() {
470 args.push("--output-format".to_string());
471 args.push("json".to_string());
472 }
473
474 let output = exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?;
475
476 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
477 message: format!("failed to parse query result: {e}"),
478 source: e,
479 })
480 }
481
482 fn build_args(&self) -> Vec<String> {
483 let mut args = vec!["--print".to_string()];
484
485 if let Some(ref model) = self.model {
486 args.push("--model".to_string());
487 args.push(model.clone());
488 }
489
490 if let Some(ref prompt) = self.system_prompt {
491 args.push("--system-prompt".to_string());
492 args.push(prompt.clone());
493 }
494
495 if let Some(ref prompt) = self.append_system_prompt {
496 args.push("--append-system-prompt".to_string());
497 args.push(prompt.clone());
498 }
499
500 if let Some(ref format) = self.output_format {
501 args.push("--output-format".to_string());
502 args.push(format.as_arg().to_string());
503 if matches!(format, OutputFormat::StreamJson) {
505 args.push("--verbose".to_string());
506 }
507 }
508
509 if let Some(budget) = self.max_budget_usd {
510 args.push("--max-budget-usd".to_string());
511 args.push(budget.to_string());
512 }
513
514 if let Some(ref mode) = self.permission_mode {
515 args.push("--permission-mode".to_string());
516 args.push(mode.as_arg().to_string());
517 }
518
519 if !self.allowed_tools.is_empty() {
520 args.push("--allowed-tools".to_string());
521 args.push(self.allowed_tools.join(","));
522 }
523
524 if !self.disallowed_tools.is_empty() {
525 args.push("--disallowed-tools".to_string());
526 args.push(self.disallowed_tools.join(","));
527 }
528
529 for config in &self.mcp_config {
530 args.push("--mcp-config".to_string());
531 args.push(config.clone());
532 }
533
534 for dir in &self.add_dir {
535 args.push("--add-dir".to_string());
536 args.push(dir.clone());
537 }
538
539 if let Some(ref effort) = self.effort {
540 args.push("--effort".to_string());
541 args.push(effort.as_arg().to_string());
542 }
543
544 if let Some(turns) = self.max_turns {
545 args.push("--max-turns".to_string());
546 args.push(turns.to_string());
547 }
548
549 if let Some(ref schema) = self.json_schema {
550 args.push("--json-schema".to_string());
551 args.push(schema.clone());
552 }
553
554 if self.continue_session {
555 args.push("--continue".to_string());
556 }
557
558 if let Some(ref session_id) = self.resume {
559 args.push("--resume".to_string());
560 args.push(session_id.clone());
561 }
562
563 if let Some(ref id) = self.session_id {
564 args.push("--session-id".to_string());
565 args.push(id.clone());
566 }
567
568 if let Some(ref model) = self.fallback_model {
569 args.push("--fallback-model".to_string());
570 args.push(model.clone());
571 }
572
573 if self.no_session_persistence {
574 args.push("--no-session-persistence".to_string());
575 }
576
577 if self.dangerously_skip_permissions {
578 args.push("--dangerously-skip-permissions".to_string());
579 }
580
581 if let Some(ref agent) = self.agent {
582 args.push("--agent".to_string());
583 args.push(agent.clone());
584 }
585
586 if let Some(ref agents) = self.agents_json {
587 args.push("--agents".to_string());
588 args.push(agents.clone());
589 }
590
591 if !self.tools.is_empty() {
592 args.push("--tools".to_string());
593 args.push(self.tools.join(","));
594 }
595
596 for spec in &self.file {
597 args.push("--file".to_string());
598 args.push(spec.clone());
599 }
600
601 if self.include_partial_messages {
602 args.push("--include-partial-messages".to_string());
603 }
604
605 if let Some(ref format) = self.input_format {
606 args.push("--input-format".to_string());
607 args.push(format.as_arg().to_string());
608 }
609
610 if self.strict_mcp_config {
611 args.push("--strict-mcp-config".to_string());
612 }
613
614 if let Some(ref settings) = self.settings {
615 args.push("--settings".to_string());
616 args.push(settings.clone());
617 }
618
619 if self.fork_session {
620 args.push("--fork-session".to_string());
621 }
622
623 if self.worktree {
624 args.push("--worktree".to_string());
625 }
626
627 if self.brief {
628 args.push("--brief".to_string());
629 }
630
631 if let Some(ref filter) = self.debug_filter {
632 args.push("--debug".to_string());
633 args.push(filter.clone());
634 }
635
636 if let Some(ref path) = self.debug_file {
637 args.push("--debug-file".to_string());
638 args.push(path.clone());
639 }
640
641 if let Some(ref betas) = self.betas {
642 args.push("--betas".to_string());
643 args.push(betas.clone());
644 }
645
646 for dir in &self.plugin_dirs {
647 args.push("--plugin-dir".to_string());
648 args.push(dir.clone());
649 }
650
651 if let Some(ref sources) = self.setting_sources {
652 args.push("--setting-sources".to_string());
653 args.push(sources.clone());
654 }
655
656 if self.tmux {
657 args.push("--tmux".to_string());
658 }
659
660 args.push("--".to_string());
662 args.push(self.prompt.clone());
663
664 args
665 }
666}
667
668impl ClaudeCommand for QueryCommand {
669 type Output = CommandOutput;
670
671 fn args(&self) -> Vec<String> {
672 self.build_args()
673 }
674
675 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
676 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
677 }
678}
679
680fn shell_quote(arg: &str) -> String {
682 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
684 format!("'{}'", arg.replace("'", "'\\''"))
686 } else {
687 arg.to_string()
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 #[test]
696 fn test_basic_query_args() {
697 let cmd = QueryCommand::new("hello world");
698 let args = cmd.args();
699 assert_eq!(args, vec!["--print", "--", "hello world"]);
700 }
701
702 #[test]
703 fn test_full_query_args() {
704 let cmd = QueryCommand::new("explain this")
705 .model("sonnet")
706 .system_prompt("be concise")
707 .output_format(OutputFormat::Json)
708 .max_budget_usd(0.50)
709 .permission_mode(PermissionMode::BypassPermissions)
710 .allowed_tools(["Bash", "Read"])
711 .mcp_config("/tmp/mcp.json")
712 .effort(Effort::High)
713 .max_turns(3)
714 .no_session_persistence();
715
716 let args = cmd.args();
717 assert!(args.contains(&"--print".to_string()));
718 assert!(args.contains(&"--model".to_string()));
719 assert!(args.contains(&"sonnet".to_string()));
720 assert!(args.contains(&"--system-prompt".to_string()));
721 assert!(args.contains(&"--output-format".to_string()));
722 assert!(args.contains(&"json".to_string()));
723 assert!(!args.contains(&"--verbose".to_string()));
725 assert!(args.contains(&"--max-budget-usd".to_string()));
726 assert!(args.contains(&"--permission-mode".to_string()));
727 assert!(args.contains(&"bypassPermissions".to_string()));
728 assert!(args.contains(&"--allowed-tools".to_string()));
729 assert!(args.contains(&"Bash,Read".to_string()));
730 assert!(args.contains(&"--effort".to_string()));
731 assert!(args.contains(&"high".to_string()));
732 assert!(args.contains(&"--max-turns".to_string()));
733 assert!(args.contains(&"--no-session-persistence".to_string()));
734 assert_eq!(args.last().unwrap(), "explain this");
736 assert_eq!(args[args.len() - 2], "--");
737 }
738
739 #[test]
740 fn test_separator_before_prompt_prevents_greedy_flag_parsing() {
741 let cmd = QueryCommand::new("fix the bug")
744 .allowed_tools(["Read", "Edit", "Bash(cargo *)"])
745 .output_format(OutputFormat::StreamJson);
746 let args = cmd.args();
747 let sep_pos = args.iter().position(|a| a == "--").unwrap();
749 let prompt_pos = args.iter().position(|a| a == "fix the bug").unwrap();
750 assert_eq!(prompt_pos, sep_pos + 1, "prompt must follow -- separator");
751 let tools_pos = args
753 .iter()
754 .position(|a| a.contains("Bash(cargo *)"))
755 .unwrap();
756 assert!(
757 tools_pos < sep_pos,
758 "allowed-tools must come before -- separator"
759 );
760 }
761
762 #[test]
763 fn test_stream_json_includes_verbose() {
764 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
765 let args = cmd.args();
766 assert!(args.contains(&"--output-format".to_string()));
767 assert!(args.contains(&"stream-json".to_string()));
768 assert!(args.contains(&"--verbose".to_string()));
769 }
770
771 #[test]
772 fn test_to_command_string_simple() {
773 let claude = Claude::builder()
774 .binary("/usr/local/bin/claude")
775 .build()
776 .unwrap();
777
778 let cmd = QueryCommand::new("hello");
779 let command_str = cmd.to_command_string(&claude);
780
781 assert!(command_str.starts_with("/usr/local/bin/claude"));
782 assert!(command_str.contains("--print"));
783 assert!(command_str.contains("hello"));
784 }
785
786 #[test]
787 fn test_to_command_string_with_spaces() {
788 let claude = Claude::builder()
789 .binary("/usr/local/bin/claude")
790 .build()
791 .unwrap();
792
793 let cmd = QueryCommand::new("hello world").model("sonnet");
794 let command_str = cmd.to_command_string(&claude);
795
796 assert!(command_str.starts_with("/usr/local/bin/claude"));
797 assert!(command_str.contains("--print"));
798 assert!(command_str.contains("'hello world'"));
800 assert!(command_str.contains("--model"));
801 assert!(command_str.contains("sonnet"));
802 }
803
804 #[test]
805 fn test_to_command_string_with_special_chars() {
806 let claude = Claude::builder()
807 .binary("/usr/local/bin/claude")
808 .build()
809 .unwrap();
810
811 let cmd = QueryCommand::new("test $VAR and `cmd`");
812 let command_str = cmd.to_command_string(&claude);
813
814 assert!(command_str.contains("'test $VAR and `cmd`'"));
816 }
817
818 #[test]
819 fn test_to_command_string_with_single_quotes() {
820 let claude = Claude::builder()
821 .binary("/usr/local/bin/claude")
822 .build()
823 .unwrap();
824
825 let cmd = QueryCommand::new("it's");
826 let command_str = cmd.to_command_string(&claude);
827
828 assert!(command_str.contains("'it'\\''s'"));
830 }
831
832 #[test]
833 fn test_worktree_flag() {
834 let cmd = QueryCommand::new("test").worktree();
835 let args = cmd.args();
836 assert!(args.contains(&"--worktree".to_string()));
837 }
838
839 #[test]
840 fn test_brief_flag() {
841 let cmd = QueryCommand::new("test").brief();
842 let args = cmd.args();
843 assert!(args.contains(&"--brief".to_string()));
844 }
845
846 #[test]
847 fn test_debug_filter() {
848 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
849 let args = cmd.args();
850 assert!(args.contains(&"--debug".to_string()));
851 assert!(args.contains(&"api,hooks".to_string()));
852 }
853
854 #[test]
855 fn test_debug_file() {
856 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
857 let args = cmd.args();
858 assert!(args.contains(&"--debug-file".to_string()));
859 assert!(args.contains(&"/tmp/debug.log".to_string()));
860 }
861
862 #[test]
863 fn test_betas() {
864 let cmd = QueryCommand::new("test").betas("feature-x");
865 let args = cmd.args();
866 assert!(args.contains(&"--betas".to_string()));
867 assert!(args.contains(&"feature-x".to_string()));
868 }
869
870 #[test]
871 fn test_plugin_dir_single() {
872 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
873 let args = cmd.args();
874 assert!(args.contains(&"--plugin-dir".to_string()));
875 assert!(args.contains(&"/plugins/foo".to_string()));
876 }
877
878 #[test]
879 fn test_plugin_dir_multiple() {
880 let cmd = QueryCommand::new("test")
881 .plugin_dir("/plugins/foo")
882 .plugin_dir("/plugins/bar");
883 let args = cmd.args();
884 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
885 assert_eq!(plugin_dir_count, 2);
886 assert!(args.contains(&"/plugins/foo".to_string()));
887 assert!(args.contains(&"/plugins/bar".to_string()));
888 }
889
890 #[test]
891 fn test_setting_sources() {
892 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
893 let args = cmd.args();
894 assert!(args.contains(&"--setting-sources".to_string()));
895 assert!(args.contains(&"user,project,local".to_string()));
896 }
897
898 #[test]
899 fn test_tmux_flag() {
900 let cmd = QueryCommand::new("test").tmux();
901 let args = cmd.args();
902 assert!(args.contains(&"--tmux".to_string()));
903 }
904
905 #[test]
908 fn shell_quote_plain_word_is_unchanged() {
909 assert_eq!(shell_quote("simple"), "simple");
910 assert_eq!(shell_quote(""), "");
911 assert_eq!(shell_quote("file.rs"), "file.rs");
912 }
913
914 #[test]
915 fn shell_quote_whitespace_gets_single_quoted() {
916 assert_eq!(shell_quote("hello world"), "'hello world'");
917 assert_eq!(shell_quote("a\tb"), "'a\tb'");
918 }
919
920 #[test]
921 fn shell_quote_metacharacters_get_quoted() {
922 assert_eq!(shell_quote("a|b"), "'a|b'");
923 assert_eq!(shell_quote("$VAR"), "'$VAR'");
924 assert_eq!(shell_quote("a;b"), "'a;b'");
925 assert_eq!(shell_quote("(x)"), "'(x)'");
926 }
927
928 #[test]
929 fn shell_quote_embedded_single_quote_is_escaped() {
930 assert_eq!(shell_quote("it's"), "'it'\\''s'");
931 }
932
933 #[test]
934 fn shell_quote_double_quote_gets_single_quoted() {
935 assert_eq!(shell_quote(r#"say "hi""#), r#"'say "hi"'"#);
936 }
937}