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
110impl CommandOutput {
111 /// `Silent` for an empty string, `Message(s)` otherwise.
112 #[must_use]
113 pub fn message_or_silent(s: String) -> Self {
114 if s.is_empty() {
115 Self::Silent
116 } else {
117 Self::Message(s)
118 }
119 }
120}
121
122/// Category for grouping commands in `/help` output.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124#[non_exhaustive]
125pub enum SlashCategory {
126 /// Session management: `/clear`, `/reset`, `/exit`, etc.
127 Session,
128 /// Model and provider configuration: `/model`, `/provider`, `/guardrail`, etc.
129 Configuration,
130 /// Memory and knowledge: `/memory`, `/graph`, `/compact`, etc.
131 Memory,
132 /// Skill management: `/skill`, `/skills`, etc.
133 Skills,
134 /// Planning and focus: `/plan`, `/focus`, `/sidequest`, etc.
135 Planning,
136 /// Debugging and diagnostics: `/debug-dump`, `/log`, `/lsp`, etc.
137 Debugging,
138 /// External integrations: `/mcp`, `/image`, `/agent`, etc.
139 Integration,
140 /// Advanced and experimental: `/experiment`, `/policy`, `/scheduler`, etc.
141 Advanced,
142}
143
144impl SlashCategory {
145 /// Return the display label for this category in `/help` output.
146 #[must_use]
147 pub fn as_str(self) -> &'static str {
148 match self {
149 Self::Session => "Session",
150 Self::Configuration => "Configuration",
151 Self::Memory => "Memory",
152 Self::Skills => "Skills",
153 Self::Planning => "Planning",
154 Self::Debugging => "Debugging",
155 Self::Integration => "Integration",
156 Self::Advanced => "Advanced",
157 }
158 }
159}
160
161/// Static metadata about a registered command, used for `/help` output generation.
162pub struct CommandInfo {
163 /// Command name including the leading slash, e.g. `"/help"`.
164 pub name: &'static str,
165 /// Argument hint shown after the command name in help, e.g. `"[path]"`.
166 pub args: &'static str,
167 /// One-line description shown in `/help` output.
168 pub description: &'static str,
169 /// Category for grouping in `/help`.
170 pub category: SlashCategory,
171 /// Feature gate label, if this command is conditionally compiled.
172 pub feature_gate: Option<&'static str>,
173}
174
175/// Error type returned by command handlers.
176///
177/// Wraps agent-level errors as a string to avoid depending on `zeph-core`'s `AgentError`.
178/// `zeph-core` converts between `AgentError` and `CommandError` at the dispatch boundary.
179#[derive(Debug, thiserror::Error)]
180#[error("{0}")]
181pub struct CommandError(pub String);
182
183impl CommandError {
184 /// Create a `CommandError` from any displayable value.
185 pub fn new(msg: impl std::fmt::Display) -> Self {
186 Self(msg.to_string())
187 }
188}
189
190/// A slash command handler that can be registered with [`CommandRegistry`].
191///
192/// Implementors must be `Send + Sync` because the registry is constructed at agent
193/// initialization time and handlers may be invoked from async contexts.
194///
195/// # Object safety
196///
197/// The `handle` method uses `Pin<Box<dyn Future>>` instead of `async fn` to remain
198/// object-safe, enabling the registry to store `Box<dyn CommandHandler<Ctx>>`. Slash
199/// commands are user-initiated so the box allocation is negligible.
200pub trait CommandHandler<Ctx: ?Sized>: Send + Sync {
201 /// Command name including the leading slash, e.g. `"/help"`.
202 ///
203 /// Must be unique per registry. Used as the dispatch key.
204 fn name(&self) -> &'static str;
205
206 /// One-line description shown in `/help` output.
207 fn description(&self) -> &'static str;
208
209 /// Argument hint shown after the command name in help, e.g. `"[path]"`.
210 ///
211 /// Return an empty string if the command takes no arguments.
212 fn args_hint(&self) -> &'static str {
213 ""
214 }
215
216 /// Category for grouping in `/help`.
217 fn category(&self) -> SlashCategory;
218
219 /// Feature gate label, if this command is conditionally compiled.
220 fn feature_gate(&self) -> Option<&'static str> {
221 None
222 }
223
224 /// Returns `true` if this command requires a trusted (local) caller.
225 ///
226 /// When `true`, [`CommandRegistry::dispatch`] rejects the command with an authorization
227 /// error if the dispatch site passes `trusted = false`. Commands in the `Debugging`,
228 /// `Configuration`, and `Advanced` categories that should not be accessible from
229 /// remote channels (Telegram, Discord, Slack) must override this to return `true`.
230 ///
231 /// The default returns `false` (accessible from all channels).
232 fn requires_auth(&self) -> bool {
233 false
234 }
235
236 /// Execute the command.
237 ///
238 /// # Arguments
239 ///
240 /// - `ctx`: Typed access to agent subsystems.
241 /// - `args`: Trimmed text after the command name. Empty string when no args given.
242 ///
243 /// # Errors
244 ///
245 /// Returns `Err(CommandError)` when the command fails. The dispatch site logs and
246 /// reports the error to the user.
247 fn handle<'a>(
248 &'a self,
249 ctx: &'a mut Ctx,
250 args: &'a str,
251 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>;
252}
253
254/// Registry of slash command handlers.
255///
256/// Handlers are stored in a `Vec`, not a `HashMap`, because command count is small (< 40)
257/// and registration happens once at agent initialization. Dispatch performs a linear scan
258/// with longest-word-boundary match to support subcommands.
259///
260/// # Dispatch
261///
262/// See [`CommandRegistry::dispatch`] for the full dispatch algorithm.
263///
264/// # Borrow splitting
265///
266/// When stored as an `Agent<C>` field, the dispatch call site uses `std::mem::take` to
267/// temporarily move the registry out of the agent, construct a context, dispatch, and
268/// restore the registry. This avoids borrow-checker conflicts.
269pub struct CommandRegistry<Ctx: ?Sized> {
270 handlers: Vec<Box<dyn CommandHandler<Ctx>>>,
271}
272
273impl<Ctx: ?Sized> CommandRegistry<Ctx> {
274 /// Create an empty registry.
275 #[must_use]
276 pub fn new() -> Self {
277 Self {
278 handlers: Vec::new(),
279 }
280 }
281
282 /// Register a command handler.
283 ///
284 /// # Panics
285 ///
286 /// Panics if a handler with the same name is already registered.
287 pub fn register(&mut self, handler: impl CommandHandler<Ctx> + 'static) {
288 let name = handler.name();
289 assert!(
290 !self.handlers.iter().any(|h| h.name() == name),
291 "duplicate command name: {name}"
292 );
293 self.handlers.push(Box::new(handler));
294 }
295
296 /// Dispatch a command string to the matching handler.
297 ///
298 /// Returns `None` if the input does not start with `/` or no handler matches.
299 ///
300 /// # Authorization
301 ///
302 /// When `trusted` is `false`, handlers that return `true` from
303 /// [`CommandHandler::requires_auth`] are rejected with a `CommandError` before execution.
304 /// Pass `trusted = true` for local CLI sessions; `false` for remote channels
305 /// (Telegram, Discord, Slack) where callers are not unconditionally trusted.
306 ///
307 /// # Algorithm
308 ///
309 /// 1. Return `None` if `input` does not start with `/`.
310 /// 2. Find all handlers where `input == name` or `input.starts_with(name + " ")`.
311 /// 3. Pick the handler with the longest matching name (subcommand resolution).
312 /// 4. If `!trusted && handler.requires_auth()`, return `Some(Err(...))`.
313 /// 5. Extract `args = input[name.len()..].trim()`.
314 /// 6. Call `handler.handle(ctx, args)` and return the result.
315 ///
316 /// # Errors
317 ///
318 /// Returns `Some(Err(_))` when authorization fails or the matched handler returns an error.
319 #[tracing::instrument(name = "commands.dispatch", skip(self, ctx))]
320 pub async fn dispatch(
321 &self,
322 ctx: &mut Ctx,
323 input: &str,
324 trusted: bool,
325 ) -> Option<Result<CommandOutput, CommandError>> {
326 let trimmed = input.trim();
327 if !trimmed.starts_with('/') {
328 return None;
329 }
330
331 let mut best_len: usize = 0;
332 let mut best_idx: Option<usize> = None;
333 for (idx, handler) in self.handlers.iter().enumerate() {
334 let name = handler.name();
335 let matched = trimmed == name
336 || trimmed
337 .strip_prefix(name)
338 .is_some_and(|rest| rest.starts_with(' '));
339 if matched && name.len() >= best_len {
340 best_len = name.len();
341 best_idx = Some(idx);
342 }
343 }
344
345 let handler = &self.handlers[best_idx?];
346 if !trusted && handler.requires_auth() {
347 return Some(Err(CommandError::new(
348 "this command requires a trusted (local) session",
349 )));
350 }
351 let name = handler.name();
352 let args = trimmed[name.len()..].trim();
353 Some(handler.handle(ctx, args).await)
354 }
355
356 /// Find the handler that would be selected for the given input, without dispatching.
357 ///
358 /// Returns `Some((idx, name))` or `None` if no handler matches.
359 /// Primarily used in tests to verify routing.
360 #[must_use]
361 pub fn find_handler(&self, input: &str) -> Option<(usize, &'static str)> {
362 let trimmed = input.trim();
363 if !trimmed.starts_with('/') {
364 return None;
365 }
366 let mut best_len: usize = 0;
367 let mut best: Option<(usize, &'static str)> = None;
368 for (idx, handler) in self.handlers.iter().enumerate() {
369 let name = handler.name();
370 let matched = trimmed == name
371 || trimmed
372 .strip_prefix(name)
373 .is_some_and(|rest| rest.starts_with(' '));
374 if matched && name.len() >= best_len {
375 best_len = name.len();
376 best = Some((idx, name));
377 }
378 }
379 best
380 }
381
382 /// List all registered commands for `/help` generation.
383 ///
384 /// Returns metadata in registration order.
385 #[must_use]
386 pub fn list(&self) -> Vec<CommandInfo> {
387 self.handlers
388 .iter()
389 .map(|h| CommandInfo {
390 name: h.name(),
391 args: h.args_hint(),
392 description: h.description(),
393 category: h.category(),
394 feature_gate: h.feature_gate(),
395 })
396 .collect()
397 }
398}
399
400impl<Ctx: ?Sized> Default for CommandRegistry<Ctx> {
401 fn default() -> Self {
402 Self::new()
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use std::future::Future;
410 use std::pin::Pin;
411
412 struct MockCtx;
413
414 struct FixedHandler {
415 name: &'static str,
416 category: SlashCategory,
417 }
418
419 impl CommandHandler<MockCtx> for FixedHandler {
420 fn name(&self) -> &'static str {
421 self.name
422 }
423
424 fn description(&self) -> &'static str {
425 "test handler"
426 }
427
428 fn category(&self) -> SlashCategory {
429 self.category
430 }
431
432 fn handle<'a>(
433 &'a self,
434 _ctx: &'a mut MockCtx,
435 args: &'a str,
436 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>
437 {
438 let name = self.name;
439 Box::pin(async move { Ok(CommandOutput::Message(format!("{name}:{args}"))) })
440 }
441 }
442
443 fn make_handler(name: &'static str) -> FixedHandler {
444 FixedHandler {
445 name,
446 category: SlashCategory::Session,
447 }
448 }
449
450 #[tokio::test]
451 async fn dispatch_routes_longest_match() {
452 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
453 reg.register(make_handler("/plan"));
454 reg.register(make_handler("/plan confirm"));
455
456 let mut ctx = MockCtx;
457 let out = reg
458 .dispatch(&mut ctx, "/plan confirm foo", true)
459 .await
460 .unwrap()
461 .unwrap();
462 let CommandOutput::Message(msg) = out else {
463 panic!("expected Message");
464 };
465 assert_eq!(msg, "/plan confirm:foo");
466 }
467
468 #[tokio::test]
469 async fn dispatch_returns_none_for_non_slash() {
470 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
471 reg.register(make_handler("/help"));
472 let mut ctx = MockCtx;
473 assert!(reg.dispatch(&mut ctx, "hello", true).await.is_none());
474 }
475
476 #[tokio::test]
477 async fn dispatch_returns_none_for_unregistered() {
478 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
479 reg.register(make_handler("/help"));
480 let mut ctx = MockCtx;
481 assert!(reg.dispatch(&mut ctx, "/unknown", true).await.is_none());
482 }
483
484 #[test]
485 #[should_panic(expected = "duplicate command name")]
486 fn register_panics_on_duplicate() {
487 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
488 reg.register(make_handler("/plan"));
489 reg.register(make_handler("/plan"));
490 }
491
492 #[test]
493 fn list_returns_metadata_in_order() {
494 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
495 reg.register(make_handler("/alpha"));
496 reg.register(make_handler("/beta"));
497 let list = reg.list();
498 assert_eq!(list.len(), 2);
499 assert_eq!(list[0].name, "/alpha");
500 assert_eq!(list[1].name, "/beta");
501 }
502
503 #[tokio::test]
504 async fn dispatch_rejects_privileged_command_when_untrusted() {
505 struct PrivHandler;
506 impl CommandHandler<MockCtx> for PrivHandler {
507 fn name(&self) -> &'static str {
508 "/secret"
509 }
510 fn description(&self) -> &'static str {
511 "secret"
512 }
513 fn category(&self) -> SlashCategory {
514 SlashCategory::Debugging
515 }
516 fn requires_auth(&self) -> bool {
517 true
518 }
519 fn handle<'a>(
520 &'a self,
521 _ctx: &'a mut MockCtx,
522 _args: &'a str,
523 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>
524 {
525 Box::pin(async { Ok(CommandOutput::Silent) })
526 }
527 }
528
529 let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
530 reg.register(PrivHandler);
531 let mut ctx = MockCtx;
532
533 // Trusted: command executes.
534 let result = reg.dispatch(&mut ctx, "/secret", true).await;
535 assert!(result.unwrap().is_ok());
536
537 // Untrusted: command is rejected.
538 let result = reg.dispatch(&mut ctx, "/secret", false).await;
539 let err = result.unwrap().unwrap_err();
540 assert!(err.0.contains("trusted"));
541 }
542
543 #[test]
544 fn message_or_silent_empty_is_silent() {
545 assert!(matches!(
546 CommandOutput::message_or_silent(String::new()),
547 CommandOutput::Silent
548 ));
549 }
550
551 #[test]
552 fn message_or_silent_non_empty_is_message() {
553 let CommandOutput::Message(msg) = CommandOutput::message_or_silent("hi".to_string()) else {
554 panic!("expected Message");
555 };
556 assert_eq!(msg, "hi");
557 }
558
559 #[test]
560 fn slash_category_as_str_all_variants() {
561 let variants = [
562 (SlashCategory::Session, "Session"),
563 (SlashCategory::Configuration, "Configuration"),
564 (SlashCategory::Memory, "Memory"),
565 (SlashCategory::Skills, "Skills"),
566 (SlashCategory::Planning, "Planning"),
567 (SlashCategory::Debugging, "Debugging"),
568 (SlashCategory::Integration, "Integration"),
569 (SlashCategory::Advanced, "Advanced"),
570 ];
571 for (variant, expected) in variants {
572 assert_eq!(variant.as_str(), expected);
573 }
574 }
575}