zeph_commands/handlers/
skill.rs1use std::future::Future;
11use std::pin::Pin;
12
13use crate::context::CommandContext;
14use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
15
16pub struct SkillCommand;
21
22impl CommandHandler<CommandContext<'_>> for SkillCommand {
23 fn name(&self) -> &'static str {
24 "/skill"
25 }
26
27 fn description(&self) -> &'static str {
28 "Load and display a skill body, or manage skill lifecycle"
29 }
30
31 fn args_hint(&self) -> &'static str {
32 "<name|subcommand>"
33 }
34
35 fn category(&self) -> SlashCategory {
36 SlashCategory::Skills
37 }
38
39 fn requires_auth(&self) -> bool {
40 true
41 }
42
43 fn handle<'a>(
44 &'a self,
45 ctx: &'a mut CommandContext<'_>,
46 args: &'a str,
47 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
48 use tracing::Instrument as _;
49 let span = tracing::info_span!("commands.skill.handle");
50 Box::pin(
51 async move {
52 let result = ctx.agent.handle_skill(args).await?;
53 Ok(CommandOutput::Message(result))
54 }
55 .instrument(span),
56 )
57 }
58}
59
60pub struct SkillsCommand;
64
65impl CommandHandler<CommandContext<'_>> for SkillsCommand {
66 fn name(&self) -> &'static str {
67 "/skills"
68 }
69
70 fn description(&self) -> &'static str {
71 "List loaded skills (grouped by category when available)"
72 }
73
74 fn category(&self) -> SlashCategory {
75 SlashCategory::Skills
76 }
77
78 fn requires_auth(&self) -> bool {
79 false
80 }
81
82 fn handle<'a>(
83 &'a self,
84 ctx: &'a mut CommandContext<'_>,
85 args: &'a str,
86 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
87 use tracing::Instrument as _;
88 let span = tracing::info_span!("commands.skills.handle");
89 Box::pin(
90 async move {
91 let result = ctx.agent.handle_skills(args).await?;
92 Ok(CommandOutput::Message(result))
93 }
94 .instrument(span),
95 )
96 }
97}
98
99pub struct FeedbackCommand;
101
102impl CommandHandler<CommandContext<'_>> for FeedbackCommand {
103 fn name(&self) -> &'static str {
104 "/feedback"
105 }
106
107 fn description(&self) -> &'static str {
108 "Submit feedback for a skill"
109 }
110
111 fn args_hint(&self) -> &'static str {
112 "<skill> <message>"
113 }
114
115 fn category(&self) -> SlashCategory {
116 SlashCategory::Skills
117 }
118
119 fn requires_auth(&self) -> bool {
120 true
121 }
122
123 fn handle<'a>(
124 &'a self,
125 ctx: &'a mut CommandContext<'_>,
126 args: &'a str,
127 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
128 use tracing::Instrument as _;
129 let span = tracing::info_span!("commands.feedback.handle");
130 Box::pin(
131 async move {
132 let result = ctx.agent.handle_feedback_command(args).await?;
133 Ok(CommandOutput::Message(result))
134 }
135 .instrument(span),
136 )
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::CommandRegistry;
144 use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
145 use crate::sink::NullSink;
146 use std::assert_matches;
147
148 #[test]
149 fn skill_name_and_description() {
150 assert_eq!(SkillCommand.name(), "/skill");
151 assert!(!SkillCommand.description().is_empty());
152 }
153
154 #[test]
155 fn skills_name_and_description() {
156 assert_eq!(SkillsCommand.name(), "/skills");
157 assert!(!SkillsCommand.description().is_empty());
158 }
159
160 #[test]
161 fn skills_requires_auth_false() {
162 assert!(!SkillsCommand.requires_auth());
163 }
164
165 #[test]
166 fn feedback_name_and_description() {
167 assert_eq!(FeedbackCommand.name(), "/feedback");
168 assert!(!FeedbackCommand.description().is_empty());
169 }
170
171 #[tokio::test]
172 async fn skill_returns_message() {
173 let mut sink = NullSink;
174 let mut debug = MockDebug;
175 let mut messages = MockMessages;
176 let session = MockSession;
177 let mut agent = crate::NullAgent;
178 let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
179 let out = SkillCommand.handle(&mut ctx, "stats").await.unwrap();
180 assert_matches!(out, CommandOutput::Message(_));
181 }
182
183 #[tokio::test]
184 async fn skills_returns_message() {
185 let mut sink = NullSink;
186 let mut debug = MockDebug;
187 let mut messages = MockMessages;
188 let session = MockSession;
189 let mut agent = crate::NullAgent;
190 let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
191 let out = SkillsCommand.handle(&mut ctx, "").await.unwrap();
192 assert_matches!(out, CommandOutput::Message(_));
193 }
194
195 #[tokio::test]
196 async fn feedback_returns_message() {
197 let mut sink = NullSink;
198 let mut debug = MockDebug;
199 let mut messages = MockMessages;
200 let session = MockSession;
201 let mut agent = crate::NullAgent;
202 let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
203 let out = FeedbackCommand
204 .handle(&mut ctx, "my-skill good job")
205 .await
206 .unwrap();
207 assert_matches!(out, CommandOutput::Message(_));
208 }
209
210 #[tokio::test]
211 async fn skill_dispatch_allowed_when_trusted() {
212 let mut sink = NullSink;
213 let mut debug = MockDebug;
214 let mut messages = MockMessages;
215 let session = MockSession;
216 let mut agent = crate::NullAgent;
217 let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
218
219 let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
220 reg.register(SkillCommand);
221
222 let result = reg.dispatch(&mut ctx, "/skill stats", true).await;
223 assert!(result.unwrap().is_ok());
224 }
225
226 #[tokio::test]
227 async fn skill_dispatch_rejected_when_untrusted() {
228 let mut sink = NullSink;
229 let mut debug = MockDebug;
230 let mut messages = MockMessages;
231 let session = MockSession;
232 let mut agent = crate::NullAgent;
233 let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
234
235 let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
236 reg.register(SkillCommand);
237
238 let result = reg.dispatch(&mut ctx, "/skill stats", false).await;
239 let err = result.unwrap().unwrap_err();
240 assert!(err.0.contains("trusted"));
241 }
242
243 #[tokio::test]
244 async fn feedback_dispatch_allowed_when_trusted() {
245 let mut sink = NullSink;
246 let mut debug = MockDebug;
247 let mut messages = MockMessages;
248 let session = MockSession;
249 let mut agent = crate::NullAgent;
250 let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
251
252 let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
253 reg.register(FeedbackCommand);
254
255 let result = reg
256 .dispatch(&mut ctx, "/feedback my-skill good job", true)
257 .await;
258 assert!(result.unwrap().is_ok());
259 }
260
261 #[tokio::test]
262 async fn feedback_dispatch_rejected_when_untrusted() {
263 let mut sink = NullSink;
264 let mut debug = MockDebug;
265 let mut messages = MockMessages;
266 let session = MockSession;
267 let mut agent = crate::NullAgent;
268 let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
269
270 let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
271 reg.register(FeedbackCommand);
272
273 let result = reg
274 .dispatch(&mut ctx, "/feedback my-skill good job", false)
275 .await;
276 let err = result.unwrap().unwrap_err();
277 assert!(err.0.contains("trusted"));
278 }
279}