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