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
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    /// Returns `true` if this command requires a trusted (local) caller.
213    ///
214    /// When `true`, [`CommandRegistry::dispatch`] rejects the command with an authorization
215    /// error if the dispatch site passes `trusted = false`. Commands in the `Debugging`,
216    /// `Configuration`, and `Advanced` categories that should not be accessible from
217    /// remote channels (Telegram, Discord, Slack) must override this to return `true`.
218    ///
219    /// The default returns `false` (accessible from all channels).
220    fn requires_auth(&self) -> bool {
221        false
222    }
223
224    /// Execute the command.
225    ///
226    /// # Arguments
227    ///
228    /// - `ctx`: Typed access to agent subsystems.
229    /// - `args`: Trimmed text after the command name. Empty string when no args given.
230    ///
231    /// # Errors
232    ///
233    /// Returns `Err(CommandError)` when the command fails. The dispatch site logs and
234    /// reports the error to the user.
235    fn handle<'a>(
236        &'a self,
237        ctx: &'a mut Ctx,
238        args: &'a str,
239    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>;
240}
241
242/// Registry of slash command handlers.
243///
244/// Handlers are stored in a `Vec`, not a `HashMap`, because command count is small (< 40)
245/// and registration happens once at agent initialization. Dispatch performs a linear scan
246/// with longest-word-boundary match to support subcommands.
247///
248/// # Dispatch
249///
250/// See [`CommandRegistry::dispatch`] for the full dispatch algorithm.
251///
252/// # Borrow splitting
253///
254/// When stored as an `Agent<C>` field, the dispatch call site uses `std::mem::take` to
255/// temporarily move the registry out of the agent, construct a context, dispatch, and
256/// restore the registry. This avoids borrow-checker conflicts.
257pub struct CommandRegistry<Ctx: ?Sized> {
258    handlers: Vec<Box<dyn CommandHandler<Ctx>>>,
259}
260
261impl<Ctx: ?Sized> CommandRegistry<Ctx> {
262    /// Create an empty registry.
263    #[must_use]
264    pub fn new() -> Self {
265        Self {
266            handlers: Vec::new(),
267        }
268    }
269
270    /// Register a command handler.
271    ///
272    /// # Panics
273    ///
274    /// Panics if a handler with the same name is already registered.
275    pub fn register(&mut self, handler: impl CommandHandler<Ctx> + 'static) {
276        let name = handler.name();
277        assert!(
278            !self.handlers.iter().any(|h| h.name() == name),
279            "duplicate command name: {name}"
280        );
281        self.handlers.push(Box::new(handler));
282    }
283
284    /// Dispatch a command string to the matching handler.
285    ///
286    /// Returns `None` if the input does not start with `/` or no handler matches.
287    ///
288    /// # Authorization
289    ///
290    /// When `trusted` is `false`, handlers that return `true` from
291    /// [`CommandHandler::requires_auth`] are rejected with a `CommandError` before execution.
292    /// Pass `trusted = true` for local CLI sessions; `false` for remote channels
293    /// (Telegram, Discord, Slack) where callers are not unconditionally trusted.
294    ///
295    /// # Algorithm
296    ///
297    /// 1. Return `None` if `input` does not start with `/`.
298    /// 2. Find all handlers where `input == name` or `input.starts_with(name + " ")`.
299    /// 3. Pick the handler with the longest matching name (subcommand resolution).
300    /// 4. If `!trusted && handler.requires_auth()`, return `Some(Err(...))`.
301    /// 5. Extract `args = input[name.len()..].trim()`.
302    /// 6. Call `handler.handle(ctx, args)` and return the result.
303    ///
304    /// # Errors
305    ///
306    /// Returns `Some(Err(_))` when authorization fails or the matched handler returns an error.
307    #[cfg_attr(
308        feature = "profiling",
309        tracing::instrument(
310            name = "commands.dispatch",
311            skip_all,
312            fields(input = tracing::field::Empty, matched = tracing::field::Empty)
313        )
314    )]
315    pub async fn dispatch(
316        &self,
317        ctx: &mut Ctx,
318        input: &str,
319        trusted: bool,
320    ) -> Option<Result<CommandOutput, CommandError>> {
321        let trimmed = input.trim();
322        if !trimmed.starts_with('/') {
323            return None;
324        }
325
326        let mut best_len: usize = 0;
327        let mut best_idx: Option<usize> = None;
328        for (idx, handler) in self.handlers.iter().enumerate() {
329            let name = handler.name();
330            let matched = trimmed == name
331                || trimmed
332                    .strip_prefix(name)
333                    .is_some_and(|rest| rest.starts_with(' '));
334            if matched && name.len() >= best_len {
335                best_len = name.len();
336                best_idx = Some(idx);
337            }
338        }
339
340        let handler = &self.handlers[best_idx?];
341        if !trusted && handler.requires_auth() {
342            return Some(Err(CommandError::new(
343                "this command requires a trusted (local) session",
344            )));
345        }
346        let name = handler.name();
347        let args = trimmed[name.len()..].trim();
348        Some(handler.handle(ctx, args).await)
349    }
350
351    /// Find the handler that would be selected for the given input, without dispatching.
352    ///
353    /// Returns `Some((idx, name))` or `None` if no handler matches.
354    /// Primarily used in tests to verify routing.
355    #[must_use]
356    pub fn find_handler(&self, input: &str) -> Option<(usize, &'static str)> {
357        let trimmed = input.trim();
358        if !trimmed.starts_with('/') {
359            return None;
360        }
361        let mut best_len: usize = 0;
362        let mut best: Option<(usize, &'static str)> = None;
363        for (idx, handler) in self.handlers.iter().enumerate() {
364            let name = handler.name();
365            let matched = trimmed == name
366                || trimmed
367                    .strip_prefix(name)
368                    .is_some_and(|rest| rest.starts_with(' '));
369            if matched && name.len() >= best_len {
370                best_len = name.len();
371                best = Some((idx, name));
372            }
373        }
374        best
375    }
376
377    /// List all registered commands for `/help` generation.
378    ///
379    /// Returns metadata in registration order.
380    #[must_use]
381    pub fn list(&self) -> Vec<CommandInfo> {
382        self.handlers
383            .iter()
384            .map(|h| CommandInfo {
385                name: h.name(),
386                args: h.args_hint(),
387                description: h.description(),
388                category: h.category(),
389                feature_gate: h.feature_gate(),
390            })
391            .collect()
392    }
393}
394
395impl<Ctx: ?Sized> Default for CommandRegistry<Ctx> {
396    fn default() -> Self {
397        Self::new()
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use std::future::Future;
405    use std::pin::Pin;
406
407    struct MockCtx;
408
409    struct FixedHandler {
410        name: &'static str,
411        category: SlashCategory,
412    }
413
414    impl CommandHandler<MockCtx> for FixedHandler {
415        fn name(&self) -> &'static str {
416            self.name
417        }
418
419        fn description(&self) -> &'static str {
420            "test handler"
421        }
422
423        fn category(&self) -> SlashCategory {
424            self.category
425        }
426
427        fn handle<'a>(
428            &'a self,
429            _ctx: &'a mut MockCtx,
430            args: &'a str,
431        ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>
432        {
433            let name = self.name;
434            Box::pin(async move { Ok(CommandOutput::Message(format!("{name}:{args}"))) })
435        }
436    }
437
438    fn make_handler(name: &'static str) -> FixedHandler {
439        FixedHandler {
440            name,
441            category: SlashCategory::Session,
442        }
443    }
444
445    #[tokio::test]
446    async fn dispatch_routes_longest_match() {
447        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
448        reg.register(make_handler("/plan"));
449        reg.register(make_handler("/plan confirm"));
450
451        let mut ctx = MockCtx;
452        let out = reg
453            .dispatch(&mut ctx, "/plan confirm foo", true)
454            .await
455            .unwrap()
456            .unwrap();
457        let CommandOutput::Message(msg) = out else {
458            panic!("expected Message");
459        };
460        assert_eq!(msg, "/plan confirm:foo");
461    }
462
463    #[tokio::test]
464    async fn dispatch_returns_none_for_non_slash() {
465        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
466        reg.register(make_handler("/help"));
467        let mut ctx = MockCtx;
468        assert!(reg.dispatch(&mut ctx, "hello", true).await.is_none());
469    }
470
471    #[tokio::test]
472    async fn dispatch_returns_none_for_unregistered() {
473        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
474        reg.register(make_handler("/help"));
475        let mut ctx = MockCtx;
476        assert!(reg.dispatch(&mut ctx, "/unknown", true).await.is_none());
477    }
478
479    #[test]
480    #[should_panic(expected = "duplicate command name")]
481    fn register_panics_on_duplicate() {
482        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
483        reg.register(make_handler("/plan"));
484        reg.register(make_handler("/plan"));
485    }
486
487    #[test]
488    fn list_returns_metadata_in_order() {
489        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
490        reg.register(make_handler("/alpha"));
491        reg.register(make_handler("/beta"));
492        let list = reg.list();
493        assert_eq!(list.len(), 2);
494        assert_eq!(list[0].name, "/alpha");
495        assert_eq!(list[1].name, "/beta");
496    }
497
498    #[tokio::test]
499    async fn dispatch_rejects_privileged_command_when_untrusted() {
500        struct PrivHandler;
501        impl CommandHandler<MockCtx> for PrivHandler {
502            fn name(&self) -> &'static str {
503                "/secret"
504            }
505            fn description(&self) -> &'static str {
506                "secret"
507            }
508            fn category(&self) -> SlashCategory {
509                SlashCategory::Debugging
510            }
511            fn requires_auth(&self) -> bool {
512                true
513            }
514            fn handle<'a>(
515                &'a self,
516                _ctx: &'a mut MockCtx,
517                _args: &'a str,
518            ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>
519            {
520                Box::pin(async { Ok(CommandOutput::Silent) })
521            }
522        }
523
524        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
525        reg.register(PrivHandler);
526        let mut ctx = MockCtx;
527
528        // Trusted: command executes.
529        let result = reg.dispatch(&mut ctx, "/secret", true).await;
530        assert!(result.unwrap().is_ok());
531
532        // Untrusted: command is rejected.
533        let result = reg.dispatch(&mut ctx, "/secret", false).await;
534        let err = result.unwrap().unwrap_err();
535        assert!(err.0.contains("trusted"));
536    }
537
538    #[test]
539    fn slash_category_as_str_all_variants() {
540        let variants = [
541            (SlashCategory::Session, "Session"),
542            (SlashCategory::Configuration, "Configuration"),
543            (SlashCategory::Memory, "Memory"),
544            (SlashCategory::Skills, "Skills"),
545            (SlashCategory::Planning, "Planning"),
546            (SlashCategory::Debugging, "Debugging"),
547            (SlashCategory::Integration, "Integration"),
548            (SlashCategory::Advanced, "Advanced"),
549        ];
550        for (variant, expected) in variants {
551            assert_eq!(variant.as_str(), expected);
552        }
553    }
554}