1use super::error::SubAgentError;
12
13#[non_exhaustive]
30#[derive(Debug, PartialEq)]
31pub enum AgentsCommand {
32 List,
34 Show { name: String },
36 Create { name: String },
38 Edit { name: String },
40 Delete { name: String },
42}
43
44impl AgentsCommand {
45 pub fn parse(input: &str) -> Result<Self, SubAgentError> {
51 let rest = input
52 .strip_prefix("/agents")
53 .ok_or_else(|| SubAgentError::InvalidCommand("input must start with /agents".into()))?
54 .trim();
55
56 if rest.is_empty() {
57 return Err(SubAgentError::InvalidCommand(
58 "usage: /agents <list|show|create|edit|delete> [args]".into(),
59 ));
60 }
61
62 let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
63 let cmd = cmd.trim();
64 let args = args.trim();
65
66 match cmd {
67 "list" => Ok(Self::List),
68 "show" => {
69 if args.is_empty() {
70 return Err(SubAgentError::InvalidCommand(
71 "usage: /agents show <name>".into(),
72 ));
73 }
74 Ok(Self::Show {
75 name: args.to_owned(),
76 })
77 }
78 "create" => {
79 if args.is_empty() {
80 return Err(SubAgentError::InvalidCommand(
81 "usage: /agents create <name>".into(),
82 ));
83 }
84 Ok(Self::Create {
85 name: args.to_owned(),
86 })
87 }
88 "edit" => {
89 if args.is_empty() {
90 return Err(SubAgentError::InvalidCommand(
91 "usage: /agents edit <name>".into(),
92 ));
93 }
94 Ok(Self::Edit {
95 name: args.to_owned(),
96 })
97 }
98 "delete" => {
99 if args.is_empty() {
100 return Err(SubAgentError::InvalidCommand(
101 "usage: /agents delete <name>".into(),
102 ));
103 }
104 Ok(Self::Delete {
105 name: args.to_owned(),
106 })
107 }
108 other => Err(SubAgentError::InvalidCommand(format!(
109 "unknown subcommand '{other}'; try: list, show, create, edit, delete"
110 ))),
111 }
112 }
113}
114
115#[non_exhaustive]
137#[derive(Debug, PartialEq)]
138pub enum AgentCommand {
139 List,
141 Spawn { name: String, prompt: String },
143 Background { name: String, prompt: String },
145 Status,
147 Cancel { id: String },
149 Approve { id: String },
151 Deny { id: String },
153 Mention { agent: String, prompt: String },
155 Resume { id: String, prompt: String },
157}
158
159impl AgentCommand {
160 pub fn parse(input: &str, known_agents: &[String]) -> Result<Self, SubAgentError> {
177 if input.starts_with('@') {
178 return Self::parse_mention(input, known_agents);
179 }
180
181 let rest = input
182 .strip_prefix("/agent")
183 .ok_or_else(|| {
184 SubAgentError::InvalidCommand("input must start with /agent or @".into())
185 })?
186 .trim();
187
188 if rest.is_empty() {
189 return Err(SubAgentError::InvalidCommand(
190 "usage: /agent <list|spawn|bg|resume|status|cancel|approve|deny> [args]".into(),
191 ));
192 }
193
194 let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
195 let cmd = cmd.trim();
196 let args = args.trim();
197
198 match cmd {
199 "list" => Ok(Self::List),
200 "status" => Ok(Self::Status),
201 "spawn" | "bg" => {
202 let (name, prompt) = args.split_once(' ').ok_or_else(|| {
203 SubAgentError::InvalidCommand(format!("usage: /agent {cmd} <name> <prompt>"))
204 })?;
205 let name = name.trim().to_owned();
206 let prompt = prompt.trim().to_owned();
207 if name.is_empty() {
208 return Err(SubAgentError::InvalidCommand(
209 "sub-agent name must not be empty".into(),
210 ));
211 }
212 if prompt.is_empty() {
213 return Err(SubAgentError::InvalidCommand(
214 "prompt must not be empty".into(),
215 ));
216 }
217 if cmd == "bg" {
218 Ok(Self::Background { name, prompt })
219 } else {
220 Ok(Self::Spawn { name, prompt })
221 }
222 }
223 "cancel" => {
224 if args.is_empty() {
225 return Err(SubAgentError::InvalidCommand(
226 "usage: /agent cancel <id>".into(),
227 ));
228 }
229 Ok(Self::Cancel {
230 id: args.to_owned(),
231 })
232 }
233 "approve" => {
234 if args.is_empty() {
235 return Err(SubAgentError::InvalidCommand(
236 "usage: /agent approve <id>".into(),
237 ));
238 }
239 Ok(Self::Approve {
240 id: args.to_owned(),
241 })
242 }
243 "deny" => {
244 if args.is_empty() {
245 return Err(SubAgentError::InvalidCommand(
246 "usage: /agent deny <id>".into(),
247 ));
248 }
249 Ok(Self::Deny {
250 id: args.to_owned(),
251 })
252 }
253 "resume" => {
254 let (id, prompt) = args.split_once(' ').ok_or_else(|| {
255 SubAgentError::InvalidCommand("usage: /agent resume <id> <prompt>".into())
256 })?;
257 let id = id.trim().to_owned();
258 let prompt = prompt.trim().to_owned();
259 if id.is_empty() {
260 return Err(SubAgentError::InvalidCommand(
261 "agent id must not be empty".into(),
262 ));
263 }
264 if id.len() < 4 {
267 return Err(SubAgentError::InvalidCommand(
268 "agent id prefix must be at least 4 characters".into(),
269 ));
270 }
271 if prompt.is_empty() {
272 return Err(SubAgentError::InvalidCommand(
273 "prompt must not be empty".into(),
274 ));
275 }
276 Ok(Self::Resume { id, prompt })
277 }
278 other => Err(SubAgentError::InvalidCommand(format!(
279 "unknown subcommand '{other}'; try: list, spawn, bg, resume, status, cancel, approve, deny"
280 ))),
281 }
282 }
283
284 pub fn parse_mention(input: &str, known_agents: &[String]) -> Result<Self, SubAgentError> {
314 let rest = input
315 .strip_prefix('@')
316 .ok_or_else(|| SubAgentError::InvalidCommand("input must start with @".into()))?;
317
318 if rest.is_empty() || rest.starts_with(' ') {
319 return Err(SubAgentError::InvalidCommand(
320 "bare '@' is not a valid agent mention".into(),
321 ));
322 }
323
324 let (agent_token, prompt) = rest.split_once(' ').unwrap_or((rest, ""));
325 let agent = agent_token.trim().to_owned();
326
327 if !known_agents.iter().any(|n| n == &agent) {
328 return Err(SubAgentError::InvalidCommand(format!(
329 "@{agent} is not a known sub-agent"
330 )));
331 }
332
333 Ok(Self::Mention {
334 agent,
335 prompt: prompt.trim().to_owned(),
336 })
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use std::assert_matches;
344
345 #[test]
346 fn parse_list() {
347 assert_eq!(
348 AgentCommand::parse("/agent list", &[]).unwrap(),
349 AgentCommand::List
350 );
351 }
352
353 #[test]
354 fn parse_status() {
355 assert_eq!(
356 AgentCommand::parse("/agent status", &[]).unwrap(),
357 AgentCommand::Status
358 );
359 }
360
361 #[test]
362 fn parse_spawn() {
363 let cmd = AgentCommand::parse("/agent spawn helper do something useful", &[]).unwrap();
364 assert_eq!(
365 cmd,
366 AgentCommand::Spawn {
367 name: "helper".into(),
368 prompt: "do something useful".into(),
369 }
370 );
371 }
372
373 #[test]
374 fn parse_bg() {
375 let cmd = AgentCommand::parse("/agent bg reviewer check the code", &[]).unwrap();
376 assert_eq!(
377 cmd,
378 AgentCommand::Background {
379 name: "reviewer".into(),
380 prompt: "check the code".into(),
381 }
382 );
383 }
384
385 #[test]
386 fn parse_cancel() {
387 let cmd = AgentCommand::parse("/agent cancel abc123", &[]).unwrap();
388 assert_eq!(
389 cmd,
390 AgentCommand::Cancel {
391 id: "abc123".into()
392 }
393 );
394 }
395
396 #[test]
397 fn parse_approve() {
398 let cmd = AgentCommand::parse("/agent approve task-1", &[]).unwrap();
399 assert_eq!(
400 cmd,
401 AgentCommand::Approve {
402 id: "task-1".into()
403 }
404 );
405 }
406
407 #[test]
408 fn parse_deny() {
409 let cmd = AgentCommand::parse("/agent deny task-2", &[]).unwrap();
410 assert_eq!(
411 cmd,
412 AgentCommand::Deny {
413 id: "task-2".into()
414 }
415 );
416 }
417
418 #[test]
419 fn parse_wrong_prefix_returns_error() {
420 let err = AgentCommand::parse("/foo list", &[]).unwrap_err();
421 assert_matches!(err, SubAgentError::InvalidCommand(_));
422 }
423
424 #[test]
425 fn parse_empty_after_prefix_returns_usage() {
426 let err = AgentCommand::parse("/agent", &[]).unwrap_err();
427 assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
428 }
429
430 #[test]
431 fn parse_whitespace_only_after_prefix_returns_usage() {
432 let err = AgentCommand::parse("/agent ", &[]).unwrap_err();
433 assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
434 }
435
436 #[test]
437 fn parse_unknown_subcommand_returns_error() {
438 let err = AgentCommand::parse("/agent frobnicate", &[]).unwrap_err();
439 assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("frobnicate"));
440 }
441
442 #[test]
443 fn parse_spawn_missing_prompt_returns_error() {
444 let err = AgentCommand::parse("/agent spawn helper", &[]).unwrap_err();
445 assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
446 }
447
448 #[test]
449 fn parse_spawn_missing_name_and_prompt_returns_error() {
450 let err = AgentCommand::parse("/agent spawn", &[]).unwrap_err();
451 assert_matches!(err, SubAgentError::InvalidCommand(_));
452 }
453
454 #[test]
455 fn parse_cancel_missing_id_returns_error() {
456 let err = AgentCommand::parse("/agent cancel", &[]).unwrap_err();
457 assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
458 }
459
460 #[test]
461 fn parse_approve_missing_id_returns_error() {
462 let err = AgentCommand::parse("/agent approve", &[]).unwrap_err();
463 assert_matches!(err, SubAgentError::InvalidCommand(_));
464 }
465
466 #[test]
467 fn parse_deny_missing_id_returns_error() {
468 let err = AgentCommand::parse("/agent deny", &[]).unwrap_err();
469 assert_matches!(err, SubAgentError::InvalidCommand(_));
470 }
471
472 #[test]
473 fn parse_extra_whitespace_trimmed() {
474 let cmd = AgentCommand::parse("/agent cancel deadbeef", &[]).unwrap();
476 assert_eq!(
477 cmd,
478 AgentCommand::Cancel {
479 id: "deadbeef".into()
480 }
481 );
482 }
483
484 #[test]
485 fn parse_spawn_prompt_with_spaces_preserved() {
486 let cmd = AgentCommand::parse(
487 "/agent spawn bot review the PR and suggest improvements",
488 &[],
489 )
490 .unwrap();
491 assert_eq!(
492 cmd,
493 AgentCommand::Spawn {
494 name: "bot".into(),
495 prompt: "review the PR and suggest improvements".into(),
496 }
497 );
498 }
499
500 fn known() -> Vec<String> {
503 vec!["reviewer".into(), "helper".into()]
504 }
505
506 #[test]
507 fn mention_known_agent_with_prompt() {
508 let cmd = AgentCommand::parse_mention("@reviewer review this PR", &known()).unwrap();
509 assert_eq!(
510 cmd,
511 AgentCommand::Mention {
512 agent: "reviewer".into(),
513 prompt: "review this PR".into(),
514 }
515 );
516 }
517
518 #[test]
519 fn mention_known_agent_without_prompt() {
520 let cmd = AgentCommand::parse_mention("@helper", &known()).unwrap();
521 assert_eq!(
522 cmd,
523 AgentCommand::Mention {
524 agent: "helper".into(),
525 prompt: String::new(),
526 }
527 );
528 }
529
530 #[test]
531 fn mention_unknown_agent_returns_error() {
532 let err = AgentCommand::parse_mention("@unknown-thing do work", &known()).unwrap_err();
533 assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("unknown-thing"));
534 }
535
536 #[test]
537 fn mention_bare_at_returns_error() {
538 let err = AgentCommand::parse_mention("@", &known()).unwrap_err();
539 assert_matches!(err, SubAgentError::InvalidCommand(_));
540 }
541
542 #[test]
543 fn mention_at_with_space_returns_error() {
544 let err = AgentCommand::parse_mention("@ something", &known()).unwrap_err();
545 assert_matches!(err, SubAgentError::InvalidCommand(_));
546 }
547
548 #[test]
549 fn mention_wrong_prefix_returns_error() {
550 let err = AgentCommand::parse_mention("reviewer do work", &known()).unwrap_err();
551 assert_matches!(err, SubAgentError::InvalidCommand(_));
552 }
553
554 #[test]
555 fn mention_empty_known_agents_always_fails() {
556 let err = AgentCommand::parse_mention("@reviewer do work", &[]).unwrap_err();
557 assert_matches!(err, SubAgentError::InvalidCommand(_));
558 }
559
560 #[test]
563 fn parse_dispatches_at_mention_to_parse_mention() {
564 let cmd = AgentCommand::parse("@reviewer review this PR", &known()).unwrap();
565 assert_eq!(
566 cmd,
567 AgentCommand::Mention {
568 agent: "reviewer".into(),
569 prompt: "review this PR".into(),
570 }
571 );
572 }
573
574 #[test]
575 fn parse_at_unknown_agent_returns_error() {
576 let err = AgentCommand::parse("@unknown test", &known()).unwrap_err();
577 assert_matches!(err, SubAgentError::InvalidCommand(_));
578 }
579
580 #[test]
581 fn parse_at_with_empty_known_returns_error() {
582 let err = AgentCommand::parse("@reviewer test", &[]).unwrap_err();
583 assert_matches!(err, SubAgentError::InvalidCommand(_));
584 }
585
586 #[test]
589 fn parse_resume() {
590 let cmd = AgentCommand::parse("/agent resume deadbeef continue the analysis", &[]).unwrap();
591 assert_eq!(
592 cmd,
593 AgentCommand::Resume {
594 id: "deadbeef".into(),
595 prompt: "continue the analysis".into(),
596 }
597 );
598 }
599
600 #[test]
601 fn parse_resume_missing_prompt_returns_error() {
602 let err = AgentCommand::parse("/agent resume deadbeef", &[]).unwrap_err();
603 assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
604 }
605
606 #[test]
607 fn parse_resume_missing_id_and_prompt_returns_error() {
608 let err = AgentCommand::parse("/agent resume", &[]).unwrap_err();
609 assert_matches!(err, SubAgentError::InvalidCommand(_));
610 }
611
612 #[test]
613 fn parse_resume_unknown_subcommand_hint() {
614 let err = AgentCommand::parse("/agent frobnicate", &[]).unwrap_err();
615 if let SubAgentError::InvalidCommand(msg) = err {
616 assert!(
617 msg.contains("resume"),
618 "hint should mention 'resume': {msg}"
619 );
620 } else {
621 panic!("expected InvalidCommand");
622 }
623 }
624
625 #[test]
626 fn parse_resume_prompt_with_spaces_preserved() {
627 let cmd = AgentCommand::parse("/agent resume abc123 do more work and fix the issue", &[])
628 .unwrap();
629 assert_eq!(
630 cmd,
631 AgentCommand::Resume {
632 id: "abc123".into(),
633 prompt: "do more work and fix the issue".into(),
634 }
635 );
636 }
637
638 #[test]
639 fn parse_resume_id_too_short_returns_error() {
640 let err = AgentCommand::parse("/agent resume abc continue", &[]).unwrap_err();
642 assert!(
643 matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("4 characters")),
644 "expected min-length error, got: {err:?}"
645 );
646 }
647
648 #[test]
649 fn parse_resume_id_exactly_four_chars_is_accepted() {
650 let cmd = AgentCommand::parse("/agent resume abcd continue the work", &[]).unwrap();
651 assert_eq!(
652 cmd,
653 AgentCommand::Resume {
654 id: "abcd".into(),
655 prompt: "continue the work".into(),
656 }
657 );
658 }
659
660 #[test]
661 fn parse_resume_whitespace_only_prompt_returns_error() {
662 let err = AgentCommand::parse("/agent resume deadbeef ", &[]).unwrap_err();
664 assert_matches!(err, SubAgentError::InvalidCommand(_));
666 }
667
668 #[test]
671 fn agents_parse_list() {
672 assert_eq!(
673 AgentsCommand::parse("/agents list").unwrap(),
674 AgentsCommand::List
675 );
676 }
677
678 #[test]
679 fn agents_parse_show() {
680 let cmd = AgentsCommand::parse("/agents show code-reviewer").unwrap();
681 assert_eq!(
682 cmd,
683 AgentsCommand::Show {
684 name: "code-reviewer".into()
685 }
686 );
687 }
688
689 #[test]
690 fn agents_parse_create() {
691 let cmd = AgentsCommand::parse("/agents create my-agent").unwrap();
692 assert_eq!(
693 cmd,
694 AgentsCommand::Create {
695 name: "my-agent".into()
696 }
697 );
698 }
699
700 #[test]
701 fn agents_parse_edit() {
702 let cmd = AgentsCommand::parse("/agents edit reviewer").unwrap();
703 assert_eq!(
704 cmd,
705 AgentsCommand::Edit {
706 name: "reviewer".into()
707 }
708 );
709 }
710
711 #[test]
712 fn agents_parse_delete() {
713 let cmd = AgentsCommand::parse("/agents delete reviewer").unwrap();
714 assert_eq!(
715 cmd,
716 AgentsCommand::Delete {
717 name: "reviewer".into()
718 }
719 );
720 }
721
722 #[test]
723 fn agents_parse_missing_subcommand_returns_usage() {
724 let err = AgentsCommand::parse("/agents").unwrap_err();
725 assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
726 }
727
728 #[test]
729 fn agents_parse_show_missing_name_returns_usage() {
730 let err = AgentsCommand::parse("/agents show").unwrap_err();
731 assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
732 }
733
734 #[test]
735 fn agents_parse_unknown_subcommand_returns_error() {
736 let err = AgentsCommand::parse("/agents frobnicate").unwrap_err();
737 assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("frobnicate"));
738 }
739
740 #[test]
741 fn agents_parse_wrong_prefix_returns_error() {
742 let err = AgentsCommand::parse("/agent list").unwrap_err();
743 assert_matches!(err, SubAgentError::InvalidCommand(_));
744 }
745}