1use crate::tools::{
4 agent::{self, AgentBuildOptions},
5 claude::{self, ClaudeBuildOptions},
6 codex::{self, CodexBuildOptions},
7 gemini::{self, GeminiBuildOptions},
8 is_tool_supported,
9 opencode::{self, OpencodeBuildOptions},
10 qwen::{self, QwenBuildOptions},
11};
12
13#[derive(Debug, Clone, Default)]
15pub struct AgentCommandOptions {
16 pub tool: String,
17 pub working_directory: String,
18 pub prompt: Option<String>,
19 pub prompt_file: Option<String>,
20 pub system_prompt: Option<String>,
21 pub append_system_prompt: Option<String>,
22 pub model: Option<String>,
23 pub fallback_model: Option<String>,
24 pub json: bool,
25 pub verbose: bool,
26 pub replay_user_messages: bool,
27 pub resume: Option<String>,
28 pub session_id: Option<String>,
29 pub fork_session: bool,
30 pub read_only: bool,
31 pub plan_only: bool,
32 pub approve_each: bool,
34 pub executable: Option<String>,
35 pub extra_args: Vec<String>,
36 pub extra_env: Vec<(String, String)>,
37 pub skip_default_safety_flags: bool,
38 pub isolation: String,
39 pub screen_name: Option<String>,
40 pub container_name: Option<String>,
41 pub detached: bool,
42}
43
44pub fn supports_read_only(tool: &str) -> bool {
46 matches!(
47 tool,
48 "claude" | "codex" | "opencode" | "gemini" | "qwen" | "agent"
49 )
50}
51
52pub fn read_only_unsupported_error(tool: &str) -> String {
54 format!(
55 "Tool \"{}\" does not support enforceable read-only mode. Choose one of: claude, codex, opencode, gemini, qwen, agent; or run without --read-only.",
56 tool
57 )
58}
59
60fn escape_quotes(s: &str) -> String {
62 s.replace('\'', "'\\''")
63}
64
65fn escape_for_bash_c(s: &str) -> String {
67 s.replace('\\', "\\\\")
68 .replace('"', "\\\"")
69 .replace('$', "\\$")
70 .replace('`', "\\`")
71}
72
73fn build_tool_command(tool: &str, prompt: Option<&str>, system_prompt: Option<&str>) -> String {
75 let mut command = tool.to_string();
76
77 if let Some(p) = prompt {
78 command.push_str(&format!(" --prompt \"{}\"", escape_quotes(p)));
79 }
80
81 if let Some(sp) = system_prompt {
82 command.push_str(&format!(" --system-prompt \"{}\"", escape_quotes(sp)));
83 }
84
85 command
86}
87
88fn build_screen_command(base_command: &str, screen_name: Option<&str>, detached: bool) -> String {
90 let session_name = screen_name.map(|s| s.to_string()).unwrap_or_else(|| {
91 format!(
92 "agent-{}",
93 std::time::SystemTime::now()
94 .duration_since(std::time::UNIX_EPOCH)
95 .unwrap()
96 .as_millis()
97 )
98 });
99
100 if detached {
101 format!(
103 "screen -dmS \"{}\" bash -c '{}'",
104 session_name,
105 escape_quotes(base_command)
106 )
107 } else {
108 format!(
110 "screen -S \"{}\" bash -c '{}'",
111 session_name,
112 escape_quotes(base_command)
113 )
114 }
115}
116
117fn build_docker_command(
119 base_command: &str,
120 container_name: Option<&str>,
121 working_directory: &str,
122 detached: bool,
123) -> String {
124 let name = container_name.map(|s| s.to_string()).unwrap_or_else(|| {
125 format!(
126 "agent-{}",
127 std::time::SystemTime::now()
128 .duration_since(std::time::UNIX_EPOCH)
129 .unwrap()
130 .as_millis()
131 )
132 });
133
134 let mut command = "docker run".to_string();
135
136 if detached {
137 command.push_str(" -d");
138 } else {
139 command.push_str(" -it");
140 }
141
142 command.push_str(&format!(" --name \"{}\"", name));
143 command.push_str(&format!(
144 " -v \"{}:{}\"",
145 working_directory, working_directory
146 ));
147 command.push_str(&format!(" -w \"{}\"", working_directory));
148 command.push_str(" node:18-slim");
149 command.push_str(&format!(" bash -c '{}'", escape_quotes(base_command)));
150
151 command
152}
153
154pub fn build_agent_command(options: &AgentCommandOptions) -> String {
162 let read_only_requested = options.read_only || options.plan_only;
165
166 assert!(
167 !(read_only_requested && !supports_read_only(&options.tool)),
168 "{}",
169 read_only_unsupported_error(&options.tool)
170 );
171
172 assert!(
176 !(options.approve_each && !crate::permissions::supports_ask(&options.tool)),
177 "{}",
178 crate::permissions::ask_unsupported_error(&options.tool)
179 );
180
181 let base_command = if is_tool_supported(&options.tool) {
183 match options.tool.as_str() {
184 "claude" => claude::build_command(&ClaudeBuildOptions {
185 prompt: options.prompt.clone(),
186 prompt_file: options.prompt_file.clone(),
187 system_prompt: options.system_prompt.clone(),
188 append_system_prompt: options.append_system_prompt.clone(),
189 model: options.model.clone(),
190 fallback_model: options.fallback_model.clone(),
191 json: options.json,
192 json_input: false,
193 verbose: options.verbose,
194 replay_user_messages: options.replay_user_messages,
195 resume: options.resume.clone(),
196 session_id: options.session_id.clone(),
197 fork_session: options.fork_session,
198 print: false,
199 read_only: read_only_requested,
200 approve_each: options.approve_each,
201 executable: options.executable.clone(),
202 extra_env: options.extra_env.clone(),
203 extra_args: options.extra_args.clone(),
204 skip_default_safety_flags: options.skip_default_safety_flags,
205 permission_mode: None,
206 stream_input: false,
207 }),
208 "codex" => codex::build_command(&CodexBuildOptions {
209 prompt: options.prompt.clone(),
210 prompt_file: options.prompt_file.clone(),
211 system_prompt: options.system_prompt.clone(),
212 model: options.model.clone(),
213 json: options.json,
214 resume: options.resume.clone(),
215 read_only: read_only_requested,
216 executable: options.executable.clone(),
217 extra_env: options.extra_env.clone(),
218 extra_args: options.extra_args.clone(),
219 skip_default_safety_flags: options.skip_default_safety_flags,
220 sandbox_mode: None,
221 approval_mode: None,
222 }),
223 "opencode" => opencode::build_command(&OpencodeBuildOptions {
224 prompt: options.prompt.clone(),
225 prompt_file: options.prompt_file.clone(),
226 system_prompt: options.system_prompt.clone(),
227 model: options.model.clone(),
228 json: options.json,
229 resume: options.resume.clone(),
230 read_only: read_only_requested,
231 executable: options.executable.clone(),
232 extra_env: options.extra_env.clone(),
233 extra_args: options.extra_args.clone(),
234 }),
235 "agent" => agent::build_command(&AgentBuildOptions {
236 prompt: options.prompt.clone(),
237 prompt_file: options.prompt_file.clone(),
238 system_prompt: options.system_prompt.clone(),
239 model: options.model.clone(),
240 compact_json: false,
241 use_existing_claude_oauth: false,
242 read_only: read_only_requested,
243 plan_only: options.plan_only,
244 approve_each: options.approve_each,
245 permission_mode: None,
246 permission: None,
247 stream_input: false,
248 executable: options.executable.clone(),
249 extra_env: options.extra_env.clone(),
250 extra_args: options.extra_args.clone(),
251 }),
252 "gemini" => {
253 let options = GeminiBuildOptions {
254 prompt: options.prompt.clone(),
255 prompt_file: options.prompt_file.clone(),
256 system_prompt: options.system_prompt.clone(),
257 model: options.model.clone(),
258 json: options.json,
259 read_only: read_only_requested,
260 executable: options.executable.clone(),
261 extra_env: options.extra_env.clone(),
262 extra_args: options.extra_args.clone(),
263 skip_default_safety_flags: options.skip_default_safety_flags,
264 ..GeminiBuildOptions::new()
265 };
266 gemini::build_command(&options)
267 }
268 "qwen" => {
269 let options = QwenBuildOptions {
270 prompt: options.prompt.clone(),
271 prompt_file: options.prompt_file.clone(),
272 system_prompt: options.system_prompt.clone(),
273 model: options.model.clone(),
274 json: options.json,
275 resume: options.resume.clone(),
276 read_only: read_only_requested,
277 executable: options.executable.clone(),
278 extra_env: options.extra_env.clone(),
279 extra_args: options.extra_args.clone(),
280 skip_default_safety_flags: options.skip_default_safety_flags,
281 ..QwenBuildOptions::new()
282 };
283 qwen::build_command(&options)
284 }
285 _ => build_tool_command(
286 &options.tool,
287 options.prompt.as_deref(),
288 options.system_prompt.as_deref(),
289 ),
290 }
291 } else {
292 build_tool_command(
294 &options.tool,
295 options.prompt.as_deref(),
296 options.system_prompt.as_deref(),
297 )
298 };
299
300 let mut full_command = format!(
302 "bash -c \"cd {} && {}\"",
303 escape_for_bash_c(&options.working_directory),
304 escape_for_bash_c(&base_command)
305 );
306
307 match options.isolation.as_str() {
309 "screen" => {
310 full_command = build_screen_command(
311 &full_command,
312 options.screen_name.as_deref(),
313 options.detached,
314 );
315 }
316 "docker" => {
317 full_command = build_docker_command(
318 &full_command,
319 options.container_name.as_deref(),
320 &options.working_directory,
321 options.detached,
322 );
323 }
324 _ => {}
325 }
326
327 full_command
328}
329
330pub fn build_screen_stop_command(screen_name: &str) -> String {
338 format!("screen -S \"{}\" -X quit", screen_name)
339}
340
341pub fn build_docker_stop_command(container_name: &str) -> String {
349 format!(
350 "docker stop \"{}\" && docker rm \"{}\"",
351 container_name, container_name
352 )
353}
354
355pub fn build_piped_command(input: &str, command: &str) -> String {
364 let escaped_input = escape_quotes(input);
365 format!("printf '%s' '{}' | {}", escaped_input, command)
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn test_build_agent_command_basic_claude() {
374 let options = AgentCommandOptions {
375 tool: "claude".to_string(),
376 working_directory: "/tmp/test".to_string(),
377 prompt: Some("Hello".to_string()),
378 isolation: "none".to_string(),
379 ..Default::default()
380 };
381
382 let command = build_agent_command(&options);
383 assert!(command.contains("bash -c"));
384 assert!(command.contains("cd"));
385 assert!(command.contains("/tmp/test"));
386 assert!(command.contains("claude"));
387 assert!(command.contains("--prompt"));
388 assert!(command.contains("Hello"));
389 }
390
391 #[test]
392 fn test_build_agent_command_with_system_prompt() {
393 let options = AgentCommandOptions {
394 tool: "claude".to_string(),
395 working_directory: "/tmp/test".to_string(),
396 prompt: Some("Hello".to_string()),
397 system_prompt: Some("You are helpful".to_string()),
398 isolation: "none".to_string(),
399 ..Default::default()
400 };
401
402 let command = build_agent_command(&options);
403 assert!(command.contains("--prompt"));
404 assert!(command.contains("--system-prompt"));
405 assert!(command.contains("You are helpful"));
406 }
407
408 #[test]
409 fn test_build_agent_command_claude_with_fallback_model() {
410 let options = AgentCommandOptions {
411 tool: "claude".to_string(),
412 working_directory: "/tmp/test".to_string(),
413 model: Some("opus".to_string()),
414 fallback_model: Some("sonnet".to_string()),
415 isolation: "none".to_string(),
416 ..Default::default()
417 };
418
419 let command = build_agent_command(&options);
420 assert!(command.contains("--model"));
421 assert!(command.contains("claude-opus-4-7"));
422 assert!(command.contains("--fallback-model"));
423 assert!(command.contains("claude-sonnet-4-6"));
424 }
425
426 #[test]
427 fn test_build_agent_command_claude_with_append_system_prompt() {
428 let options = AgentCommandOptions {
429 tool: "claude".to_string(),
430 working_directory: "/tmp/test".to_string(),
431 append_system_prompt: Some("Extra instructions".to_string()),
432 isolation: "none".to_string(),
433 ..Default::default()
434 };
435
436 let command = build_agent_command(&options);
437 assert!(command.contains("--append-system-prompt"));
438 assert!(command.contains("Extra instructions"));
439 }
440
441 #[test]
442 fn test_build_agent_command_claude_with_session_management() {
443 let options = AgentCommandOptions {
444 tool: "claude".to_string(),
445 working_directory: "/tmp/test".to_string(),
446 resume: Some("abc123".to_string()),
447 session_id: Some("123e4567-e89b-12d3-a456-426614174000".to_string()),
448 fork_session: true,
449 isolation: "none".to_string(),
450 ..Default::default()
451 };
452
453 let command = build_agent_command(&options);
454 assert!(command.contains("--resume"));
455 assert!(command.contains("abc123"));
456 assert!(command.contains("--session-id"));
457 assert!(command.contains("123e4567-e89b-12d3-a456-426614174000"));
458 assert!(command.contains("--fork-session"));
459 }
460
461 #[test]
462 fn test_build_agent_command_claude_with_verbose_streaming() {
463 let options = AgentCommandOptions {
464 tool: "claude".to_string(),
465 working_directory: "/tmp/test".to_string(),
466 verbose: true,
467 replay_user_messages: true,
468 isolation: "none".to_string(),
469 ..Default::default()
470 };
471
472 let command = build_agent_command(&options);
473 assert!(command.contains("--verbose"));
474 assert!(command.contains("--replay-user-messages"));
475 }
476
477 #[test]
478 fn test_build_agent_command_claude_raw_passthrough() {
479 let options = AgentCommandOptions {
480 tool: "claude".to_string(),
481 working_directory: "/tmp/test".to_string(),
482 prompt: Some("Hello".to_string()),
483 executable: Some("/opt/Claude Code/bin/claude".to_string()),
484 extra_env: vec![
485 (
486 "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
487 "1".to_string(),
488 ),
489 ("MCP_TIMEOUT".to_string(), "10000".to_string()),
490 ],
491 extra_args: vec![
492 "--mcp-config".to_string(),
493 "/tmp/mcp config.json".to_string(),
494 "--permission-mode".to_string(),
495 "default".to_string(),
496 ],
497 skip_default_safety_flags: true,
498 isolation: "none".to_string(),
499 ..Default::default()
500 };
501
502 let command = build_agent_command(&options);
503 assert!(command.contains("env"));
504 assert!(command.contains("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1"));
505 assert!(command.contains("MCP_TIMEOUT=10000"));
506 assert!(command.contains("/opt/Claude Code/bin/claude"));
507 assert!(command.contains("--mcp-config"));
508 assert!(command.contains("/tmp/mcp config.json"));
509 assert!(command.contains("--permission-mode"));
510 assert!(command.contains("default"));
511 assert!(!command.contains("--dangerously-skip-permissions"));
512 }
513
514 #[test]
515 fn test_build_agent_command_qwen_raw_passthrough() {
516 let options = AgentCommandOptions {
517 tool: "qwen".to_string(),
518 working_directory: "/tmp/test".to_string(),
519 prompt_file: Some("/tmp/prompt.txt".to_string()),
520 executable: Some("/opt/qwen code/qwen".to_string()),
521 extra_env: vec![("QWEN_HOME".to_string(), "/tmp/qwen home".to_string())],
522 extra_args: vec![
523 "--checkpointing".to_string(),
524 "--approval-mode".to_string(),
525 "default".to_string(),
526 ],
527 skip_default_safety_flags: true,
528 isolation: "none".to_string(),
529 ..Default::default()
530 };
531
532 let command = build_agent_command(&options);
533 assert!(command.contains("cat"));
534 assert!(command.contains("/tmp/prompt.txt"));
535 assert!(command.contains("| env QWEN_HOME="));
536 assert!(command.contains("/tmp/qwen home"));
537 assert!(command.contains("/opt/qwen code/qwen"));
538 assert!(command.contains("--checkpointing"));
539 assert!(command.contains("--approval-mode"));
540 assert!(command.contains("default"));
541 assert!(!command.contains("--yolo"));
542 }
543
544 #[test]
545 fn test_build_agent_command_gemini_raw_passthrough() {
546 let options = AgentCommandOptions {
547 tool: "gemini".to_string(),
548 working_directory: "/tmp/test".to_string(),
549 prompt_file: Some("/tmp/prompt.txt".to_string()),
550 executable: Some("/opt/gemini cli/gemini".to_string()),
551 extra_env: vec![("GEMINI_HOME".to_string(), "/tmp/gemini home".to_string())],
552 extra_args: vec!["--telemetry".to_string(), "false".to_string()],
553 skip_default_safety_flags: true,
554 isolation: "none".to_string(),
555 ..Default::default()
556 };
557
558 let command = build_agent_command(&options);
559 assert!(command.contains("cat"));
560 assert!(command.contains("/tmp/prompt.txt"));
561 assert!(command.contains("| env GEMINI_HOME="));
562 assert!(command.contains("/tmp/gemini home"));
563 assert!(command.contains("/opt/gemini cli/gemini"));
564 assert!(command.contains("--telemetry"));
565 assert!(command.contains("false"));
566 assert!(!command.contains("--yolo"));
567 }
568
569 #[test]
570 fn test_build_agent_command_unknown_tool() {
571 let options = AgentCommandOptions {
572 tool: "unknown-tool".to_string(),
573 working_directory: "/tmp/test".to_string(),
574 prompt: Some("Hello".to_string()),
575 isolation: "none".to_string(),
576 ..Default::default()
577 };
578
579 let command = build_agent_command(&options);
580 assert!(command.contains("bash -c"));
581 assert!(command.contains("unknown-tool"));
582 assert!(command.contains("--prompt"));
583 }
584
585 #[test]
586 fn test_build_agent_command_screen_isolation() {
587 let options = AgentCommandOptions {
588 tool: "claude".to_string(),
589 working_directory: "/tmp/test".to_string(),
590 isolation: "screen".to_string(),
591 screen_name: Some("my-session".to_string()),
592 detached: true,
593 ..Default::default()
594 };
595
596 let command = build_agent_command(&options);
597 assert!(command.contains("screen"));
598 assert!(command.contains("-dmS"));
599 assert!(command.contains("my-session"));
600 }
601
602 #[test]
603 fn test_build_agent_command_docker_isolation() {
604 let options = AgentCommandOptions {
605 tool: "claude".to_string(),
606 working_directory: "/tmp/test".to_string(),
607 isolation: "docker".to_string(),
608 container_name: Some("my-container".to_string()),
609 detached: true,
610 ..Default::default()
611 };
612
613 let command = build_agent_command(&options);
614 assert!(command.contains("docker run"));
615 assert!(command.contains("-d"));
616 assert!(command.contains("--name \"my-container\""));
617 assert!(command.contains("-v \"/tmp/test:/tmp/test\""));
618 }
619
620 #[test]
621 fn test_build_agent_command_with_model() {
622 let options = AgentCommandOptions {
623 tool: "claude".to_string(),
624 working_directory: "/tmp/test".to_string(),
625 model: Some("opus".to_string()),
626 isolation: "none".to_string(),
627 ..Default::default()
628 };
629
630 let command = build_agent_command(&options);
631 assert!(command.contains("--model"));
632 assert!(command.contains("claude-opus-4-7"));
633 }
634
635 #[test]
636 fn test_build_agent_command_codex() {
637 let options = AgentCommandOptions {
638 tool: "codex".to_string(),
639 working_directory: "/tmp/test".to_string(),
640 prompt: Some("Hello".to_string()),
641 json: true,
642 isolation: "none".to_string(),
643 ..Default::default()
644 };
645
646 let command = build_agent_command(&options);
647 assert!(command.contains("codex"));
648 assert!(command.contains("exec"));
649 assert!(command.contains("--json"));
650 }
651
652 #[test]
653 fn test_build_agent_command_codex_prompt_file() {
654 let inline_prompt = "Secret prompt with 'quotes', $HOME, and `pwd`";
655 let options = AgentCommandOptions {
656 tool: "codex".to_string(),
657 working_directory: "/tmp/test".to_string(),
658 prompt: Some(inline_prompt.to_string()),
659 system_prompt: Some("System instructions".to_string()),
660 prompt_file: Some("/tmp/agent prompt.txt".to_string()),
661 json: true,
662 isolation: "none".to_string(),
663 ..Default::default()
664 };
665
666 let command = build_agent_command(&options);
667 assert!(command.contains("cat"));
668 assert!(command.contains("/tmp/agent prompt.txt"));
669 assert!(command.contains("codex"));
670 assert!(!command.contains(inline_prompt));
671 assert!(!command.contains("System instructions"));
672 }
673
674 #[test]
675 fn test_build_agent_command_claude_prompt_file() {
676 let inline_prompt = "Secret prompt with 'quotes', $HOME, and `pwd`";
677 let options = AgentCommandOptions {
678 tool: "claude".to_string(),
679 working_directory: "/tmp/test".to_string(),
680 prompt: Some(inline_prompt.to_string()),
681 system_prompt: Some("You are helpful".to_string()),
682 prompt_file: Some("/tmp/agent prompt.txt".to_string()),
683 isolation: "none".to_string(),
684 ..Default::default()
685 };
686
687 let command = build_agent_command(&options);
688 assert!(command.contains("cat"));
689 assert!(command.contains("/tmp/agent prompt.txt"));
690 assert!(command.contains("claude"));
691 assert!(command.contains("--system-prompt"));
692 assert!(command.contains("You are helpful"));
693 assert!(!command.contains(inline_prompt));
694 }
695
696 #[test]
697 fn test_build_agent_command_qwen_prompt_file() {
698 let inline_prompt = "Secret prompt with 'quotes', $HOME, and `pwd`";
699 let options = AgentCommandOptions {
700 tool: "qwen".to_string(),
701 working_directory: "/tmp/test".to_string(),
702 prompt: Some(inline_prompt.to_string()),
703 system_prompt: Some("System instructions".to_string()),
704 prompt_file: Some("/tmp/agent prompt.txt".to_string()),
705 isolation: "none".to_string(),
706 ..Default::default()
707 };
708
709 let command = build_agent_command(&options);
710 assert!(command.contains("cat"));
711 assert!(command.contains("/tmp/agent prompt.txt"));
712 assert!(command.contains("qwen"));
713 assert!(!command.contains(inline_prompt));
714 assert!(!command.contains("System instructions"));
715 }
716
717 #[test]
718 fn test_build_agent_command_gemini_prompt_file() {
719 let inline_prompt = "Secret prompt with 'quotes', $HOME, and `pwd`";
720 let options = AgentCommandOptions {
721 tool: "gemini".to_string(),
722 working_directory: "/tmp/test".to_string(),
723 prompt: Some(inline_prompt.to_string()),
724 system_prompt: Some("System instructions".to_string()),
725 prompt_file: Some("/tmp/agent prompt.txt".to_string()),
726 isolation: "none".to_string(),
727 ..Default::default()
728 };
729
730 let command = build_agent_command(&options);
731 assert!(command.contains("cat"));
732 assert!(command.contains("/tmp/agent prompt.txt"));
733 assert!(command.contains("gemini"));
734 assert!(!command.contains(inline_prompt));
735 assert!(!command.contains("System instructions"));
736 }
737
738 #[test]
739 fn test_build_agent_command_opencode() {
740 let options = AgentCommandOptions {
741 tool: "opencode".to_string(),
742 working_directory: "/tmp/test".to_string(),
743 prompt: Some("Hello".to_string()),
744 json: true,
745 isolation: "none".to_string(),
746 ..Default::default()
747 };
748
749 let command = build_agent_command(&options);
750 assert!(command.contains("opencode"));
751 assert!(command.contains("run"));
752 assert!(command.contains("--format"));
753 }
754
755 #[test]
756 fn test_build_agent_command_agent_tool() {
757 let options = AgentCommandOptions {
758 tool: "agent".to_string(),
759 working_directory: "/tmp/test".to_string(),
760 model: Some("grok".to_string()),
761 isolation: "none".to_string(),
762 ..Default::default()
763 };
764
765 let command = build_agent_command(&options);
766 assert!(command.contains("agent"));
767 assert!(command.contains("--model"));
768 assert!(command.contains("opencode/grok-code"));
769 }
770
771 #[test]
772 fn test_build_agent_command_claude_read_only() {
773 let options = AgentCommandOptions {
774 tool: "claude".to_string(),
775 working_directory: "/tmp/test".to_string(),
776 prompt: Some("Plan only".to_string()),
777 read_only: true,
778 isolation: "none".to_string(),
779 ..Default::default()
780 };
781
782 let command = build_agent_command(&options);
783 assert!(command.contains("--permission-mode"));
784 assert!(command.contains("plan"));
785 assert!(!command.contains("--dangerously-skip-permissions"));
786 }
787
788 #[test]
789 fn test_build_agent_command_codex_read_only() {
790 let options = AgentCommandOptions {
791 tool: "codex".to_string(),
792 working_directory: "/tmp/test".to_string(),
793 prompt: Some("Plan only".to_string()),
794 read_only: true,
795 isolation: "none".to_string(),
796 ..Default::default()
797 };
798
799 let command = build_agent_command(&options);
800 assert!(command.contains("codex --ask-for-approval never exec"));
801 assert!(command.contains("--sandbox"));
802 assert!(command.contains("read-only"));
803 assert!(!command.contains("--dangerously-bypass-approvals-and-sandbox"));
804 }
805
806 #[test]
807 fn test_build_agent_command_opencode_read_only() {
808 let options = AgentCommandOptions {
809 tool: "opencode".to_string(),
810 working_directory: "/tmp/test".to_string(),
811 prompt: Some("Plan only".to_string()),
812 read_only: true,
813 isolation: "none".to_string(),
814 ..Default::default()
815 };
816
817 let command = build_agent_command(&options);
818 assert!(command.contains("OPENCODE_PERMISSION="));
819 assert!(command.contains("bash"));
820 assert!(command.contains("edit"));
821 assert!(command.contains("deny"));
822 }
823
824 #[test]
825 fn test_build_agent_command_agent_read_only_uses_readonly_mode() {
826 let options = AgentCommandOptions {
827 tool: "agent".to_string(),
828 working_directory: "/tmp/test".to_string(),
829 prompt: Some("Inspect only".to_string()),
830 read_only: true,
831 isolation: "none".to_string(),
832 ..Default::default()
833 };
834
835 let command = build_agent_command(&options);
836 assert!(command.contains("--permission-mode"));
837 assert!(command.contains("readonly"));
838 assert!(!command.contains("plan"));
839 }
840
841 #[test]
842 fn test_build_agent_command_agent_plan_only_uses_plan_mode() {
843 let options = AgentCommandOptions {
844 tool: "agent".to_string(),
845 working_directory: "/tmp/test".to_string(),
846 prompt: Some("Plan only".to_string()),
847 plan_only: true,
848 isolation: "none".to_string(),
849 ..Default::default()
850 };
851
852 let command = build_agent_command(&options);
853 assert!(command.contains("--permission-mode"));
854 assert!(command.contains("plan"));
855 assert!(!command.contains("readonly"));
856 }
857
858 #[test]
859 fn test_build_agent_command_agent_approve_each_uses_ask_mode() {
860 let options = AgentCommandOptions {
861 tool: "agent".to_string(),
862 working_directory: "/tmp/test".to_string(),
863 prompt: Some("Do work".to_string()),
864 approve_each: true,
865 isolation: "none".to_string(),
866 ..Default::default()
867 };
868
869 let command = build_agent_command(&options);
870 assert!(command.contains("--permission-mode"));
871 assert!(command.contains("ask"));
872 assert!(command.contains("--input-format"));
874 assert!(command.contains("stream-json"));
875 assert!(!command.contains("readonly"));
876 }
877
878 #[test]
879 fn test_build_agent_command_claude_approve_each_uses_default_mode() {
880 let options = AgentCommandOptions {
881 tool: "claude".to_string(),
882 working_directory: "/tmp/test".to_string(),
883 prompt: Some("Do work".to_string()),
884 approve_each: true,
885 isolation: "none".to_string(),
886 ..Default::default()
887 };
888
889 let command = build_agent_command(&options);
890 assert!(command.contains("--permission-mode"));
891 assert!(command.contains("default"));
892 assert!(!command.contains("--dangerously-skip-permissions"));
895 assert!(!command.contains("plan"));
896 }
897
898 #[test]
899 #[should_panic(expected = "does not support enforceable per-command approval")]
900 fn test_build_agent_command_approve_each_rejects_codex() {
901 let options = AgentCommandOptions {
902 tool: "codex".to_string(),
903 working_directory: "/tmp/test".to_string(),
904 approve_each: true,
905 isolation: "none".to_string(),
906 ..Default::default()
907 };
908
909 let _command = build_agent_command(&options);
910 }
911
912 #[test]
913 #[should_panic(expected = "does not support enforceable per-command approval")]
914 fn test_build_agent_command_approve_each_rejects_unknown_tool() {
915 let options = AgentCommandOptions {
916 tool: "unknown-tool".to_string(),
917 working_directory: "/tmp/test".to_string(),
918 approve_each: true,
919 isolation: "none".to_string(),
920 ..Default::default()
921 };
922
923 let _command = build_agent_command(&options);
924 }
925
926 #[test]
927 #[should_panic(expected = "does not support enforceable read-only mode")]
928 fn test_build_agent_command_read_only_rejects_unknown_tool() {
929 let options = AgentCommandOptions {
930 tool: "unknown-tool".to_string(),
931 working_directory: "/tmp/test".to_string(),
932 read_only: true,
933 isolation: "none".to_string(),
934 ..Default::default()
935 };
936
937 let _command = build_agent_command(&options);
938 }
939
940 #[test]
941 fn test_build_screen_stop_command() {
942 let command = build_screen_stop_command("my-session");
943 assert!(command.contains("screen"));
944 assert!(command.contains("-S \"my-session\""));
945 assert!(command.contains("-X quit"));
946 }
947
948 #[test]
949 fn test_build_docker_stop_command() {
950 let command = build_docker_stop_command("my-container");
951 assert!(command.contains("docker stop \"my-container\""));
952 assert!(command.contains("docker rm \"my-container\""));
953 }
954
955 #[test]
956 fn test_build_piped_command_basic() {
957 let command = build_piped_command("Hello World", "mycommand --flag");
958 assert!(command.contains("printf '%s'"));
959 assert!(command.contains("Hello World"));
960 assert!(command.contains("mycommand --flag"));
961 }
962
963 #[test]
964 fn test_build_piped_command_escapes_quotes() {
965 let command = build_piped_command("It's working", "mycommand");
966 assert!(command.contains("'\\''"));
967 }
968}