pub mod commands;
pub mod context;
pub mod handlers;
pub mod sink;
pub mod traits;
pub use commands::COMMANDS;
pub use context::CommandContext;
pub use sink::{ChannelSink, NullSink};
pub use traits::agent::{AgentAccess, NullAgent};
use std::future::Future;
use std::pin::Pin;
#[derive(Debug)]
pub enum CommandOutput {
Message(String),
Silent,
Exit,
Continue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlashCategory {
Session,
Configuration,
Memory,
Skills,
Planning,
Debugging,
Integration,
Advanced,
}
impl SlashCategory {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Session => "Session",
Self::Configuration => "Configuration",
Self::Memory => "Memory",
Self::Skills => "Skills",
Self::Planning => "Planning",
Self::Debugging => "Debugging",
Self::Integration => "Integration",
Self::Advanced => "Advanced",
}
}
}
pub struct CommandInfo {
pub name: &'static str,
pub args: &'static str,
pub description: &'static str,
pub category: SlashCategory,
pub feature_gate: Option<&'static str>,
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct CommandError(pub String);
impl CommandError {
pub fn new(msg: impl std::fmt::Display) -> Self {
Self(msg.to_string())
}
}
pub trait CommandHandler<Ctx: ?Sized>: Send + Sync {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn args_hint(&self) -> &'static str {
""
}
fn category(&self) -> SlashCategory;
fn feature_gate(&self) -> Option<&'static str> {
None
}
fn handle<'a>(
&'a self,
ctx: &'a mut Ctx,
args: &'a str,
) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>;
}
pub struct CommandRegistry<Ctx: ?Sized> {
handlers: Vec<Box<dyn CommandHandler<Ctx>>>,
}
impl<Ctx: ?Sized> CommandRegistry<Ctx> {
#[must_use]
pub fn new() -> Self {
Self {
handlers: Vec::new(),
}
}
pub fn register(&mut self, handler: impl CommandHandler<Ctx> + 'static) {
let name = handler.name();
assert!(
!self.handlers.iter().any(|h| h.name() == name),
"duplicate command name: {name}"
);
self.handlers.push(Box::new(handler));
}
pub async fn dispatch(
&self,
ctx: &mut Ctx,
input: &str,
) -> Option<Result<CommandOutput, CommandError>> {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return None;
}
let mut best_len: usize = 0;
let mut best_idx: Option<usize> = None;
for (idx, handler) in self.handlers.iter().enumerate() {
let name = handler.name();
let matched = trimmed == name
|| trimmed
.strip_prefix(name)
.is_some_and(|rest| rest.starts_with(' '));
if matched && name.len() >= best_len {
best_len = name.len();
best_idx = Some(idx);
}
}
let handler = &self.handlers[best_idx?];
let name = handler.name();
let args = trimmed[name.len()..].trim();
Some(handler.handle(ctx, args).await)
}
#[must_use]
pub fn find_handler(&self, input: &str) -> Option<(usize, &'static str)> {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return None;
}
let mut best_len: usize = 0;
let mut best: Option<(usize, &'static str)> = None;
for (idx, handler) in self.handlers.iter().enumerate() {
let name = handler.name();
let matched = trimmed == name
|| trimmed
.strip_prefix(name)
.is_some_and(|rest| rest.starts_with(' '));
if matched && name.len() >= best_len {
best_len = name.len();
best = Some((idx, name));
}
}
best
}
#[must_use]
pub fn list(&self) -> Vec<CommandInfo> {
self.handlers
.iter()
.map(|h| CommandInfo {
name: h.name(),
args: h.args_hint(),
description: h.description(),
category: h.category(),
feature_gate: h.feature_gate(),
})
.collect()
}
}
impl<Ctx: ?Sized> Default for CommandRegistry<Ctx> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::future::Future;
use std::pin::Pin;
struct MockCtx;
struct FixedHandler {
name: &'static str,
category: SlashCategory,
}
impl CommandHandler<MockCtx> for FixedHandler {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
"test handler"
}
fn category(&self) -> SlashCategory {
self.category
}
fn handle<'a>(
&'a self,
_ctx: &'a mut MockCtx,
args: &'a str,
) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>
{
let name = self.name;
Box::pin(async move { Ok(CommandOutput::Message(format!("{name}:{args}"))) })
}
}
fn make_handler(name: &'static str) -> FixedHandler {
FixedHandler {
name,
category: SlashCategory::Session,
}
}
#[tokio::test]
async fn dispatch_routes_longest_match() {
let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
reg.register(make_handler("/plan"));
reg.register(make_handler("/plan confirm"));
let mut ctx = MockCtx;
let out = reg
.dispatch(&mut ctx, "/plan confirm foo")
.await
.unwrap()
.unwrap();
let CommandOutput::Message(msg) = out else {
panic!("expected Message");
};
assert_eq!(msg, "/plan confirm:foo");
}
#[tokio::test]
async fn dispatch_returns_none_for_non_slash() {
let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
reg.register(make_handler("/help"));
let mut ctx = MockCtx;
assert!(reg.dispatch(&mut ctx, "hello").await.is_none());
}
#[tokio::test]
async fn dispatch_returns_none_for_unregistered() {
let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
reg.register(make_handler("/help"));
let mut ctx = MockCtx;
assert!(reg.dispatch(&mut ctx, "/unknown").await.is_none());
}
#[test]
#[should_panic(expected = "duplicate command name")]
fn register_panics_on_duplicate() {
let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
reg.register(make_handler("/plan"));
reg.register(make_handler("/plan"));
}
#[test]
fn list_returns_metadata_in_order() {
let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
reg.register(make_handler("/alpha"));
reg.register(make_handler("/beta"));
let list = reg.list();
assert_eq!(list.len(), 2);
assert_eq!(list[0].name, "/alpha");
assert_eq!(list[1].name, "/beta");
}
#[test]
fn slash_category_as_str_all_variants() {
let variants = [
(SlashCategory::Session, "Session"),
(SlashCategory::Configuration, "Configuration"),
(SlashCategory::Memory, "Memory"),
(SlashCategory::Skills, "Skills"),
(SlashCategory::Planning, "Planning"),
(SlashCategory::Debugging, "Debugging"),
(SlashCategory::Integration, "Integration"),
(SlashCategory::Advanced, "Advanced"),
];
for (variant, expected) in variants {
assert_eq!(variant.as_str(), expected);
}
}
}