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}