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