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
344 #[test]
345 fn parse_list() {
346 assert_eq!(
347 AgentCommand::parse("/agent list", &[]).unwrap(),
348 AgentCommand::List
349 );
350 }
351
352 #[test]
353 fn parse_status() {
354 assert_eq!(
355 AgentCommand::parse("/agent status", &[]).unwrap(),
356 AgentCommand::Status
357 );
358 }
359
360 #[test]
361 fn parse_spawn() {
362 let cmd = AgentCommand::parse("/agent spawn helper do something useful", &[]).unwrap();
363 assert_eq!(
364 cmd,
365 AgentCommand::Spawn {
366 name: "helper".into(),
367 prompt: "do something useful".into(),
368 }
369 );
370 }
371
372 #[test]
373 fn parse_bg() {
374 let cmd = AgentCommand::parse("/agent bg reviewer check the code", &[]).unwrap();
375 assert_eq!(
376 cmd,
377 AgentCommand::Background {
378 name: "reviewer".into(),
379 prompt: "check the code".into(),
380 }
381 );
382 }
383
384 #[test]
385 fn parse_cancel() {
386 let cmd = AgentCommand::parse("/agent cancel abc123", &[]).unwrap();
387 assert_eq!(
388 cmd,
389 AgentCommand::Cancel {
390 id: "abc123".into()
391 }
392 );
393 }
394
395 #[test]
396 fn parse_approve() {
397 let cmd = AgentCommand::parse("/agent approve task-1", &[]).unwrap();
398 assert_eq!(
399 cmd,
400 AgentCommand::Approve {
401 id: "task-1".into()
402 }
403 );
404 }
405
406 #[test]
407 fn parse_deny() {
408 let cmd = AgentCommand::parse("/agent deny task-2", &[]).unwrap();
409 assert_eq!(
410 cmd,
411 AgentCommand::Deny {
412 id: "task-2".into()
413 }
414 );
415 }
416
417 #[test]
418 fn parse_wrong_prefix_returns_error() {
419 let err = AgentCommand::parse("/foo list", &[]).unwrap_err();
420 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
421 }
422
423 #[test]
424 fn parse_empty_after_prefix_returns_usage() {
425 let err = AgentCommand::parse("/agent", &[]).unwrap_err();
426 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
427 }
428
429 #[test]
430 fn parse_whitespace_only_after_prefix_returns_usage() {
431 let err = AgentCommand::parse("/agent ", &[]).unwrap_err();
432 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
433 }
434
435 #[test]
436 fn parse_unknown_subcommand_returns_error() {
437 let err = AgentCommand::parse("/agent frobnicate", &[]).unwrap_err();
438 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("frobnicate")));
439 }
440
441 #[test]
442 fn parse_spawn_missing_prompt_returns_error() {
443 let err = AgentCommand::parse("/agent spawn helper", &[]).unwrap_err();
444 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
445 }
446
447 #[test]
448 fn parse_spawn_missing_name_and_prompt_returns_error() {
449 let err = AgentCommand::parse("/agent spawn", &[]).unwrap_err();
450 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
451 }
452
453 #[test]
454 fn parse_cancel_missing_id_returns_error() {
455 let err = AgentCommand::parse("/agent cancel", &[]).unwrap_err();
456 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
457 }
458
459 #[test]
460 fn parse_approve_missing_id_returns_error() {
461 let err = AgentCommand::parse("/agent approve", &[]).unwrap_err();
462 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
463 }
464
465 #[test]
466 fn parse_deny_missing_id_returns_error() {
467 let err = AgentCommand::parse("/agent deny", &[]).unwrap_err();
468 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
469 }
470
471 #[test]
472 fn parse_extra_whitespace_trimmed() {
473 let cmd = AgentCommand::parse("/agent cancel deadbeef", &[]).unwrap();
475 assert_eq!(
476 cmd,
477 AgentCommand::Cancel {
478 id: "deadbeef".into()
479 }
480 );
481 }
482
483 #[test]
484 fn parse_spawn_prompt_with_spaces_preserved() {
485 let cmd = AgentCommand::parse(
486 "/agent spawn bot review the PR and suggest improvements",
487 &[],
488 )
489 .unwrap();
490 assert_eq!(
491 cmd,
492 AgentCommand::Spawn {
493 name: "bot".into(),
494 prompt: "review the PR and suggest improvements".into(),
495 }
496 );
497 }
498
499 fn known() -> Vec<String> {
502 vec!["reviewer".into(), "helper".into()]
503 }
504
505 #[test]
506 fn mention_known_agent_with_prompt() {
507 let cmd = AgentCommand::parse_mention("@reviewer review this PR", &known()).unwrap();
508 assert_eq!(
509 cmd,
510 AgentCommand::Mention {
511 agent: "reviewer".into(),
512 prompt: "review this PR".into(),
513 }
514 );
515 }
516
517 #[test]
518 fn mention_known_agent_without_prompt() {
519 let cmd = AgentCommand::parse_mention("@helper", &known()).unwrap();
520 assert_eq!(
521 cmd,
522 AgentCommand::Mention {
523 agent: "helper".into(),
524 prompt: String::new(),
525 }
526 );
527 }
528
529 #[test]
530 fn mention_unknown_agent_returns_error() {
531 let err = AgentCommand::parse_mention("@unknown-thing do work", &known()).unwrap_err();
532 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("unknown-thing")));
533 }
534
535 #[test]
536 fn mention_bare_at_returns_error() {
537 let err = AgentCommand::parse_mention("@", &known()).unwrap_err();
538 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
539 }
540
541 #[test]
542 fn mention_at_with_space_returns_error() {
543 let err = AgentCommand::parse_mention("@ something", &known()).unwrap_err();
544 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
545 }
546
547 #[test]
548 fn mention_wrong_prefix_returns_error() {
549 let err = AgentCommand::parse_mention("reviewer do work", &known()).unwrap_err();
550 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
551 }
552
553 #[test]
554 fn mention_empty_known_agents_always_fails() {
555 let err = AgentCommand::parse_mention("@reviewer do work", &[]).unwrap_err();
556 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
557 }
558
559 #[test]
562 fn parse_dispatches_at_mention_to_parse_mention() {
563 let cmd = AgentCommand::parse("@reviewer review this PR", &known()).unwrap();
564 assert_eq!(
565 cmd,
566 AgentCommand::Mention {
567 agent: "reviewer".into(),
568 prompt: "review this PR".into(),
569 }
570 );
571 }
572
573 #[test]
574 fn parse_at_unknown_agent_returns_error() {
575 let err = AgentCommand::parse("@unknown test", &known()).unwrap_err();
576 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
577 }
578
579 #[test]
580 fn parse_at_with_empty_known_returns_error() {
581 let err = AgentCommand::parse("@reviewer test", &[]).unwrap_err();
582 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
583 }
584
585 #[test]
588 fn parse_resume() {
589 let cmd = AgentCommand::parse("/agent resume deadbeef continue the analysis", &[]).unwrap();
590 assert_eq!(
591 cmd,
592 AgentCommand::Resume {
593 id: "deadbeef".into(),
594 prompt: "continue the analysis".into(),
595 }
596 );
597 }
598
599 #[test]
600 fn parse_resume_missing_prompt_returns_error() {
601 let err = AgentCommand::parse("/agent resume deadbeef", &[]).unwrap_err();
602 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
603 }
604
605 #[test]
606 fn parse_resume_missing_id_and_prompt_returns_error() {
607 let err = AgentCommand::parse("/agent resume", &[]).unwrap_err();
608 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
609 }
610
611 #[test]
612 fn parse_resume_unknown_subcommand_hint() {
613 let err = AgentCommand::parse("/agent frobnicate", &[]).unwrap_err();
614 if let SubAgentError::InvalidCommand(msg) = err {
615 assert!(
616 msg.contains("resume"),
617 "hint should mention 'resume': {msg}"
618 );
619 } else {
620 panic!("expected InvalidCommand");
621 }
622 }
623
624 #[test]
625 fn parse_resume_prompt_with_spaces_preserved() {
626 let cmd = AgentCommand::parse("/agent resume abc123 do more work and fix the issue", &[])
627 .unwrap();
628 assert_eq!(
629 cmd,
630 AgentCommand::Resume {
631 id: "abc123".into(),
632 prompt: "do more work and fix the issue".into(),
633 }
634 );
635 }
636
637 #[test]
638 fn parse_resume_id_too_short_returns_error() {
639 let err = AgentCommand::parse("/agent resume abc continue", &[]).unwrap_err();
641 assert!(
642 matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("4 characters")),
643 "expected min-length error, got: {err:?}"
644 );
645 }
646
647 #[test]
648 fn parse_resume_id_exactly_four_chars_is_accepted() {
649 let cmd = AgentCommand::parse("/agent resume abcd continue the work", &[]).unwrap();
650 assert_eq!(
651 cmd,
652 AgentCommand::Resume {
653 id: "abcd".into(),
654 prompt: "continue the work".into(),
655 }
656 );
657 }
658
659 #[test]
660 fn parse_resume_whitespace_only_prompt_returns_error() {
661 let err = AgentCommand::parse("/agent resume deadbeef ", &[]).unwrap_err();
663 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
665 }
666
667 #[test]
670 fn agents_parse_list() {
671 assert_eq!(
672 AgentsCommand::parse("/agents list").unwrap(),
673 AgentsCommand::List
674 );
675 }
676
677 #[test]
678 fn agents_parse_show() {
679 let cmd = AgentsCommand::parse("/agents show code-reviewer").unwrap();
680 assert_eq!(
681 cmd,
682 AgentsCommand::Show {
683 name: "code-reviewer".into()
684 }
685 );
686 }
687
688 #[test]
689 fn agents_parse_create() {
690 let cmd = AgentsCommand::parse("/agents create my-agent").unwrap();
691 assert_eq!(
692 cmd,
693 AgentsCommand::Create {
694 name: "my-agent".into()
695 }
696 );
697 }
698
699 #[test]
700 fn agents_parse_edit() {
701 let cmd = AgentsCommand::parse("/agents edit reviewer").unwrap();
702 assert_eq!(
703 cmd,
704 AgentsCommand::Edit {
705 name: "reviewer".into()
706 }
707 );
708 }
709
710 #[test]
711 fn agents_parse_delete() {
712 let cmd = AgentsCommand::parse("/agents delete reviewer").unwrap();
713 assert_eq!(
714 cmd,
715 AgentsCommand::Delete {
716 name: "reviewer".into()
717 }
718 );
719 }
720
721 #[test]
722 fn agents_parse_missing_subcommand_returns_usage() {
723 let err = AgentsCommand::parse("/agents").unwrap_err();
724 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
725 }
726
727 #[test]
728 fn agents_parse_show_missing_name_returns_usage() {
729 let err = AgentsCommand::parse("/agents show").unwrap_err();
730 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
731 }
732
733 #[test]
734 fn agents_parse_unknown_subcommand_returns_error() {
735 let err = AgentsCommand::parse("/agents frobnicate").unwrap_err();
736 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("frobnicate")));
737 }
738
739 #[test]
740 fn agents_parse_wrong_prefix_returns_error() {
741 let err = AgentsCommand::parse("/agent list").unwrap_err();
742 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
743 }
744}