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
39/// Status of a long-horizon goal.
40///
41/// Mirrors `zeph_core::goal::GoalStatus`. Defined here to avoid a dependency cycle
42/// (`zeph-commands` cannot depend on `zeph-core`).
43#[non_exhaustive]
44#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum GoalStatusView {
47 /// Goal is being actively tracked.
48 Active,
49 /// Goal is paused; not injected into context.
50 Paused,
51 /// Goal was marked as achieved. Terminal state.
52 Completed,
53 /// Goal was dismissed. Terminal state.
54 Cleared,
55}
56
57impl GoalStatusView {
58 /// Short ASCII symbol used in TUI status badge.
59 #[must_use]
60 pub fn badge_symbol(self) -> &'static str {
61 match self {
62 Self::Active => "▶",
63 Self::Paused => "⏸",
64 Self::Completed => "✓",
65 Self::Cleared => "✗",
66 }
67 }
68}
69
70/// Lightweight cross-crate snapshot of an active goal.
71///
72/// Produced by [`AgentAccess::active_goal_snapshot`] and consumed by the TUI status bar
73/// and metrics bridge. Contains only display-relevant fields.
74#[derive(Debug, Clone, serde::Serialize)]
75pub struct GoalSnapshot {
76 /// UUID string of the goal.
77 pub id: String,
78 /// Goal text, pre-validated to fit within `max_text_chars`.
79 pub text: String,
80 /// Current FSM status.
81 pub status: GoalStatusView,
82 /// Number of turns completed under this goal.
83 pub turns_used: u64,
84 /// Total tokens consumed across all turns.
85 pub tokens_used: u64,
86 /// Optional token budget (`None` = unlimited).
87 pub token_budget: Option<u64>,
88}
89
90use std::future::Future;
91use std::pin::Pin;
92
93/// Result of executing a slash command.
94///
95/// Replaces the heterogeneous return types of earlier command dispatch with a unified,
96/// exhaustive enum.
97#[non_exhaustive]
98#[derive(Debug)]
99pub enum CommandOutput {
100 /// Send a message to the user via the channel.
101 Message(String),
102 /// Command handled silently; no output (e.g., `/clear`).
103 Silent,
104 /// Exit the agent loop immediately.
105 Exit,
106 /// Continue to the next loop iteration.
107 Continue,
108}
109
110/// Category for grouping commands in `/help` output.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112#[non_exhaustive]
113pub enum SlashCategory {
114 /// Session management: `/clear`, `/reset`, `/exit`, etc.
115 Session,
116 /// Model and provider configuration: `/model`, `/provider`, `/guardrail`, etc.
117 Configuration,
118 /// Memory and knowledge: `/memory`, `/graph`, `/compact`, etc.
119 Memory,
120 /// Skill management: `/skill`, `/skills`, etc.
121 Skills,
122 /// Planning and focus: `/plan`, `/focus`, `/sidequest`, etc.
123 Planning,
124 /// Debugging and diagnostics: `/debug-dump`, `/log`, `/lsp`, etc.
125 Debugging,
126 /// External integrations: `/mcp`, `/image`, `/agent`, etc.
127 Integration,
128 /// Advanced and experimental: `/experiment`, `/policy`, `/scheduler`, etc.
129 Advanced,
130}
131
132impl SlashCategory {
133 /// Return the display label for this category in `/help` output.
134 #[must_use]
135 pub fn as_str(self) -> &'static str {
136 match self {
137 Self::Session => "Session",
138 Self::Configuration => "Configuration",
139 Self::Memory => "Memory",
140 Self::Skills => "Skills",
141 Self::Planning => "Planning",
142 Self::Debugging => "Debugging",
143 Self::Integration => "Integration",
144 Self::Advanced => "Advanced",
145 }
146 }
147}
148
149/// Static metadata about a registered command, used for `/help` output generation.
150pub struct CommandInfo {
151 /// Command name including the leading slash, e.g. `"/help"`.
152 pub name: &'static str,
153 /// Argument hint shown after the command name in help, e.g. `"[path]"`.
154 pub args: &'static str,
155 /// One-line description shown in `/help` output.
156 pub description: &'static str,
157 /// Category for grouping in `/help`.
158 pub category: SlashCategory,
159 /// Feature gate label, if this command is conditionally compiled.
160 pub feature_gate: Option<&'static str>,
161}
162
163/// Error type returned by command handlers.
164///
165/// Wraps agent-level errors as a string to avoid depending on `zeph-core`'s `AgentError`.
166/// `zeph-core` converts between `AgentError` and `CommandError` at the dispatch boundary.
167#[derive(Debug, thiserror::Error)]
168#[error("{0}")]
169pub struct CommandError(pub String);
170
171impl CommandError {
172 /// Create a `CommandError` from any displayable value.
173 pub fn new(msg: impl std::fmt::Display) -> Self {
174 Self(msg.to_string())
175 }
176}
177
178/// A slash command handler that can be registered with [`CommandRegistry`].
179///
180/// Implementors must be `Send + Sync` because the registry is constructed at agent
181/// initialization time and handlers may be invoked from async contexts.
182///
183/// # Object safety
184///
185/// The `handle` method uses `Pin<Box<dyn Future>>` instead of `async fn` to remain
186/// object-safe, enabling the registry to store `Box<dyn CommandHandler<Ctx>>`. Slash
187/// commands are user-initiated so the box allocation is negligible.
188pub trait CommandHandler<Ctx: ?Sized>: Send + Sync {
189 /// Command name including the leading slash, e.g. `"/help"`.
190 ///
191 /// Must be unique per registry. Used as the dispatch key.
192 fn name(&self) -> &'static str;
193
194 /// One-line description shown in `/help` output.
195 fn description(&self) -> &'static str;
196
197 /// Argument hint shown after the command name in help, e.g. `"[path]"`.
198 ///
199 /// Return an empty string if the command takes no arguments.
200 fn args_hint(&self) -> &'static str {
201 ""
202 }
203
204 /// Category for grouping in `/help`.
205 fn category(&self) -> SlashCategory;
206
207 /// Feature gate label, if this command is conditionally compiled.
208 fn feature_gate(&self) -> Option<&'static str> {
209 None
210 }
211
212 /// Execute the command.
213 ///
214 /// # Arguments
215 ///
216 /// - `ctx`: Typed access to agent subsystems.
217 /// - `args`: Trimmed text after the command name. Empty string when no args given.
218 ///
219 /// # Errors
220 ///
221 /// Returns `Err(CommandError)` when the command fails. The dispatch site logs and
222 /// reports the error to the user.
223 fn handle<'a>(
224 &'a self,
225 ctx: &'a mut Ctx,
226 args: &'a str,
227 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>;
228}
229
230/// Registry of slash command handlers.
231///
232/// Handlers are stored in a `Vec`, not a `HashMap`, because command count is small (< 40)
233/// and registration happens once at agent initialization. Dispatch performs a linear scan
234/// with longest-word-boundary match to support subcommands.
235///
236/// # Dispatch
237///
238/// See [`CommandRegistry::dispatch`] for the full dispatch algorithm.
239///
240/// # Borrow splitting
241///
242/// When stored as an `Agent<C>` field, the dispatch call site uses `std::mem::take` to
243/// temporarily move the registry out of the agent, construct a context, dispatch, and
244/// restore the registry. This avoids borrow-checker conflicts.
245pub struct CommandRegistry<Ctx: ?Sized> {
246 handlers: Vec<Box<dyn CommandHandler<Ctx>>>,
247}
248
249impl<Ctx: ?Sized> CommandRegistry<Ctx> {
250 /// Create an empty registry.
251 #[must_use]
252 pub fn new() -> Self {
253 Self {
254 handlers: Vec::new(),
255 }
256 }
257
258 /// Register a command handler.
259 ///
260 /// # Panics
261 ///
262 /// Panics if a handler with the same name is already registered.
263 pub fn register(&mut self, handler: impl CommandHandler<Ctx> + 'static) {
264 let name = handler.name();
265 assert!(
266 !self.handlers.iter().any(|h| h.name() == name),
267 "duplicate command name: {name}"
268 );
269 self.handlers.push(Box::new(handler));
270 }
271
272 /// Dispatch a command string to the matching handler.
273 ///
274 /// Returns `None` if the input does not start with `/` or no handler matches.
275 ///
276 /// # Algorithm
277 ///
278 /// 1. Return `None` if `input` does not start with `/`.
279 /// 2. Find all handlers where `input == name` or `input.starts_with(name + " ")`.
280 /// 3. Pick the handler with the longest matching name (subcommand resolution).
281 /// 4. Extract `args = input[name.len()..].trim()`.
282 /// 5. Call `handler.handle(ctx, args)` and return the result.
283 ///
284 /// # Errors
285 ///
286 /// Returns `Some(Err(_))` when the matched handler returns an error.
287 pub async fn dispatch(
288 &self,
289 ctx: &mut Ctx,
290 input: &str,
291 ) -> Option<Result<CommandOutput, CommandError>> {
292 let trimmed = input.trim();
293 if !trimmed.starts_with('/') {
294 return None;
295 }
296
297 let mut best_len: usize = 0;
298 let mut best_idx: Option<usize> = None;
299 for (idx, handler) in self.handlers.iter().enumerate() {
300 let name = handler.name();
301 let matched = trimmed == name
302 || trimmed
303 .strip_prefix(name)
304 .is_some_and(|rest| rest.starts_with(' '));
305 if matched && name.len() >= best_len {
306 best_len = name.len();
307 best_idx = Some(idx);
308 }
309 }
310
311 let handler = &self.handlers[best_idx?];
312 let name = handler.name();
313 let args = trimmed[name.len()..].trim();
314 Some(handler.handle(ctx, args).await)
315 }
316
317 /// Find the handler that would be selected for the given input, without dispatching.
318 ///
319 /// Returns `Some((idx, name))` or `None` if no handler matches.
320 /// Primarily used in tests to verify routing.
321 #[must_use]
322 pub fn find_handler(&self, input: &str) -> Option<(usize, &'static str)> {
323 let trimmed = input.trim();
324 if !trimmed.starts_with('/') {
325 return None;
326 }
327 let mut best_len: usize = 0;
328 let mut best: Option<(usize, &'static str)> = None;
329 for (idx, handler) in self.handlers.iter().enumerate() {
330 let name = handler.name();
331 let matched = trimmed == name
332 || trimmed
333 .strip_prefix(name)
334 .is_some_and(|rest| rest.starts_with(' '));
335 if matched && name.len() >= best_len {
336 best_len = name.len();
337 best = Some((idx, name));
338 }
339 }
340 best
341 }
342
343 /// List all registered commands for `/help` generation.
344 ///
345 /// Returns metadata in registration order.
346 #[must_use]
347 pub fn list(&self) -> Vec<CommandInfo> {
348 self.handlers
349 .iter()
350 .map(|h| CommandInfo {
351 name: h.name(),
352 args: h.args_hint(),
353 description: h.description(),
354 category: h.category(),
355 feature_gate: h.feature_gate(),
356 })
357 .collect()
358 }
359}
360
361impl<Ctx: ?Sized> Default for CommandRegistry<Ctx> {
362 fn default() -> Self {
363 Self::new()
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use std::future::Future;
371 use std::pin::Pin;
372
373 struct MockCtx;
374
375 struct FixedHandler {
376 name: &'static str,
377 category: SlashCategory,
378 }
379
380 impl CommandHandler<MockCtx> for FixedHandler {
381 fn name(&self) -> &'static str {
382 self.name
383 }
384
385 fn description(&self) -> &'static str {
386 "test handler"
387 }
388
389 fn category(&self) -> SlashCategory {
390 self.category
391 }
392
393 fn handle<'a>(
394 &'a self,
395 _ctx: &'a mut MockCtx,
396 args: &'a str,
397 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>
398 {
399 let name = self.name;
400 Box::pin(async move { Ok(CommandOutput::Message(format!("{name}:{args}"))) })
401 }
402 }
403
404 fn make_handler(name: &'static str) -> FixedHandler {
405 FixedHandler {
406 name,
407 category: SlashCategory::Session,
408 }
409 }
410
411 #[tokio::test]
412 async fn dispatch_routes_longest_match() {
413 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
414 reg.register(make_handler("/plan"));
415 reg.register(make_handler("/plan confirm"));
416
417 let mut ctx = MockCtx;
418 let out = reg
419 .dispatch(&mut ctx, "/plan confirm foo")
420 .await
421 .unwrap()
422 .unwrap();
423 let CommandOutput::Message(msg) = out else {
424 panic!("expected Message");
425 };
426 assert_eq!(msg, "/plan confirm:foo");
427 }
428
429 #[tokio::test]
430 async fn dispatch_returns_none_for_non_slash() {
431 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
432 reg.register(make_handler("/help"));
433 let mut ctx = MockCtx;
434 assert!(reg.dispatch(&mut ctx, "hello").await.is_none());
435 }
436
437 #[tokio::test]
438 async fn dispatch_returns_none_for_unregistered() {
439 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
440 reg.register(make_handler("/help"));
441 let mut ctx = MockCtx;
442 assert!(reg.dispatch(&mut ctx, "/unknown").await.is_none());
443 }
444
445 #[test]
446 #[should_panic(expected = "duplicate command name")]
447 fn register_panics_on_duplicate() {
448 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
449 reg.register(make_handler("/plan"));
450 reg.register(make_handler("/plan"));
451 }
452
453 #[test]
454 fn list_returns_metadata_in_order() {
455 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
456 reg.register(make_handler("/alpha"));
457 reg.register(make_handler("/beta"));
458 let list = reg.list();
459 assert_eq!(list.len(), 2);
460 assert_eq!(list[0].name, "/alpha");
461 assert_eq!(list[1].name, "/beta");
462 }
463
464 #[test]
465 fn slash_category_as_str_all_variants() {
466 let variants = [
467 (SlashCategory::Session, "Session"),
468 (SlashCategory::Configuration, "Configuration"),
469 (SlashCategory::Memory, "Memory"),
470 (SlashCategory::Skills, "Skills"),
471 (SlashCategory::Planning, "Planning"),
472 (SlashCategory::Debugging, "Debugging"),
473 (SlashCategory::Integration, "Integration"),
474 (SlashCategory::Advanced, "Advanced"),
475 ];
476 for (variant, expected) in variants {
477 assert_eq!(variant.as_str(), expected);
478 }
479 }
480}