Skip to main content

agent_commander/
command_builder.rs

1//! Build command strings for different agent tools
2
3use 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/// Agent command build options
14#[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    /// Approve each mutating command (`--permission-mode ask` / `--approve-each`)
33    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
44/// Check whether a tool has an enforceable native read-only/planning mode.
45pub fn supports_read_only(tool: &str) -> bool {
46    matches!(
47        tool,
48        "claude" | "codex" | "opencode" | "gemini" | "qwen" | "agent"
49    )
50}
51
52/// Build the standard error for tools without enforceable read-only mode.
53pub 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
60/// Escape quotes in strings for shell commands (single quotes)
61fn escape_quotes(s: &str) -> String {
62    s.replace('\'', "'\\''")
63}
64
65/// Escape strings for use inside bash -c "..."
66fn escape_for_bash_c(s: &str) -> String {
67    s.replace('\\', "\\\\")
68        .replace('"', "\\\"")
69        .replace('$', "\\$")
70        .replace('`', "\\`")
71}
72
73/// Build the base tool command (generic)
74fn 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
88/// Build screen isolation command
89fn 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        // Start detached screen session
102        format!(
103            "screen -dmS \"{}\" bash -c '{}'",
104            session_name,
105            escape_quotes(base_command)
106        )
107    } else {
108        // Start attached screen session
109        format!(
110            "screen -S \"{}\" bash -c '{}'",
111            session_name,
112            escape_quotes(base_command)
113        )
114    }
115}
116
117/// Build docker isolation command
118fn 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
154/// Build the command for executing an agent
155///
156/// # Arguments
157/// * `options` - Command options
158///
159/// # Returns
160/// The command string
161pub fn build_agent_command(options: &AgentCommandOptions) -> String {
162    // A planning request implies a read-only restriction for tools that do not
163    // distinguish the two modes.
164    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    // Per-command approval ("ask" mode) is only enforceable on tools that expose
173    // a drivable JSON permission request/response protocol (agent, claude). Fail
174    // clearly for the rest, mirroring the --read-only gate above.
175    assert!(
176        !(options.approve_each && !crate::permissions::supports_ask(&options.tool)),
177        "{}",
178        crate::permissions::ask_unsupported_error(&options.tool)
179    );
180
181    // Build base command using tool-specific builder if available
182    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        // Unknown tool, use generic command builder
293        build_tool_command(
294            &options.tool,
295            options.prompt.as_deref(),
296            options.system_prompt.as_deref(),
297        )
298    };
299
300    // Wrap in bash -c with working directory change
301    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    // Apply isolation wrapper
308    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
330/// Build stop command for screen sessions
331///
332/// # Arguments
333/// * `screen_name` - Screen session name
334///
335/// # Returns
336/// Stop command
337pub fn build_screen_stop_command(screen_name: &str) -> String {
338    format!("screen -S \"{}\" -X quit", screen_name)
339}
340
341/// Build stop command for docker containers
342///
343/// # Arguments
344/// * `container_name` - Container name
345///
346/// # Returns
347/// Stop command
348pub fn build_docker_stop_command(container_name: &str) -> String {
349    format!(
350        "docker stop \"{}\" && docker rm \"{}\"",
351        container_name, container_name
352    )
353}
354
355/// Build stdin piping command for tools that accept input via stdin
356///
357/// # Arguments
358/// * `input` - Input to pipe
359/// * `command` - Command to pipe to
360///
361/// # Returns
362/// Piped command
363pub 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        // Ask mode requires streaming stdin so requests can be answered mid-turn.
873        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        // Default mode keeps Claude's own per-tool prompting active instead of
893        // bypassing it.
894        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}