Skip to main content

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}