Skip to main content

zeph_subagent/
command.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use super::error::SubAgentError;
5
6/// Typed representation of a parsed `/agents` command for definition CRUD operations.
7///
8/// Separate from [`AgentCommand`] (runtime operations like spawn/cancel) to avoid
9/// namespace collision between running-agent management and definition management.
10#[derive(Debug, PartialEq)]
11pub enum AgentsCommand {
12    /// List all discovered sub-agent definitions.
13    List,
14    /// Show full details of a definition.
15    Show { name: String },
16    /// Create a new definition.
17    Create { name: String },
18    /// Edit an existing definition.
19    Edit { name: String },
20    /// Delete a definition.
21    Delete { name: String },
22}
23
24impl AgentsCommand {
25    /// Parse from raw input text starting with `/agents`.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`SubAgentError::InvalidCommand`] if parsing fails.
30    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/// Typed representation of a parsed `/agent` CLI command or `@agent` mention.
96#[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    /// Foreground spawn triggered by `@agent_name <prompt>` mention syntax.
118    Mention {
119        agent: String,
120        prompt: String,
121    },
122    /// Resume a previously completed sub-agent session by ID prefix.
123    Resume {
124        id: String,
125        prompt: String,
126    },
127}
128
129impl AgentCommand {
130    /// Parse from raw input text.
131    ///
132    /// The input must start with `/agent`. Everything after that prefix is
133    /// interpreted as `<subcommand> [args]`.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`SubAgentError::InvalidCommand`] if:
138    /// - `input` does not start with `/agent`
139    /// - the subcommand is missing (empty after prefix)
140    /// - required arguments are missing
141    /// - the subcommand is not recognised
142    ///
143    /// Also handles `@agent_name prompt` mention syntax when `known_agents`
144    /// contains a match. If `@` prefix is present but the agent is unknown,
145    /// returns `Err` so the caller can fall back to file-reference handling.
146    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                // Require at least 4 characters to prevent accidental mass-match or session
235                // enumeration via very short prefixes.
236                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    /// Parse an `@agent_name <prompt>` mention from raw input.
255    ///
256    /// Returns `Ok(Mention { agent, prompt })` if `input` starts with `@` and the
257    /// token after `@` matches one of `known_agents`. Returns
258    /// [`SubAgentError::InvalidCommand`] if:
259    /// - `input` does not start with `@`
260    /// - the agent name token is empty (bare `@`)
261    /// - the named agent is not in `known_agents` — caller should fall back to
262    ///   other `@` handling such as file references
263    ///
264    /// # Errors
265    ///
266    /// Returns [`SubAgentError::InvalidCommand`] on any parse failure.
267    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        // Extra spaces around subcommand and args should be handled gracefully.
428        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    // ── parse_mention() tests ─────────────────────────────────────────────────
454
455    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    // ── parse() unified entry point with @ ──────────────────────────────────
514
515    #[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    // ── parse resume ─────────────────────────────────────────────────────────
540
541    #[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        // id "abc" has only 3 chars — below the 4-char minimum.
594        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        // After split_once, prompt is "   " which trims to "".
616        let err = AgentCommand::parse("/agent resume deadbeef    ", &[]).unwrap_err();
617        // Either split_once returns None (no space after id) or prompt trims to empty.
618        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
619    }
620
621    // ── AgentsCommand (definition CRUD) ────────────────────────────────────
622
623    #[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}