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