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