1use std::collections::HashMap;
5
6const VALUE_OPTION_KEYS: &[&str] = &["tool-arg"];
7
8#[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 pub fn get(&self, key: &str) -> Option<&String> {
19 self.options.get(key).and_then(|values| values.last())
20 }
21
22 pub fn get_all(&self, key: &str) -> Vec<String> {
24 self.options.get(key).cloned().unwrap_or_default()
25 }
26
27 pub fn has_flag(&self, key: &str) -> bool {
29 self.flags.contains(&key.to_string()) || self.options.contains_key(key)
30 }
31
32 pub fn get_bool(&self, key: &str) -> bool {
34 self.has_flag(key)
35 }
36}
37
38pub 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 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; } 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#[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 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#[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
120pub 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, help: parsed.get_bool("help") || parsed.get_bool("h"),
168 }
169}
170
171pub 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
190pub 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
255pub 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#[derive(Debug, Clone)]
283pub struct ValidationResult {
284 pub valid: bool,
285 pub errors: Vec<String>,
286}
287
288pub 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
324pub 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}