zeph_commands/lib.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Slash command registry, handler trait, and channel sink abstraction for Zeph.
5//!
6//! This crate provides the non-generic infrastructure for slash command dispatch:
7//! - [`ChannelSink`] — minimal async I/O trait replacing the `C: Channel` generic in handlers
8//! - [`CommandOutput`] — exhaustive result type for command execution
9//! - [`SlashCategory`] — grouping enum for `/help` output
10//! - [`CommandInfo`] — static metadata for a registered command
11//! - [`CommandHandler`] — object-safe handler trait (no `C` generic)
12//! - [`CommandRegistry`] — registry with longest-word-boundary dispatch
13//! - [`CommandContext`] — non-generic dispatch context with trait-object fields
14//! - [`traits`] — sub-trait definitions for subsystem access
15//! - [`handlers`] — concrete handler implementations (session, debug)
16//!
17//! # Design
18//!
19//! `CommandRegistry` and `CommandHandler` are non-generic: they operate on [`CommandContext`],
20//! a concrete struct whose fields are trait objects (`&mut dyn DebugAccess`, etc.). `zeph-core`
21//! implements these traits on its internal state types and constructs `CommandContext` at dispatch
22//! time from `Agent<C>` fields.
23//!
24//! This crate does NOT depend on `zeph-core`. A change in `zeph-core`'s agent loop does
25//! not recompile `zeph-commands`.
26
27pub mod commands;
28pub mod context;
29pub mod handlers;
30pub mod sink;
31pub mod traits;
32
33pub use commands::COMMANDS;
34
35pub use context::CommandContext;
36pub use sink::{ChannelSink, NullSink};
37pub use traits::agent::{AgentAccess, NullAgent};
38
39use std::future::Future;
40use std::pin::Pin;
41
42/// Result of executing a slash command.
43///
44/// Replaces the heterogeneous return types of earlier command dispatch with a unified,
45/// exhaustive enum.
46#[derive(Debug)]
47pub enum CommandOutput {
48 /// Send a message to the user via the channel.
49 Message(String),
50 /// Command handled silently; no output (e.g., `/clear`).
51 Silent,
52 /// Exit the agent loop immediately.
53 Exit,
54 /// Continue to the next loop iteration.
55 Continue,
56}
57
58/// Category for grouping commands in `/help` output.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum SlashCategory {
61 /// Session management: `/clear`, `/reset`, `/exit`, etc.
62 Session,
63 /// Model and provider configuration: `/model`, `/provider`, `/guardrail`, etc.
64 Configuration,
65 /// Memory and knowledge: `/memory`, `/graph`, `/compact`, etc.
66 Memory,
67 /// Skill management: `/skill`, `/skills`, etc.
68 Skills,
69 /// Planning and focus: `/plan`, `/focus`, `/sidequest`, etc.
70 Planning,
71 /// Debugging and diagnostics: `/debug-dump`, `/log`, `/lsp`, etc.
72 Debugging,
73 /// External integrations: `/mcp`, `/image`, `/agent`, etc.
74 Integration,
75 /// Advanced and experimental: `/experiment`, `/policy`, `/scheduler`, etc.
76 Advanced,
77}
78
79impl SlashCategory {
80 /// Return the display label for this category in `/help` output.
81 #[must_use]
82 pub fn as_str(self) -> &'static str {
83 match self {
84 Self::Session => "Session",
85 Self::Configuration => "Configuration",
86 Self::Memory => "Memory",
87 Self::Skills => "Skills",
88 Self::Planning => "Planning",
89 Self::Debugging => "Debugging",
90 Self::Integration => "Integration",
91 Self::Advanced => "Advanced",
92 }
93 }
94}
95
96/// Static metadata about a registered command, used for `/help` output generation.
97pub struct CommandInfo {
98 /// Command name including the leading slash, e.g. `"/help"`.
99 pub name: &'static str,
100 /// Argument hint shown after the command name in help, e.g. `"[path]"`.
101 pub args: &'static str,
102 /// One-line description shown in `/help` output.
103 pub description: &'static str,
104 /// Category for grouping in `/help`.
105 pub category: SlashCategory,
106 /// Feature gate label, if this command is conditionally compiled.
107 pub feature_gate: Option<&'static str>,
108}
109
110/// Error type returned by command handlers.
111///
112/// Wraps agent-level errors as a string to avoid depending on `zeph-core`'s `AgentError`.
113/// `zeph-core` converts between `AgentError` and `CommandError` at the dispatch boundary.
114#[derive(Debug, thiserror::Error)]
115#[error("{0}")]
116pub struct CommandError(pub String);
117
118impl CommandError {
119 /// Create a `CommandError` from any displayable value.
120 pub fn new(msg: impl std::fmt::Display) -> Self {
121 Self(msg.to_string())
122 }
123}
124
125/// A slash command handler that can be registered with [`CommandRegistry`].
126///
127/// Implementors must be `Send + Sync` because the registry is constructed at agent
128/// initialization time and handlers may be invoked from async contexts.
129///
130/// # Object safety
131///
132/// The `handle` method uses `Pin<Box<dyn Future>>` instead of `async fn` to remain
133/// object-safe, enabling the registry to store `Box<dyn CommandHandler<Ctx>>`. Slash
134/// commands are user-initiated so the box allocation is negligible.
135pub trait CommandHandler<Ctx: ?Sized>: Send + Sync {
136 /// Command name including the leading slash, e.g. `"/help"`.
137 ///
138 /// Must be unique per registry. Used as the dispatch key.
139 fn name(&self) -> &'static str;
140
141 /// One-line description shown in `/help` output.
142 fn description(&self) -> &'static str;
143
144 /// Argument hint shown after the command name in help, e.g. `"[path]"`.
145 ///
146 /// Return an empty string if the command takes no arguments.
147 fn args_hint(&self) -> &'static str {
148 ""
149 }
150
151 /// Category for grouping in `/help`.
152 fn category(&self) -> SlashCategory;
153
154 /// Feature gate label, if this command is conditionally compiled.
155 fn feature_gate(&self) -> Option<&'static str> {
156 None
157 }
158
159 /// Execute the command.
160 ///
161 /// # Arguments
162 ///
163 /// - `ctx`: Typed access to agent subsystems.
164 /// - `args`: Trimmed text after the command name. Empty string when no args given.
165 ///
166 /// # Errors
167 ///
168 /// Returns `Err(CommandError)` when the command fails. The dispatch site logs and
169 /// reports the error to the user.
170 fn handle<'a>(
171 &'a self,
172 ctx: &'a mut Ctx,
173 args: &'a str,
174 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>;
175}
176
177/// Registry of slash command handlers.
178///
179/// Handlers are stored in a `Vec`, not a `HashMap`, because command count is small (< 40)
180/// and registration happens once at agent initialization. Dispatch performs a linear scan
181/// with longest-word-boundary match to support subcommands.
182///
183/// # Dispatch
184///
185/// See [`CommandRegistry::dispatch`] for the full dispatch algorithm.
186///
187/// # Borrow splitting
188///
189/// When stored as an `Agent<C>` field, the dispatch call site uses `std::mem::take` to
190/// temporarily move the registry out of the agent, construct a context, dispatch, and
191/// restore the registry. This avoids borrow-checker conflicts.
192pub struct CommandRegistry<Ctx: ?Sized> {
193 handlers: Vec<Box<dyn CommandHandler<Ctx>>>,
194}
195
196impl<Ctx: ?Sized> CommandRegistry<Ctx> {
197 /// Create an empty registry.
198 #[must_use]
199 pub fn new() -> Self {
200 Self {
201 handlers: Vec::new(),
202 }
203 }
204
205 /// Register a command handler.
206 ///
207 /// # Panics
208 ///
209 /// Panics if a handler with the same name is already registered.
210 pub fn register(&mut self, handler: impl CommandHandler<Ctx> + 'static) {
211 let name = handler.name();
212 assert!(
213 !self.handlers.iter().any(|h| h.name() == name),
214 "duplicate command name: {name}"
215 );
216 self.handlers.push(Box::new(handler));
217 }
218
219 /// Dispatch a command string to the matching handler.
220 ///
221 /// Returns `None` if the input does not start with `/` or no handler matches.
222 ///
223 /// # Algorithm
224 ///
225 /// 1. Return `None` if `input` does not start with `/`.
226 /// 2. Find all handlers where `input == name` or `input.starts_with(name + " ")`.
227 /// 3. Pick the handler with the longest matching name (subcommand resolution).
228 /// 4. Extract `args = input[name.len()..].trim()`.
229 /// 5. Call `handler.handle(ctx, args)` and return the result.
230 ///
231 /// # Errors
232 ///
233 /// Returns `Some(Err(_))` when the matched handler returns an error.
234 pub async fn dispatch(
235 &self,
236 ctx: &mut Ctx,
237 input: &str,
238 ) -> Option<Result<CommandOutput, CommandError>> {
239 let trimmed = input.trim();
240 if !trimmed.starts_with('/') {
241 return None;
242 }
243
244 let mut best_len: usize = 0;
245 let mut best_idx: Option<usize> = None;
246 for (idx, handler) in self.handlers.iter().enumerate() {
247 let name = handler.name();
248 let matched = trimmed == name
249 || trimmed
250 .strip_prefix(name)
251 .is_some_and(|rest| rest.starts_with(' '));
252 if matched && name.len() >= best_len {
253 best_len = name.len();
254 best_idx = Some(idx);
255 }
256 }
257
258 let handler = &self.handlers[best_idx?];
259 let name = handler.name();
260 let args = trimmed[name.len()..].trim();
261 Some(handler.handle(ctx, args).await)
262 }
263
264 /// Find the handler that would be selected for the given input, without dispatching.
265 ///
266 /// Returns `Some((idx, name))` or `None` if no handler matches.
267 /// Primarily used in tests to verify routing.
268 #[must_use]
269 pub fn find_handler(&self, input: &str) -> Option<(usize, &'static str)> {
270 let trimmed = input.trim();
271 if !trimmed.starts_with('/') {
272 return None;
273 }
274 let mut best_len: usize = 0;
275 let mut best: Option<(usize, &'static str)> = None;
276 for (idx, handler) in self.handlers.iter().enumerate() {
277 let name = handler.name();
278 let matched = trimmed == name
279 || trimmed
280 .strip_prefix(name)
281 .is_some_and(|rest| rest.starts_with(' '));
282 if matched && name.len() >= best_len {
283 best_len = name.len();
284 best = Some((idx, name));
285 }
286 }
287 best
288 }
289
290 /// List all registered commands for `/help` generation.
291 ///
292 /// Returns metadata in registration order.
293 #[must_use]
294 pub fn list(&self) -> Vec<CommandInfo> {
295 self.handlers
296 .iter()
297 .map(|h| CommandInfo {
298 name: h.name(),
299 args: h.args_hint(),
300 description: h.description(),
301 category: h.category(),
302 feature_gate: h.feature_gate(),
303 })
304 .collect()
305 }
306}
307
308impl<Ctx: ?Sized> Default for CommandRegistry<Ctx> {
309 fn default() -> Self {
310 Self::new()
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use std::future::Future;
318 use std::pin::Pin;
319
320 struct MockCtx;
321
322 struct FixedHandler {
323 name: &'static str,
324 category: SlashCategory,
325 }
326
327 impl CommandHandler<MockCtx> for FixedHandler {
328 fn name(&self) -> &'static str {
329 self.name
330 }
331
332 fn description(&self) -> &'static str {
333 "test handler"
334 }
335
336 fn category(&self) -> SlashCategory {
337 self.category
338 }
339
340 fn handle<'a>(
341 &'a self,
342 _ctx: &'a mut MockCtx,
343 args: &'a str,
344 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>
345 {
346 let name = self.name;
347 Box::pin(async move { Ok(CommandOutput::Message(format!("{name}:{args}"))) })
348 }
349 }
350
351 fn make_handler(name: &'static str) -> FixedHandler {
352 FixedHandler {
353 name,
354 category: SlashCategory::Session,
355 }
356 }
357
358 #[tokio::test]
359 async fn dispatch_routes_longest_match() {
360 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
361 reg.register(make_handler("/plan"));
362 reg.register(make_handler("/plan confirm"));
363
364 let mut ctx = MockCtx;
365 let out = reg
366 .dispatch(&mut ctx, "/plan confirm foo")
367 .await
368 .unwrap()
369 .unwrap();
370 let CommandOutput::Message(msg) = out else {
371 panic!("expected Message");
372 };
373 assert_eq!(msg, "/plan confirm:foo");
374 }
375
376 #[tokio::test]
377 async fn dispatch_returns_none_for_non_slash() {
378 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
379 reg.register(make_handler("/help"));
380 let mut ctx = MockCtx;
381 assert!(reg.dispatch(&mut ctx, "hello").await.is_none());
382 }
383
384 #[tokio::test]
385 async fn dispatch_returns_none_for_unregistered() {
386 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
387 reg.register(make_handler("/help"));
388 let mut ctx = MockCtx;
389 assert!(reg.dispatch(&mut ctx, "/unknown").await.is_none());
390 }
391
392 #[test]
393 #[should_panic(expected = "duplicate command name")]
394 fn register_panics_on_duplicate() {
395 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
396 reg.register(make_handler("/plan"));
397 reg.register(make_handler("/plan"));
398 }
399
400 #[test]
401 fn list_returns_metadata_in_order() {
402 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
403 reg.register(make_handler("/alpha"));
404 reg.register(make_handler("/beta"));
405 let list = reg.list();
406 assert_eq!(list.len(), 2);
407 assert_eq!(list[0].name, "/alpha");
408 assert_eq!(list[1].name, "/beta");
409 }
410
411 #[test]
412 fn slash_category_as_str_all_variants() {
413 let variants = [
414 (SlashCategory::Session, "Session"),
415 (SlashCategory::Configuration, "Configuration"),
416 (SlashCategory::Memory, "Memory"),
417 (SlashCategory::Skills, "Skills"),
418 (SlashCategory::Planning, "Planning"),
419 (SlashCategory::Debugging, "Debugging"),
420 (SlashCategory::Integration, "Integration"),
421 (SlashCategory::Advanced, "Advanced"),
422 ];
423 for (variant, expected) in variants {
424 assert_eq!(variant.as_str(), expected);
425 }
426 }
427}