Skip to main content

zeph_commands/handlers/
plugins.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `/plugins` slash command handler.
5
6use std::future::Future;
7use std::pin::Pin;
8
9use crate::context::CommandContext;
10use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
11
12/// Manage installed plugins (list, install, remove, update).
13pub struct PluginsCommand;
14
15impl CommandHandler<CommandContext<'_>> for PluginsCommand {
16    fn name(&self) -> &'static str {
17        "/plugins"
18    }
19
20    fn description(&self) -> &'static str {
21        "Manage installed plugins (list, install, remove, update)"
22    }
23
24    fn args_hint(&self) -> &'static str {
25        "[list | install <name> | remove <name> | update [name]]"
26    }
27
28    fn category(&self) -> SlashCategory {
29        SlashCategory::Integration
30    }
31
32    fn requires_auth(&self) -> bool {
33        true
34    }
35
36    fn handle<'a>(
37        &'a self,
38        ctx: &'a mut CommandContext<'_>,
39        args: &'a str,
40    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
41        use tracing::Instrument as _;
42        let span = tracing::info_span!("commands.plugins.handle");
43        Box::pin(
44            async move {
45                let msg = ctx.agent.handle_plugins(args).await?;
46                Ok(CommandOutput::message_or_silent(msg))
47            }
48            .instrument(span),
49        )
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use crate::CommandRegistry;
57    use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
58    use crate::sink::NullSink;
59
60    #[test]
61    fn name_matches_slash_plugins() {
62        assert_eq!(PluginsCommand.name(), "/plugins");
63    }
64
65    #[test]
66    fn category_is_integration() {
67        assert_eq!(PluginsCommand.category(), SlashCategory::Integration);
68    }
69
70    #[test]
71    fn description_is_non_empty() {
72        assert!(!PluginsCommand.description().is_empty());
73    }
74
75    #[test]
76    fn args_hint_is_non_empty() {
77        assert!(!PluginsCommand.args_hint().is_empty());
78    }
79
80    #[tokio::test]
81    async fn plugins_dispatch_allowed_when_trusted() {
82        let mut sink = NullSink;
83        let mut debug = MockDebug;
84        let mut messages = MockMessages;
85        let session = MockSession;
86        let mut agent = crate::NullAgent;
87        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
88
89        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
90        reg.register(PluginsCommand);
91
92        let result = reg.dispatch(&mut ctx, "/plugins list", true).await;
93        assert!(result.unwrap().is_ok());
94    }
95
96    #[tokio::test]
97    async fn plugins_dispatch_rejected_when_untrusted() {
98        let mut sink = NullSink;
99        let mut debug = MockDebug;
100        let mut messages = MockMessages;
101        let session = MockSession;
102        let mut agent = crate::NullAgent;
103        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
104
105        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
106        reg.register(PluginsCommand);
107
108        let result = reg
109            .dispatch(&mut ctx, "/plugins add /etc/passwd", false)
110            .await;
111        let err = result.unwrap().unwrap_err();
112        assert!(err.0.contains("trusted"));
113    }
114}