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 #[must_use]
239 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
240 self.fallback_model = Some(model.into());
241 self
242 }
243
244 #[must_use]
246 pub fn no_session_persistence(mut self) -> Self {
247 self.no_session_persistence = true;
248 self
249 }
250
251 #[must_use]
253 pub fn dangerously_skip_permissions(mut self) -> Self {
254 self.dangerously_skip_permissions = true;
255 self
256 }
257
258 #[must_use]
260 pub fn agent(mut self, agent: impl Into<String>) -> Self {
261 self.agent = Some(agent.into());
262 self
263 }
264
265 #[must_use]
269 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
270 self.agents_json = Some(json.into());
271 self
272 }
273
274 #[must_use]
280 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
281 self.tools.extend(tools.into_iter().map(Into::into));
282 self
283 }
284
285 #[must_use]
289 pub fn file(mut self, spec: impl Into<String>) -> Self {
290 self.file.push(spec.into());
291 self
292 }
293
294 #[must_use]
298 pub fn include_partial_messages(mut self) -> Self {
299 self.include_partial_messages = true;
300 self
301 }
302
303 #[must_use]
305 pub fn input_format(mut self, format: InputFormat) -> Self {
306 self.input_format = Some(format);
307 self
308 }
309
310 #[must_use]
312 pub fn strict_mcp_config(mut self) -> Self {
313 self.strict_mcp_config = true;
314 self
315 }
316
317 #[must_use]
319 pub fn settings(mut self, settings: impl Into<String>) -> Self {
320 self.settings = Some(settings.into());
321 self
322 }
323
324 #[must_use]
326 pub fn fork_session(mut self) -> Self {
327 self.fork_session = true;
328 self
329 }
330
331 #[must_use]
333 pub fn worktree(mut self) -> Self {
334 self.worktree = true;
335 self
336 }
337
338 #[must_use]
340 pub fn brief(mut self) -> Self {
341 self.brief = true;
342 self
343 }
344
345 #[must_use]
347 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
348 self.debug_filter = Some(filter.into());
349 self
350 }
351
352 #[must_use]
354 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
355 self.debug_file = Some(path.into());
356 self
357 }
358
359 #[must_use]
361 pub fn betas(mut self, betas: impl Into<String>) -> Self {
362 self.betas = Some(betas.into());
363 self
364 }
365
366 #[must_use]
368 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
369 self.plugin_dirs.push(dir.into());
370 self
371 }
372
373 #[must_use]
375 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
376 self.setting_sources = Some(sources.into());
377 self
378 }
379
380 #[must_use]
382 pub fn tmux(mut self) -> Self {
383 self.tmux = true;
384 self
385 }
386
387 #[must_use]
410 pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
411 self.retry_policy = Some(policy);
412 self
413 }
414
415 pub fn to_command_string(&self, claude: &Claude) -> String {
438 let args = self.build_args();
439 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
440 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
441 }
442
443 #[cfg(feature = "json")]
448 pub async fn execute_json(&self, claude: &Claude) -> Result<crate::types::QueryResult> {
449 let mut args = self.build_args();
451
452 if self.output_format.is_none() {
454 args.push("--output-format".to_string());
455 args.push("json".to_string());
456 }
457
458 let output = exec::run_claude_with_retry(claude, args, self.retry_policy.as_ref()).await?;
459
460 serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
461 message: format!("failed to parse query result: {e}"),
462 source: e,
463 })
464 }
465
466 fn build_args(&self) -> Vec<String> {
467 let mut args = vec!["--print".to_string()];
468
469 if let Some(ref model) = self.model {
470 args.push("--model".to_string());
471 args.push(model.clone());
472 }
473
474 if let Some(ref prompt) = self.system_prompt {
475 args.push("--system-prompt".to_string());
476 args.push(prompt.clone());
477 }
478
479 if let Some(ref prompt) = self.append_system_prompt {
480 args.push("--append-system-prompt".to_string());
481 args.push(prompt.clone());
482 }
483
484 if let Some(ref format) = self.output_format {
485 args.push("--output-format".to_string());
486 args.push(format.as_arg().to_string());
487 if matches!(format, OutputFormat::StreamJson) {
489 args.push("--verbose".to_string());
490 }
491 }
492
493 if let Some(budget) = self.max_budget_usd {
494 args.push("--max-budget-usd".to_string());
495 args.push(budget.to_string());
496 }
497
498 if let Some(ref mode) = self.permission_mode {
499 args.push("--permission-mode".to_string());
500 args.push(mode.as_arg().to_string());
501 }
502
503 if !self.allowed_tools.is_empty() {
504 args.push("--allowed-tools".to_string());
505 args.push(self.allowed_tools.join(","));
506 }
507
508 if !self.disallowed_tools.is_empty() {
509 args.push("--disallowed-tools".to_string());
510 args.push(self.disallowed_tools.join(","));
511 }
512
513 for config in &self.mcp_config {
514 args.push("--mcp-config".to_string());
515 args.push(config.clone());
516 }
517
518 for dir in &self.add_dir {
519 args.push("--add-dir".to_string());
520 args.push(dir.clone());
521 }
522
523 if let Some(ref effort) = self.effort {
524 args.push("--effort".to_string());
525 args.push(effort.as_arg().to_string());
526 }
527
528 if let Some(turns) = self.max_turns {
529 args.push("--max-turns".to_string());
530 args.push(turns.to_string());
531 }
532
533 if let Some(ref schema) = self.json_schema {
534 args.push("--json-schema".to_string());
535 args.push(schema.clone());
536 }
537
538 if self.continue_session {
539 args.push("--continue".to_string());
540 }
541
542 if let Some(ref session_id) = self.resume {
543 args.push("--resume".to_string());
544 args.push(session_id.clone());
545 }
546
547 if let Some(ref id) = self.session_id {
548 args.push("--session-id".to_string());
549 args.push(id.clone());
550 }
551
552 if let Some(ref model) = self.fallback_model {
553 args.push("--fallback-model".to_string());
554 args.push(model.clone());
555 }
556
557 if self.no_session_persistence {
558 args.push("--no-session-persistence".to_string());
559 }
560
561 if self.dangerously_skip_permissions {
562 args.push("--dangerously-skip-permissions".to_string());
563 }
564
565 if let Some(ref agent) = self.agent {
566 args.push("--agent".to_string());
567 args.push(agent.clone());
568 }
569
570 if let Some(ref agents) = self.agents_json {
571 args.push("--agents".to_string());
572 args.push(agents.clone());
573 }
574
575 if !self.tools.is_empty() {
576 args.push("--tools".to_string());
577 args.push(self.tools.join(","));
578 }
579
580 for spec in &self.file {
581 args.push("--file".to_string());
582 args.push(spec.clone());
583 }
584
585 if self.include_partial_messages {
586 args.push("--include-partial-messages".to_string());
587 }
588
589 if let Some(ref format) = self.input_format {
590 args.push("--input-format".to_string());
591 args.push(format.as_arg().to_string());
592 }
593
594 if self.strict_mcp_config {
595 args.push("--strict-mcp-config".to_string());
596 }
597
598 if let Some(ref settings) = self.settings {
599 args.push("--settings".to_string());
600 args.push(settings.clone());
601 }
602
603 if self.fork_session {
604 args.push("--fork-session".to_string());
605 }
606
607 if self.worktree {
608 args.push("--worktree".to_string());
609 }
610
611 if self.brief {
612 args.push("--brief".to_string());
613 }
614
615 if let Some(ref filter) = self.debug_filter {
616 args.push("--debug".to_string());
617 args.push(filter.clone());
618 }
619
620 if let Some(ref path) = self.debug_file {
621 args.push("--debug-file".to_string());
622 args.push(path.clone());
623 }
624
625 if let Some(ref betas) = self.betas {
626 args.push("--betas".to_string());
627 args.push(betas.clone());
628 }
629
630 for dir in &self.plugin_dirs {
631 args.push("--plugin-dir".to_string());
632 args.push(dir.clone());
633 }
634
635 if let Some(ref sources) = self.setting_sources {
636 args.push("--setting-sources".to_string());
637 args.push(sources.clone());
638 }
639
640 if self.tmux {
641 args.push("--tmux".to_string());
642 }
643
644 args.push(self.prompt.clone());
646
647 args
648 }
649}
650
651impl ClaudeCommand for QueryCommand {
652 type Output = CommandOutput;
653
654 fn args(&self) -> Vec<String> {
655 self.build_args()
656 }
657
658 async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
659 exec::run_claude_with_retry(claude, self.args(), self.retry_policy.as_ref()).await
660 }
661}
662
663fn shell_quote(arg: &str) -> String {
665 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}".contains(c)) {
667 format!("'{}'", arg.replace("'", "'\\''"))
669 } else {
670 arg.to_string()
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 #[test]
679 fn test_basic_query_args() {
680 let cmd = QueryCommand::new("hello world");
681 let args = cmd.args();
682 assert_eq!(args, vec!["--print", "hello world"]);
683 }
684
685 #[test]
686 fn test_full_query_args() {
687 let cmd = QueryCommand::new("explain this")
688 .model("sonnet")
689 .system_prompt("be concise")
690 .output_format(OutputFormat::Json)
691 .max_budget_usd(0.50)
692 .permission_mode(PermissionMode::BypassPermissions)
693 .allowed_tools(["Bash", "Read"])
694 .mcp_config("/tmp/mcp.json")
695 .effort(Effort::High)
696 .max_turns(3)
697 .no_session_persistence();
698
699 let args = cmd.args();
700 assert!(args.contains(&"--print".to_string()));
701 assert!(args.contains(&"--model".to_string()));
702 assert!(args.contains(&"sonnet".to_string()));
703 assert!(args.contains(&"--system-prompt".to_string()));
704 assert!(args.contains(&"--output-format".to_string()));
705 assert!(args.contains(&"json".to_string()));
706 assert!(!args.contains(&"--verbose".to_string()));
708 assert!(args.contains(&"--max-budget-usd".to_string()));
709 assert!(args.contains(&"--permission-mode".to_string()));
710 assert!(args.contains(&"bypassPermissions".to_string()));
711 assert!(args.contains(&"--allowed-tools".to_string()));
712 assert!(args.contains(&"Bash,Read".to_string()));
713 assert!(args.contains(&"--effort".to_string()));
714 assert!(args.contains(&"high".to_string()));
715 assert!(args.contains(&"--max-turns".to_string()));
716 assert!(args.contains(&"--no-session-persistence".to_string()));
717 assert_eq!(args.last().unwrap(), "explain this");
719 }
720
721 #[test]
722 fn test_stream_json_includes_verbose() {
723 let cmd = QueryCommand::new("test").output_format(OutputFormat::StreamJson);
724 let args = cmd.args();
725 assert!(args.contains(&"--output-format".to_string()));
726 assert!(args.contains(&"stream-json".to_string()));
727 assert!(args.contains(&"--verbose".to_string()));
728 }
729
730 #[test]
731 fn test_to_command_string_simple() {
732 let claude = Claude::builder()
733 .binary("/usr/local/bin/claude")
734 .build()
735 .unwrap();
736
737 let cmd = QueryCommand::new("hello");
738 let command_str = cmd.to_command_string(&claude);
739
740 assert!(command_str.starts_with("/usr/local/bin/claude"));
741 assert!(command_str.contains("--print"));
742 assert!(command_str.contains("hello"));
743 }
744
745 #[test]
746 fn test_to_command_string_with_spaces() {
747 let claude = Claude::builder()
748 .binary("/usr/local/bin/claude")
749 .build()
750 .unwrap();
751
752 let cmd = QueryCommand::new("hello world").model("sonnet");
753 let command_str = cmd.to_command_string(&claude);
754
755 assert!(command_str.starts_with("/usr/local/bin/claude"));
756 assert!(command_str.contains("--print"));
757 assert!(command_str.contains("'hello world'"));
759 assert!(command_str.contains("--model"));
760 assert!(command_str.contains("sonnet"));
761 }
762
763 #[test]
764 fn test_to_command_string_with_special_chars() {
765 let claude = Claude::builder()
766 .binary("/usr/local/bin/claude")
767 .build()
768 .unwrap();
769
770 let cmd = QueryCommand::new("test $VAR and `cmd`");
771 let command_str = cmd.to_command_string(&claude);
772
773 assert!(command_str.contains("'test $VAR and `cmd`'"));
775 }
776
777 #[test]
778 fn test_to_command_string_with_single_quotes() {
779 let claude = Claude::builder()
780 .binary("/usr/local/bin/claude")
781 .build()
782 .unwrap();
783
784 let cmd = QueryCommand::new("it's");
785 let command_str = cmd.to_command_string(&claude);
786
787 assert!(command_str.contains("'it'\\''s'"));
789 }
790
791 #[test]
792 fn test_worktree_flag() {
793 let cmd = QueryCommand::new("test").worktree();
794 let args = cmd.args();
795 assert!(args.contains(&"--worktree".to_string()));
796 }
797
798 #[test]
799 fn test_brief_flag() {
800 let cmd = QueryCommand::new("test").brief();
801 let args = cmd.args();
802 assert!(args.contains(&"--brief".to_string()));
803 }
804
805 #[test]
806 fn test_debug_filter() {
807 let cmd = QueryCommand::new("test").debug_filter("api,hooks");
808 let args = cmd.args();
809 assert!(args.contains(&"--debug".to_string()));
810 assert!(args.contains(&"api,hooks".to_string()));
811 }
812
813 #[test]
814 fn test_debug_file() {
815 let cmd = QueryCommand::new("test").debug_file("/tmp/debug.log");
816 let args = cmd.args();
817 assert!(args.contains(&"--debug-file".to_string()));
818 assert!(args.contains(&"/tmp/debug.log".to_string()));
819 }
820
821 #[test]
822 fn test_betas() {
823 let cmd = QueryCommand::new("test").betas("feature-x");
824 let args = cmd.args();
825 assert!(args.contains(&"--betas".to_string()));
826 assert!(args.contains(&"feature-x".to_string()));
827 }
828
829 #[test]
830 fn test_plugin_dir_single() {
831 let cmd = QueryCommand::new("test").plugin_dir("/plugins/foo");
832 let args = cmd.args();
833 assert!(args.contains(&"--plugin-dir".to_string()));
834 assert!(args.contains(&"/plugins/foo".to_string()));
835 }
836
837 #[test]
838 fn test_plugin_dir_multiple() {
839 let cmd = QueryCommand::new("test")
840 .plugin_dir("/plugins/foo")
841 .plugin_dir("/plugins/bar");
842 let args = cmd.args();
843 let plugin_dir_count = args.iter().filter(|a| *a == "--plugin-dir").count();
844 assert_eq!(plugin_dir_count, 2);
845 assert!(args.contains(&"/plugins/foo".to_string()));
846 assert!(args.contains(&"/plugins/bar".to_string()));
847 }
848
849 #[test]
850 fn test_setting_sources() {
851 let cmd = QueryCommand::new("test").setting_sources("user,project,local");
852 let args = cmd.args();
853 assert!(args.contains(&"--setting-sources".to_string()));
854 assert!(args.contains(&"user,project,local".to_string()));
855 }
856
857 #[test]
858 fn test_tmux_flag() {
859 let cmd = QueryCommand::new("test").tmux();
860 let args = cmd.args();
861 assert!(args.contains(&"--tmux".to_string()));
862 }
863}