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