Skip to main content

agent_commander/
cli_parser.rs

1//! Parse CLI arguments
2//! Simple argument parser without external dependencies
3
4use std::collections::HashMap;
5
6const VALUE_OPTION_KEYS: &[&str] = &["tool-arg"];
7
8/// Parsed command line arguments
9#[derive(Debug, Clone, Default)]
10pub struct ParsedArgs {
11    pub options: HashMap<String, Vec<String>>,
12    pub flags: Vec<String>,
13    pub positional: Vec<String>,
14}
15
16impl ParsedArgs {
17    /// Get an option value
18    pub fn get(&self, key: &str) -> Option<&String> {
19        self.options.get(key).and_then(|values| values.last())
20    }
21
22    /// Get all option values
23    pub fn get_all(&self, key: &str) -> Vec<String> {
24        self.options.get(key).cloned().unwrap_or_default()
25    }
26
27    /// Check if a flag is set
28    pub fn has_flag(&self, key: &str) -> bool {
29        self.flags.contains(&key.to_string()) || self.options.contains_key(key)
30    }
31
32    /// Get an option as boolean (flag presence)
33    pub fn get_bool(&self, key: &str) -> bool {
34        self.has_flag(key)
35    }
36}
37
38/// Parse command line arguments
39///
40/// # Arguments
41/// * `args` - Process arguments
42///
43/// # Returns
44/// Parsed options
45pub fn parse_args(args: &[String]) -> ParsedArgs {
46    let mut parsed = ParsedArgs::default();
47    let mut i = 0;
48
49    while i < args.len() {
50        let arg = &args[i];
51
52        if arg.starts_with("--") {
53            let key = arg.trim_start_matches("--").to_string();
54            let next_arg = args.get(i + 1);
55
56            // Check if it's a flag (boolean) or has a value
57            if let Some(next) = next_arg {
58                if !next.starts_with("--") || VALUE_OPTION_KEYS.contains(&key.as_str()) {
59                    parsed.options.entry(key).or_default().push(next.clone());
60                    i += 1; // Skip next arg as it's the value
61                } else {
62                    parsed.flags.push(key);
63                }
64            } else {
65                parsed.flags.push(key);
66            }
67        } else {
68            parsed.positional.push(arg.clone());
69        }
70
71        i += 1;
72    }
73
74    parsed
75}
76
77/// Start agent options
78#[derive(Debug, Clone, Default)]
79pub struct StartAgentOptions {
80    pub tool: Option<String>,
81    pub working_directory: Option<String>,
82    pub prompt: Option<String>,
83    pub prompt_file: Option<String>,
84    pub system_prompt: Option<String>,
85    pub append_system_prompt: Option<String>,
86    pub model: Option<String>,
87    pub fallback_model: Option<String>,
88    pub verbose: bool,
89    pub replay_user_messages: bool,
90    pub read_only: bool,
91    pub plan_only: bool,
92    /// Approve each command (ask mode); relayable only for: claude, agent
93    pub approve_each: bool,
94    pub resume: Option<String>,
95    pub session_id: Option<String>,
96    pub fork_session: bool,
97    pub tool_executable: Option<String>,
98    pub tool_args: Vec<String>,
99    pub tool_env: Vec<String>,
100    pub skip_default_safety_flags: bool,
101    pub isolation: String,
102    pub screen_name: Option<String>,
103    pub container_name: Option<String>,
104    pub dry_run: bool,
105    pub detached: bool,
106    pub attached: bool,
107    pub help: bool,
108}
109
110/// Stop agent options
111#[derive(Debug, Clone, Default)]
112pub struct StopAgentOptions {
113    pub isolation: Option<String>,
114    pub screen_name: Option<String>,
115    pub container_name: Option<String>,
116    pub dry_run: bool,
117    pub help: bool,
118}
119
120/// Parse start-agent CLI arguments
121///
122/// # Arguments
123/// * `args` - Process arguments
124///
125/// # Returns
126/// Parsed configuration
127pub fn parse_start_agent_args(args: &[String]) -> StartAgentOptions {
128    let parsed = parse_args(args);
129
130    let detached = parsed.get_bool("detached");
131    let isolation = parsed
132        .get("isolation")
133        .cloned()
134        .unwrap_or_else(|| "none".to_string());
135
136    StartAgentOptions {
137        tool: parsed.get("tool").cloned(),
138        working_directory: parsed.get("working-directory").cloned(),
139        prompt: parsed.get("prompt").cloned(),
140        prompt_file: parsed.get("prompt-file").cloned(),
141        system_prompt: parsed.get("system-prompt").cloned(),
142        append_system_prompt: parsed.get("append-system-prompt").cloned(),
143        model: parsed.get("model").cloned(),
144        fallback_model: parsed.get("fallback-model").cloned(),
145        verbose: parsed.get_bool("verbose"),
146        replay_user_messages: parsed.get_bool("replay-user-messages"),
147        read_only: parsed.get_bool("read-only") || parsed.get_bool("plan-only"),
148        plan_only: parsed.get_bool("plan-only"),
149        approve_each: parsed.get_bool("approve-each")
150            || parsed
151                .get("permission-mode")
152                .map(|m| m == "ask")
153                .unwrap_or(false),
154        resume: parsed.get("resume").cloned(),
155        session_id: parsed.get("session-id").cloned(),
156        fork_session: parsed.get_bool("fork-session"),
157        tool_executable: parsed.get("tool-executable").cloned(),
158        tool_args: parsed.get_all("tool-arg"),
159        tool_env: parsed.get_all("tool-env"),
160        skip_default_safety_flags: parsed.get_bool("skip-default-safety-flags"),
161        isolation,
162        screen_name: parsed.get("screen-name").cloned(),
163        container_name: parsed.get("container-name").cloned(),
164        dry_run: parsed.get_bool("dry-run"),
165        detached,
166        attached: !detached, // Default is attached unless detached is specified
167        help: parsed.get_bool("help") || parsed.get_bool("h"),
168    }
169}
170
171/// Parse stop-agent CLI arguments
172///
173/// # Arguments
174/// * `args` - Process arguments
175///
176/// # Returns
177/// Parsed configuration
178pub fn parse_stop_agent_args(args: &[String]) -> StopAgentOptions {
179    let parsed = parse_args(args);
180
181    StopAgentOptions {
182        isolation: parsed.get("isolation").cloned(),
183        screen_name: parsed.get("screen-name").cloned(),
184        container_name: parsed.get("container-name").cloned(),
185        dry_run: parsed.get_bool("dry-run"),
186        help: parsed.get_bool("help") || parsed.get_bool("h"),
187    }
188}
189
190/// Show start-agent help message
191pub fn show_start_agent_help() {
192    println!(
193        r#"
194Usage: start-agent [options]
195
196Options:
197  --tool <name>                    CLI tool to use (e.g., 'claude') [required]
198  --working-directory <path>       Working directory for the agent [required]
199  --prompt <text>                  Prompt for the agent
200  --prompt-file <path>             Read prompt input from a file
201  --system-prompt <text>           System prompt for the agent
202  --append-system-prompt <text>    Append to the default system prompt
203  --model <model>                  Model to use (e.g., 'sonnet', 'opus', 'haiku')
204  --fallback-model <model>         Fallback model when default is overloaded
205  --verbose                        Enable verbose mode
206  --read-only                      Enforce native read-only mode (agent: --permission-mode readonly)
207  --plan-only                      Enforce native planning mode (agent: --permission-mode plan)
208  --approve-each                   Approve each command (ask mode); relayable for: claude, agent
209  --permission-mode ask            Alias for --approve-each
210  --resume <sessionId>             Resume a previous session by ID
211  --session-id <uuid>              Use a specific session ID (must be valid UUID)
212  --fork-session                   Create new session ID when resuming
213  --replay-user-messages           Re-emit user messages on stdout (streaming mode)
214  --tool-executable <path>         Override the tool executable path/name
215  --tool-env <KEY=VALUE>           Add an environment variable for the tool (repeatable)
216  --tool-arg <arg>                 Append a raw argument to the tool command (repeatable)
217  --skip-default-safety-flags      Do not add default autonomous safety bypass flags
218  --isolation <mode>               Isolation mode: none, screen, docker (default: none)
219  --screen-name <name>             Screen session name (required for screen isolation)
220  --container-name <name>          Container name (required for docker isolation)
221  --detached                       Run in detached mode
222  --dry-run                        Show command without executing
223  --help, -h                       Show this help message
224
225Examples:
226  # Basic usage (no isolation)
227  start-agent --tool claude --working-directory "/tmp/dir" --prompt "Hello"
228
229  # With model selection
230  start-agent --tool claude --working-directory "/tmp/dir" \
231    --prompt "Hello" --model opus --fallback-model sonnet
232
233  # Resume a session with fork
234  start-agent --tool claude --working-directory "/tmp/dir" \
235    --resume abc123 --fork-session
236
237  # Read-only planning mode
238  start-agent --tool claude --working-directory "/tmp/dir" \
239    --prompt "Inspect this project" --read-only
240
241  # With screen isolation (detached)
242  start-agent --tool claude --working-directory "/tmp/dir" \
243    --isolation screen --screen-name my-agent --detached
244
245  # With docker isolation (attached)
246  start-agent --tool claude --working-directory "/tmp/dir" \
247    --isolation docker --container-name my-container
248
249  # Dry run
250  start-agent --tool claude --working-directory "/tmp/dir" --dry-run
251"#
252    );
253}
254
255/// Show stop-agent help message
256pub fn show_stop_agent_help() {
257    println!(
258        r#"
259Usage: stop-agent [options]
260
261Options:
262  --isolation <mode>               Isolation mode: screen, docker [required]
263  --screen-name <name>             Screen session name (required for screen isolation)
264  --container-name <name>          Container name (required for docker isolation)
265  --dry-run                        Show command without executing
266  --help, -h                       Show this help message
267
268Examples:
269  # Stop screen session
270  stop-agent --isolation screen --screen-name my-agent
271
272  # Stop docker container
273  stop-agent --isolation docker --container-name my-container
274
275  # Dry run
276  stop-agent --isolation screen --screen-name my-agent --dry-run
277"#
278    );
279}
280
281/// Validation result
282#[derive(Debug, Clone)]
283pub struct ValidationResult {
284    pub valid: bool,
285    pub errors: Vec<String>,
286}
287
288/// Validate start-agent options
289///
290/// # Arguments
291/// * `options` - Parsed options
292///
293/// # Returns
294/// Validation result with valid flag and errors
295pub fn validate_start_agent_options(options: &StartAgentOptions) -> ValidationResult {
296    let mut errors = Vec::new();
297
298    if options.tool.is_none() {
299        errors.push("--tool is required".to_string());
300    }
301
302    if options.working_directory.is_none() {
303        errors.push("--working-directory is required".to_string());
304    }
305
306    if options.isolation == "screen" && options.screen_name.is_none() {
307        errors.push("--screen-name is required for screen isolation".to_string());
308    }
309
310    if options.isolation == "docker" && options.container_name.is_none() {
311        errors.push("--container-name is required for docker isolation".to_string());
312    }
313
314    if !["none", "screen", "docker"].contains(&options.isolation.as_str()) {
315        errors.push("--isolation must be one of: none, screen, docker".to_string());
316    }
317
318    ValidationResult {
319        valid: errors.is_empty(),
320        errors,
321    }
322}
323
324/// Validate stop-agent options
325///
326/// # Arguments
327/// * `options` - Parsed options
328///
329/// # Returns
330/// Validation result with valid flag and errors
331pub fn validate_stop_agent_options(options: &StopAgentOptions) -> ValidationResult {
332    let mut errors = Vec::new();
333
334    if options.isolation.is_none() {
335        errors.push("--isolation is required".to_string());
336    }
337
338    if let Some(ref isolation) = options.isolation {
339        if !["screen", "docker"].contains(&isolation.as_str()) {
340            errors.push("--isolation must be one of: screen, docker".to_string());
341        }
342
343        if isolation == "screen" && options.screen_name.is_none() {
344            errors.push("--screen-name is required for screen isolation".to_string());
345        }
346
347        if isolation == "docker" && options.container_name.is_none() {
348            errors.push("--container-name is required for docker isolation".to_string());
349        }
350    }
351
352    ValidationResult {
353        valid: errors.is_empty(),
354        errors,
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_parse_args_basic_flags() {
364        let args: Vec<String> = vec!["--foo".into(), "bar".into(), "--baz".into()];
365        let result = parse_args(&args);
366
367        assert_eq!(result.get("foo"), Some(&"bar".to_string()));
368        assert!(result.has_flag("baz"));
369    }
370
371    #[test]
372    fn test_parse_args_with_positional() {
373        let args: Vec<String> = vec!["--foo".into(), "bar".into(), "positional1".into()];
374        let result = parse_args(&args);
375
376        assert_eq!(result.get("foo"), Some(&"bar".to_string()));
377        assert_eq!(result.positional, vec!["positional1"]);
378    }
379
380    #[test]
381    fn test_parse_start_agent_args_basic() {
382        let args: Vec<String> = vec![
383            "--tool".into(),
384            "claude".into(),
385            "--working-directory".into(),
386            "/tmp/test".into(),
387            "--prompt".into(),
388            "Hello".into(),
389        ];
390        let result = parse_start_agent_args(&args);
391
392        assert_eq!(result.tool, Some("claude".to_string()));
393        assert_eq!(result.working_directory, Some("/tmp/test".to_string()));
394        assert_eq!(result.prompt, Some("Hello".to_string()));
395        assert_eq!(result.isolation, "none");
396    }
397
398    #[test]
399    fn test_parse_start_agent_args_with_isolation() {
400        let args: Vec<String> = vec![
401            "--tool".into(),
402            "claude".into(),
403            "--working-directory".into(),
404            "/tmp/test".into(),
405            "--isolation".into(),
406            "screen".into(),
407            "--screen-name".into(),
408            "my-session".into(),
409        ];
410        let result = parse_start_agent_args(&args);
411
412        assert_eq!(result.isolation, "screen");
413        assert_eq!(result.screen_name, Some("my-session".to_string()));
414    }
415
416    #[test]
417    fn test_parse_start_agent_args_dry_run() {
418        let args: Vec<String> = vec![
419            "--tool".into(),
420            "claude".into(),
421            "--working-directory".into(),
422            "/tmp/test".into(),
423            "--dry-run".into(),
424        ];
425        let result = parse_start_agent_args(&args);
426
427        assert!(result.dry_run);
428    }
429
430    #[test]
431    fn test_parse_start_agent_args_read_only() {
432        let args: Vec<String> = vec![
433            "--tool".into(),
434            "claude".into(),
435            "--working-directory".into(),
436            "/tmp/test".into(),
437            "--read-only".into(),
438        ];
439        let result = parse_start_agent_args(&args);
440
441        assert!(result.read_only);
442    }
443
444    #[test]
445    fn test_parse_start_agent_args_with_raw_passthrough_options() {
446        let args: Vec<String> = vec![
447            "--tool".into(),
448            "claude".into(),
449            "--working-directory".into(),
450            "/tmp/test".into(),
451            "--tool-executable".into(),
452            "/opt/claude".into(),
453            "--tool-env".into(),
454            "MCP_TIMEOUT=10000".into(),
455            "--tool-env".into(),
456            "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1".into(),
457            "--tool-arg".into(),
458            "--mcp-config".into(),
459            "--tool-arg".into(),
460            "/tmp/mcp.json".into(),
461            "--skip-default-safety-flags".into(),
462        ];
463        let result = parse_start_agent_args(&args);
464
465        assert_eq!(result.tool_executable, Some("/opt/claude".to_string()));
466        assert_eq!(
467            result.tool_env,
468            vec![
469                "MCP_TIMEOUT=10000".to_string(),
470                "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1".to_string()
471            ]
472        );
473        assert_eq!(
474            result.tool_args,
475            vec!["--mcp-config".to_string(), "/tmp/mcp.json".to_string()]
476        );
477        assert!(result.skip_default_safety_flags);
478    }
479
480    #[test]
481    fn test_parse_start_agent_args_approve_each() {
482        let args: Vec<String> = vec![
483            "--tool".into(),
484            "claude".into(),
485            "--working-directory".into(),
486            "/tmp/test".into(),
487            "--approve-each".into(),
488        ];
489        let result = parse_start_agent_args(&args);
490
491        assert!(result.approve_each);
492    }
493
494    #[test]
495    fn test_parse_start_agent_args_permission_mode_ask_alias() {
496        let args: Vec<String> = vec![
497            "--tool".into(),
498            "claude".into(),
499            "--working-directory".into(),
500            "/tmp/test".into(),
501            "--permission-mode".into(),
502            "ask".into(),
503        ];
504        let result = parse_start_agent_args(&args);
505
506        assert!(result.approve_each);
507    }
508
509    #[test]
510    fn test_parse_start_agent_args_permission_mode_other_is_not_approve_each() {
511        let args: Vec<String> = vec![
512            "--tool".into(),
513            "claude".into(),
514            "--working-directory".into(),
515            "/tmp/test".into(),
516            "--permission-mode".into(),
517            "default".into(),
518        ];
519        let result = parse_start_agent_args(&args);
520
521        assert!(!result.approve_each);
522    }
523
524    #[test]
525    fn test_parse_start_agent_args_plan_only_alias() {
526        let args: Vec<String> = vec![
527            "--tool".into(),
528            "claude".into(),
529            "--working-directory".into(),
530            "/tmp/test".into(),
531            "--plan-only".into(),
532        ];
533        let result = parse_start_agent_args(&args);
534
535        assert!(result.read_only);
536    }
537
538    #[test]
539    fn test_parse_start_agent_args_with_model_and_fallback_model() {
540        let args: Vec<String> = vec![
541            "--tool".into(),
542            "claude".into(),
543            "--working-directory".into(),
544            "/tmp/test".into(),
545            "--model".into(),
546            "opus".into(),
547            "--fallback-model".into(),
548            "sonnet".into(),
549        ];
550        let result = parse_start_agent_args(&args);
551
552        assert_eq!(result.model, Some("opus".to_string()));
553        assert_eq!(result.fallback_model, Some("sonnet".to_string()));
554    }
555
556    #[test]
557    fn test_parse_start_agent_args_with_session_management_options() {
558        let args: Vec<String> = vec![
559            "--tool".into(),
560            "claude".into(),
561            "--working-directory".into(),
562            "/tmp/test".into(),
563            "--resume".into(),
564            "abc123".into(),
565            "--session-id".into(),
566            "123e4567-e89b-12d3-a456-426614174000".into(),
567            "--fork-session".into(),
568        ];
569        let result = parse_start_agent_args(&args);
570
571        assert_eq!(result.resume, Some("abc123".to_string()));
572        assert_eq!(
573            result.session_id,
574            Some("123e4567-e89b-12d3-a456-426614174000".to_string())
575        );
576        assert!(result.fork_session);
577    }
578
579    #[test]
580    fn test_parse_start_agent_args_with_append_system_prompt() {
581        let args: Vec<String> = vec![
582            "--tool".into(),
583            "claude".into(),
584            "--working-directory".into(),
585            "/tmp/test".into(),
586            "--system-prompt".into(),
587            "You are helpful".into(),
588            "--append-system-prompt".into(),
589            "Extra instructions".into(),
590        ];
591        let result = parse_start_agent_args(&args);
592
593        assert_eq!(result.system_prompt, Some("You are helpful".to_string()));
594        assert_eq!(
595            result.append_system_prompt,
596            Some("Extra instructions".to_string())
597        );
598    }
599
600    #[test]
601    fn test_parse_start_agent_args_with_prompt_file() {
602        let args: Vec<String> = vec![
603            "--tool".into(),
604            "codex".into(),
605            "--working-directory".into(),
606            "/tmp/test".into(),
607            "--prompt-file".into(),
608            "/tmp/prompt.txt".into(),
609        ];
610        let result = parse_start_agent_args(&args);
611
612        assert_eq!(result.prompt_file, Some("/tmp/prompt.txt".to_string()));
613    }
614
615    #[test]
616    fn test_parse_start_agent_args_with_verbose_and_replay_user_messages() {
617        let args: Vec<String> = vec![
618            "--tool".into(),
619            "claude".into(),
620            "--working-directory".into(),
621            "/tmp/test".into(),
622            "--verbose".into(),
623            "--replay-user-messages".into(),
624        ];
625        let result = parse_start_agent_args(&args);
626
627        assert!(result.verbose);
628        assert!(result.replay_user_messages);
629    }
630
631    #[test]
632    fn test_parse_start_agent_args_defaults_for_new_options() {
633        let args: Vec<String> = vec![
634            "--tool".into(),
635            "claude".into(),
636            "--working-directory".into(),
637            "/tmp/test".into(),
638        ];
639        let result = parse_start_agent_args(&args);
640
641        assert!(!result.verbose);
642        assert!(!result.replay_user_messages);
643        assert!(!result.fork_session);
644        assert!(result.model.is_none());
645        assert!(result.fallback_model.is_none());
646        assert!(result.resume.is_none());
647        assert!(result.session_id.is_none());
648        assert!(result.append_system_prompt.is_none());
649        assert!(result.prompt_file.is_none());
650    }
651
652    #[test]
653    fn test_parse_stop_agent_args_screen() {
654        let args: Vec<String> = vec![
655            "--isolation".into(),
656            "screen".into(),
657            "--screen-name".into(),
658            "my-session".into(),
659        ];
660        let result = parse_stop_agent_args(&args);
661
662        assert_eq!(result.isolation, Some("screen".to_string()));
663        assert_eq!(result.screen_name, Some("my-session".to_string()));
664    }
665
666    #[test]
667    fn test_validate_start_agent_options_valid() {
668        let options = StartAgentOptions {
669            tool: Some("claude".to_string()),
670            working_directory: Some("/tmp/test".to_string()),
671            isolation: "none".to_string(),
672            ..Default::default()
673        };
674        let result = validate_start_agent_options(&options);
675
676        assert!(result.valid);
677        assert!(result.errors.is_empty());
678    }
679
680    #[test]
681    fn test_validate_start_agent_options_missing_tool() {
682        let options = StartAgentOptions {
683            working_directory: Some("/tmp/test".to_string()),
684            isolation: "none".to_string(),
685            ..Default::default()
686        };
687        let result = validate_start_agent_options(&options);
688
689        assert!(!result.valid);
690        assert!(result.errors.iter().any(|e| e.contains("tool")));
691    }
692
693    #[test]
694    fn test_validate_start_agent_options_screen_without_name() {
695        let options = StartAgentOptions {
696            tool: Some("claude".to_string()),
697            working_directory: Some("/tmp/test".to_string()),
698            isolation: "screen".to_string(),
699            ..Default::default()
700        };
701        let result = validate_start_agent_options(&options);
702
703        assert!(!result.valid);
704        assert!(result.errors.iter().any(|e| e.contains("screen-name")));
705    }
706
707    #[test]
708    fn test_validate_stop_agent_options_valid() {
709        let options = StopAgentOptions {
710            isolation: Some("screen".to_string()),
711            screen_name: Some("my-session".to_string()),
712            ..Default::default()
713        };
714        let result = validate_stop_agent_options(&options);
715
716        assert!(result.valid);
717        assert!(result.errors.is_empty());
718    }
719
720    #[test]
721    fn test_validate_stop_agent_options_missing_isolation() {
722        let options = StopAgentOptions::default();
723        let result = validate_stop_agent_options(&options);
724
725        assert!(!result.valid);
726        assert!(result.errors.iter().any(|e| e.contains("isolation")));
727    }
728}