1pub 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#[non_exhaustive]
62#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum GoalStatusView {
65 Active,
67 Paused,
69 Completed,
71 Cleared,
73}
74
75impl GoalStatusView {
76 #[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#[derive(Debug, Clone, serde::Serialize)]
93pub struct GoalSnapshot {
94 pub id: String,
96 pub text: String,
98 pub status: GoalStatusView,
100 pub turns_used: u64,
102 pub tokens_used: u64,
104 pub token_budget: Option<u64>,
106}
107
108use std::future::Future;
109use std::pin::Pin;
110
111#[non_exhaustive]
116#[derive(Debug)]
117pub enum CommandOutput {
118 Message(String),
120 Silent,
122 Exit,
124 Continue,
126}
127
128impl CommandOutput {
129 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142#[non_exhaustive]
143pub enum SlashCategory {
144 Session,
146 Configuration,
148 Memory,
150 Skills,
152 Planning,
154 Debugging,
156 Integration,
158 Advanced,
160}
161
162impl SlashCategory {
163 #[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
179pub struct CommandInfo {
181 pub name: &'static str,
183 pub args: &'static str,
185 pub description: &'static str,
187 pub category: SlashCategory,
189 pub feature_gate: Option<&'static str>,
191}
192
193#[derive(Debug, thiserror::Error)]
198#[error("{0}")]
199pub struct CommandError(pub String);
200
201impl CommandError {
202 pub fn new(msg: impl std::fmt::Display) -> Self {
204 Self(msg.to_string())
205 }
206}
207
208pub trait CommandHandler<Ctx: ?Sized>: Send + Sync {
219 fn name(&self) -> &'static str;
223
224 fn description(&self) -> &'static str;
226
227 fn args_hint(&self) -> &'static str {
231 ""
232 }
233
234 fn category(&self) -> SlashCategory;
236
237 fn feature_gate(&self) -> Option<&'static str> {
239 None
240 }
241
242 fn requires_auth(&self) -> bool {
252 true
253 }
254
255 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
273pub struct CommandRegistry<Ctx: ?Sized> {
289 handlers: Vec<Box<dyn CommandHandler<Ctx>>>,
290}
291
292impl<Ctx: ?Sized> CommandRegistry<Ctx> {
293 #[must_use]
295 pub fn new() -> Self {
296 Self {
297 handlers: Vec::new(),
298 }
299 }
300
301 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 #[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 #[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 #[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 let result = reg.dispatch(&mut ctx, "/secret", true).await;
554 assert!(result.unwrap().is_ok());
555
556 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 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}