claude_agent/skills/
executor.rs1use std::sync::Arc;
4use std::time::Duration;
5
6use super::{SkillIndex, SkillResult};
7use crate::common::{IndexRegistry, Named};
8
9const DEFAULT_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
10
11pub type SkillExecutionCallback = Arc<
12 dyn Fn(
13 String,
14 )
15 -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, String>> + Send>>
16 + Send
17 + Sync,
18>;
19
20pub struct SkillExecutor {
25 registry: IndexRegistry<SkillIndex>,
26 execution_callback: Option<SkillExecutionCallback>,
27 callback_timeout: Duration,
28 mode: ExecutionMode,
29}
30
31#[derive(Clone, Copy, Debug, Default)]
32pub enum ExecutionMode {
33 #[default]
35 InlinePrompt,
36 Callback,
38 DryRun,
40}
41
42impl SkillExecutor {
43 pub fn new(registry: IndexRegistry<SkillIndex>) -> Self {
45 Self {
46 registry,
47 execution_callback: None,
48 callback_timeout: DEFAULT_CALLBACK_TIMEOUT,
49 mode: ExecutionMode::InlinePrompt,
50 }
51 }
52
53 pub fn with_defaults() -> Self {
55 Self::new(IndexRegistry::new())
56 }
57
58 pub fn with_callback(mut self, callback: SkillExecutionCallback) -> Self {
60 self.execution_callback = Some(callback);
61 self.mode = ExecutionMode::Callback;
62 self
63 }
64
65 pub fn with_callback_timeout(mut self, timeout: Duration) -> Self {
67 self.callback_timeout = timeout;
68 self
69 }
70
71 pub fn with_mode(mut self, mode: ExecutionMode) -> Self {
73 self.mode = mode;
74 self
75 }
76
77 pub fn registry(&self) -> &IndexRegistry<SkillIndex> {
79 &self.registry
80 }
81
82 pub fn registry_mut(&mut self) -> &mut IndexRegistry<SkillIndex> {
84 &mut self.registry
85 }
86
87 pub fn into_registry(self) -> IndexRegistry<SkillIndex> {
89 self.registry
90 }
91
92 pub async fn execute(&self, name: &str, args: Option<&str>) -> SkillResult {
96 let skill = match self.registry.get(name) {
97 Some(s) => s.clone(),
98 None => {
99 return SkillResult::error(format!("Skill '{}' not found", name));
100 }
101 };
102
103 self.execute_skill(&skill, args).await
104 }
105
106 pub async fn execute_by_trigger(&self, input: &str) -> Option<SkillResult> {
108 let skill = self.registry.iter().find(|s| s.matches_triggers(input))?;
110 let skill = skill.clone();
111
112 let args = self.extract_args(input, &skill);
113 Some(self.execute_skill(&skill, args.as_deref()).await)
114 }
115
116 async fn execute_skill(&self, skill: &SkillIndex, args: Option<&str>) -> SkillResult {
118 let content = match self.registry.load_content(skill.name()).await {
120 Ok(c) => c,
121 Err(e) => {
122 return SkillResult::error(format!("Failed to load skill '{}': {}", skill.name, e));
123 }
124 };
125
126 let prompt = skill.execute(args.unwrap_or(""), &content).await;
128
129 let base_result = match self.mode {
130 ExecutionMode::DryRun => SkillResult::success(format!(
131 "[DRY RUN] Skill '{}' prompt:\n\n{}",
132 skill.name, prompt
133 )),
134 ExecutionMode::Callback => {
135 if let Some(ref callback) = self.execution_callback {
136 match tokio::time::timeout(self.callback_timeout, callback(prompt)).await {
137 Ok(Ok(result)) => SkillResult::success(result),
138 Ok(Err(e)) => SkillResult::error(e),
139 Err(_) => SkillResult::error(format!(
140 "Skill callback timed out after {:?}",
141 self.callback_timeout
142 )),
143 }
144 } else {
145 SkillResult::error("No execution callback configured")
146 }
147 }
148 ExecutionMode::InlinePrompt => SkillResult::success(format!(
149 "Execute the following skill instructions:\n\n---\n{}\n---\n\nSkill: {}\nArguments: {}",
150 prompt,
151 skill.name,
152 args.unwrap_or("(none)")
153 )),
154 };
155
156 base_result
157 .with_allowed_tools(skill.allowed_tools.clone())
158 .with_model(skill.model.clone())
159 .with_base_dir(skill.base_dir())
160 }
161
162 fn extract_args(&self, input: &str, skill: &SkillIndex) -> Option<String> {
163 for trigger in &skill.triggers {
164 if let Some(pos) = input.to_lowercase().find(&trigger.to_lowercase()) {
165 let after_trigger = &input[pos + trigger.len()..].trim();
166 if !after_trigger.is_empty() {
167 return Some(after_trigger.to_string());
168 }
169 }
170 }
171 None
172 }
173
174 pub fn list_skills(&self) -> Vec<&str> {
176 self.registry.list()
177 }
178
179 pub fn has_skill(&self, name: &str) -> bool {
181 self.registry.contains(name)
182 }
183
184 pub fn get_skill(&self, name: &str) -> Option<&SkillIndex> {
186 self.registry.get(name)
187 }
188
189 pub fn get_by_trigger(&self, input: &str) -> Option<&SkillIndex> {
191 self.registry.iter().find(|s| s.matches_triggers(input))
192 }
193
194 pub fn build_summary(&self) -> String {
196 self.registry.build_summary()
197 }
198}
199
200impl Default for SkillExecutor {
201 fn default() -> Self {
202 Self::with_defaults()
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use crate::common::ContentSource;
209
210 use super::*;
211
212 fn test_skill(name: &str, content: &str) -> SkillIndex {
213 SkillIndex::new(name, format!("Test skill: {}", name))
214 .with_source(ContentSource::in_memory(content))
215 }
216
217 #[test]
218 fn test_substitute_args() {
219 let content = "Do something with $ARGUMENTS and ${ARGUMENTS}";
220 let result = SkillIndex::substitute_args(content, Some("test args"));
221 assert_eq!(result, "Do something with test args and test args");
222 }
223
224 #[test]
225 fn test_substitute_args_empty() {
226 let content = "Run with: $ARGUMENTS";
227 let result = SkillIndex::substitute_args(content, None);
228 assert_eq!(result, "Run with: ");
229 }
230
231 #[tokio::test]
232 async fn test_execute_not_found() {
233 let executor = SkillExecutor::with_defaults();
234 let result = executor.execute("nonexistent", None).await;
235
236 assert!(!result.success);
237 assert!(result.error.is_some());
238 }
239
240 #[tokio::test]
241 async fn test_execute_skill() {
242 let mut registry = IndexRegistry::new();
243 registry.register(test_skill("test-skill", "Execute: $ARGUMENTS"));
244
245 let executor = SkillExecutor::new(registry);
246 let result = executor.execute("test-skill", Some("my args")).await;
247
248 assert!(result.success);
249 assert!(result.output.contains("my args"));
250 }
251
252 #[tokio::test]
253 async fn test_execute_by_trigger() {
254 let mut registry = IndexRegistry::new();
255 registry.register(
256 SkillIndex::new("commit", "Create commit")
257 .with_source(ContentSource::in_memory("Create commit: $ARGUMENTS"))
258 .with_triggers(["/commit"]),
259 );
260
261 let executor = SkillExecutor::new(registry);
262 let result = executor.execute_by_trigger("/commit fix bug").await;
263
264 assert!(result.is_some());
265 let result = result.unwrap();
266 assert!(result.success);
267 assert!(result.output.contains("fix bug"));
268 }
269
270 #[tokio::test]
271 async fn test_dry_run_mode() {
272 let mut registry = IndexRegistry::new();
273 registry.register(test_skill("test", "Test content"));
274
275 let executor = SkillExecutor::new(registry).with_mode(ExecutionMode::DryRun);
276 let result = executor.execute("test", None).await;
277
278 assert!(result.success);
279 assert!(result.output.contains("[DRY RUN]"));
280 }
281
282 #[test]
283 fn test_list_skills() {
284 let mut registry = IndexRegistry::new();
285 registry.register(test_skill("a", "A"));
286 registry.register(test_skill("b", "B"));
287
288 let executor = SkillExecutor::new(registry);
289 let names = executor.list_skills();
290
291 assert_eq!(names.len(), 2);
292 assert!(names.contains(&"a"));
293 assert!(names.contains(&"b"));
294 }
295
296 #[test]
297 fn test_has_skill() {
298 let mut registry = IndexRegistry::new();
299 registry.register(test_skill("exists", "Content"));
300
301 let executor = SkillExecutor::new(registry);
302 assert!(executor.has_skill("exists"));
303 assert!(!executor.has_skill("missing"));
304 }
305
306 #[tokio::test]
307 async fn test_skill_with_allowed_tools() {
308 let mut registry = IndexRegistry::new();
309 registry.register(
310 SkillIndex::new("reader", "Read files")
311 .with_source(ContentSource::in_memory("Read: $ARGUMENTS"))
312 .with_allowed_tools(["Read", "Grep"]),
313 );
314
315 let executor = SkillExecutor::new(registry);
316 let result = executor.execute("reader", None).await;
317
318 assert!(result.success);
319 assert_eq!(result.allowed_tools, vec!["Read", "Grep"]);
320 }
321
322 #[tokio::test]
323 async fn test_skill_with_model() {
324 let mut registry = IndexRegistry::new();
325 registry.register(
326 SkillIndex::new("fast", "Fast task")
327 .with_source(ContentSource::in_memory("Do: $ARGUMENTS"))
328 .with_model("claude-haiku-4-5-20251001"),
329 );
330
331 let executor = SkillExecutor::new(registry);
332 let result = executor.execute("fast", None).await;
333
334 assert!(result.success);
335 assert_eq!(result.model, Some("claude-haiku-4-5-20251001".to_string()));
336 }
337
338 #[test]
339 fn test_build_summary() {
340 let mut registry = IndexRegistry::new();
341 registry.register(SkillIndex::new("commit", "Create commits"));
342 registry.register(SkillIndex::new("review", "Review code"));
343
344 let executor = SkillExecutor::new(registry);
345 let summary = executor.build_summary();
346
347 assert!(summary.contains("commit"));
348 assert!(summary.contains("review"));
349 }
350}