1use crate::error::AgentError;
7use crate::skills::loader::{LoadedSkill, load_skills_from_dir, substitute_env_vars_in_skill};
8use crate::types::*;
9use crate::utils::cwd::get_cwd;
10use crate::utils::prompt_shell_execution::{
11 FrontmatterShell, execute_shell_commands_in_prompt,
12};
13use regex::Regex;
14use std::collections::HashMap;
15use std::path::Path;
16use std::sync::{Mutex, OnceLock};
17
18pub const SKILL_TOOL_NAME: &str = "Skill";
19
20static LOADED_SKILLS: OnceLock<Mutex<HashMap<String, LoadedSkill>>> = OnceLock::new();
22
23static REMOTE_SKILLS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
25
26fn init_skills_map() -> Mutex<HashMap<String, LoadedSkill>> {
27 let mut skills = HashMap::new();
28 if let Ok(loaded) = load_skills_from_dir(Path::new("examples/skills"), &get_cwd()) {
29 for skill in loaded {
30 skills.insert(skill.metadata.name.clone(), skill);
31 }
32 }
33 Mutex::new(skills)
34}
35
36fn get_skills_map() -> &'static Mutex<HashMap<String, LoadedSkill>> {
37 LOADED_SKILLS.get_or_init(init_skills_map)
38}
39
40fn get_remote_skills() -> &'static Mutex<HashMap<String, String>> {
41 REMOTE_SKILLS.get_or_init(|| Mutex::new(HashMap::new()))
42}
43
44fn argument_pattern() -> &'static Regex {
46 lazy_static::lazy_static! {
47 static ref ARG_PATTERN: Regex = Regex::new(r"\{\{\{(\w+)\}\}\}").unwrap();
48 }
49 &ARG_PATTERN
50}
51
52pub fn parse_argument_names(skill_content: &str) -> Vec<String> {
57 let mut seen = HashMap::new();
58 let mut names = Vec::new();
59 for cap in argument_pattern().captures_iter(skill_content) {
60 if let Some(name) = cap.get(1).map(|m| m.as_str().to_string()) {
61 if seen.insert(name.clone(), ()).is_none() {
62 names.push(name);
63 }
64 }
65 }
66 names
67}
68
69pub fn substitute_arguments(content: &str, args: &HashMap<String, String>) -> String {
73 if args.is_empty() {
74 return content.to_string();
75 }
76 argument_pattern()
77 .replace_all(content, |cap: ®ex::Captures| {
78 let key = cap[1].to_string();
79 if let Some(val) = args.get(&key) {
80 val.clone()
81 } else {
82 cap[0].to_string()
84 }
85 })
86 .to_string()
87}
88
89pub fn register_skills_from_dir(dir: &Path) {
91 if dir.as_os_str().is_empty() {
92 return;
93 }
94 if let Ok(loaded) = load_skills_from_dir(dir, &get_cwd()) {
95 if let Ok(mut skills) = get_skills_map().lock() {
96 for skill in loaded {
97 skills.insert(skill.metadata.name.clone(), skill);
98 }
99 }
100 }
101}
102
103pub fn register_skill(skill: LoadedSkill) {
105 if let Ok(mut skills) = get_skills_map().lock() {
106 skills.insert(skill.metadata.name.clone(), skill);
107 }
108}
109
110pub fn register_skills(skills_list: Vec<LoadedSkill>) {
112 if let Ok(mut skills) = get_skills_map().lock() {
113 for skill in skills_list {
114 skills.insert(skill.metadata.name.clone(), skill);
115 }
116 }
117}
118
119pub fn get_skill(name: &str) -> Option<LoadedSkill> {
121 let guard = get_skills_map().lock().ok()?;
122 guard.get(name).cloned()
123}
124
125pub fn get_all_skill_names() -> Vec<String> {
127 let mut names = Vec::new();
128 if let Ok(guard) = get_skills_map().lock() {
129 names.extend(guard.keys().cloned());
130 }
131 if let Ok(guard) = get_remote_skills().lock() {
132 names.extend(guard.keys().cloned());
133 }
134 names.sort();
135 names.dedup();
136 names
137}
138
139pub struct SkillTool;
143
144impl SkillTool {
145 pub fn new() -> Self {
146 Self
147 }
148
149 pub fn name(&self) -> &str {
150 SKILL_TOOL_NAME
151 }
152
153 pub fn description(&self) -> &str {
154 "Invoke a skill by name. Skills are pre-built workflows or commands that can be \
155 executed to accomplish specific tasks. Use this tool to discover and run available skills."
156 }
157
158 pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
159 "Skill".to_string()
160 }
161
162 pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
163 input.and_then(|inp| inp["skill"].as_str().map(String::from))
164 }
165
166 pub fn render_tool_result_message(
167 &self,
168 content: &serde_json::Value,
169 ) -> Option<String> {
170 let text = content["content"].as_str()?;
171 Some(text.lines().next()?.to_string())
172 }
173
174 pub fn input_schema(&self) -> ToolInputSchema {
175 ToolInputSchema {
176 schema_type: "object".to_string(),
177 properties: serde_json::json!({
178 "skill": {
179 "type": "string",
180 "description": "The name of the skill to invoke. Can also use prefix matching like 'review:*' to match skill groups."
181 },
182 "args": {
183 "type": "object",
184 "description": "Arguments to pass to the skill"
185 },
186 "mode": {
187 "type": "string",
188 "enum": ["inline", "fork"],
189 "description": "Execution mode: 'inline' (default) runs in the current context, 'fork' runs as a sub-agent."
190 }
191 }),
192 required: Some(vec!["skill".to_string()]),
193 }
194 }
195
196 pub async fn execute(
197 &self,
198 input: serde_json::Value,
199 context: &ToolContext,
200 ) -> Result<ToolResult, AgentError> {
201 let skill_name = input["skill"].as_str().unwrap_or("");
202 let mode = input["mode"].as_str().unwrap_or("inline");
203
204 let args_map: HashMap<String, String> = if let Some(args_obj) = input.get("args") {
206 if let Some(obj) = args_obj.as_object() {
207 obj.iter()
208 .filter_map(|(k, v)| {
209 let val = v.as_str().unwrap_or("").to_string();
210 Some((k.clone(), val))
211 })
212 .collect()
213 } else {
214 HashMap::new()
215 }
216 } else {
217 HashMap::new()
218 };
219
220 if skill_name.ends_with(":*") {
222 let prefix = &skill_name[..skill_name.len() - 2];
223 let guard = get_skills_map().lock().unwrap();
224 let matching: Vec<String> = guard
225 .keys()
226 .filter(|name| name.starts_with(prefix))
227 .cloned()
228 .collect();
229 drop(guard);
230
231 if matching.is_empty() {
232 return Ok(ToolResult {
233 result_type: "text".to_string(),
234 tool_use_id: "".to_string(),
235 content: format!(
236 "No skills found matching prefix '{}'.\n\
237 Available skills groups: {}",
238 prefix,
239 self.get_skill_groups()
240 ),
241 is_error: Some(true),
242 was_persisted: None,
243 });
244 }
245
246 return Ok(ToolResult {
247 result_type: "text".to_string(),
248 tool_use_id: "".to_string(),
249 content: format!(
250 "Skills matching '{}':\n{}",
251 prefix,
252 matching
253 .iter()
254 .map(|s| format!(" - {}", s))
255 .collect::<Vec<_>>()
256 .join("\n")
257 ),
258 is_error: Some(false),
259 was_persisted: None,
260 });
261 }
262
263 if let Some(skill) = self.get_skill(skill_name) {
265 let substituted_content = substitute_arguments(&skill.content, &args_map);
266
267 let substituted_content = substitute_env_vars_in_skill(
270 &substituted_content,
271 &skill.base_dir,
272 );
273
274 let shell = skill
276 .metadata
277 .shell
278 .as_deref()
279 .map(FrontmatterShell::from_str)
280 .unwrap_or_default();
281 let processed_content =
282 execute_shell_commands_in_prompt(&substituted_content, &shell, skill_name, None::<&(dyn Fn(&str, &str) -> bool + Send + Sync)>)
283 .await;
284
285 let content = format!(
286 "Skill '{}' loaded successfully.\n\
287 Description: {}\n\
288 Mode: {}\n\
289 \n{}\n\n\
290 You can now use tools to complete the task.",
291 skill_name, &skill.metadata.description, mode, substituted_content
292 );
293
294 if mode == "fork" {
297 return Ok(ToolResult {
298 result_type: "text".to_string(),
299 tool_use_id: "".to_string(),
300 content: format!(
301 "Skill '{}' would be executed as a forked sub-agent.\n\
302 In a full implementation, this would spawn a new agent process\n\
303 with the skill content as its system prompt.\n\
304 Skill content length: {} chars",
305 skill_name,
306 content.len()
307 ),
308 is_error: Some(false),
309 was_persisted: None,
310 });
311 }
312
313 return Ok(ToolResult {
314 result_type: "text".to_string(),
315 tool_use_id: "skill".to_string(),
316 content,
317 is_error: Some(false),
318 was_persisted: None,
319 });
320 }
321
322 let remote_guard = get_remote_skills().lock().unwrap();
324 if let Some(remote_content) = remote_guard.get(skill_name) {
325 let substituted_remote = substitute_arguments(remote_content, &args_map);
326 return Ok(ToolResult {
327 result_type: "text".to_string(),
328 tool_use_id: "".to_string(),
329 content: format!(
330 "Remote skill '{}' loaded successfully.\n\
331 \n{}\n\n\
332 You can now use tools to complete the task.",
333 skill_name, substituted_remote
334 ),
335 is_error: Some(false),
336 was_persisted: None,
337 });
338 }
339 drop(remote_guard);
340
341 let available = get_all_skill_names();
343 let mut content = format!(
344 "Skill '{}' not found.\n\n\
345 Available skills:\n",
346 skill_name
347 );
348
349 if available.is_empty() {
350 content.push_str(" (no skills available)");
351 } else {
352 for name in &available {
353 let guard = get_skills_map().lock().unwrap();
354 if let Some(skill) = guard.get(name) {
355 content.push_str(&format!(" - {}: {}\n", name, &skill.metadata.description));
356 } else {
357 content.push_str(&format!(" - {}\n", name));
358 }
359 }
360 }
361
362 content.push_str(&format!(
363 "\n\nTo invoke a skill, use the Skill tool with the skill name.\n\
364 Current working directory: {}",
365 context.cwd
366 ));
367
368 Ok(ToolResult {
369 result_type: "text".to_string(),
370 tool_use_id: "skill".to_string(),
371 content,
372 is_error: Some(true),
373 was_persisted: None,
374 })
375 }
376
377 pub fn get_skill(&self, name: &str) -> Option<LoadedSkill> {
379 let guard = get_skills_map().lock().ok()?;
380 guard.get(name).cloned()
381 }
382
383 fn get_skill_groups(&self) -> String {
385 let guard = get_skills_map().lock().ok().unwrap();
386 let mut groups: Vec<String> = guard
387 .keys()
388 .filter_map(|name| {
389 if name.contains(':') {
390 Some(name.split(':').next().unwrap_or(name).to_string())
391 } else {
392 None
393 }
394 })
395 .collect();
396 groups.sort();
397 groups.dedup();
398 groups.join(", ")
399 }
400}
401
402impl Default for SkillTool {
403 fn default() -> Self {
404 Self::new()
405 }
406}
407
408pub fn reset_skills_for_testing() {
410 {
411 let guard = get_skills_map().lock().unwrap();
412 drop(guard);
413 }
414 if let Ok(mut skills) = get_skills_map().lock() {
417 skills.clear();
419 if let Ok(loaded) = crate::skills::loader::load_skills_from_dir(std::path::Path::new("examples/skills"), &std::env::current_dir().unwrap_or_default()) {
420 for skill in loaded {
421 skills.insert(skill.metadata.name.clone(), skill);
422 }
423 }
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 use crate::skills::loader::SkillMetadata;
432 use crate::tests::common::clear_all_test_state;
433
434 #[test]
435 fn test_skill_tool_name() {
436 clear_all_test_state();
437 let tool = SkillTool::new();
438 assert_eq!(tool.name(), SKILL_TOOL_NAME);
439 }
440
441 #[test]
442 fn test_skill_tool_schema() {
443 clear_all_test_state();
444 let tool = SkillTool::new();
445 let schema = tool.input_schema();
446 assert!(schema.properties.get("skill").is_some());
447 assert!(schema.properties.get("args").is_some());
448 assert!(schema.properties.get("mode").is_some());
449 }
450
451 #[tokio::test]
452 async fn test_skill_tool_unknown_skill() {
453 clear_all_test_state();
454 let tool = SkillTool::new();
455 let input = serde_json::json!({
456 "skill": "nonexistent_skill"
457 });
458 let context = ToolContext::default();
459 let result = tool.execute(input, &context).await;
460 assert!(result.is_ok());
461 let content = result.unwrap().content;
462 assert!(content.contains("not found"));
463 assert!(content.contains("Available skills"));
464 }
465
466 #[tokio::test]
467 async fn test_skill_tool_prefix_matching() {
468 clear_all_test_state();
469 let tool = SkillTool::new();
470 let input = serde_json::json!({
471 "skill": "review:*"
472 });
473 let context = ToolContext::default();
474 let result = tool.execute(input, &context).await;
475 assert!(result.is_ok());
476 let content = result.unwrap().content;
478 let lower = content.to_lowercase();
480 assert!(
481 lower.contains("skill")
482 || lower.contains("matching")
483 || lower.contains("found")
484 || lower.contains("available"),
485 "Content: {}",
486 content
487 );
488 }
489
490 #[test]
493 fn test_parse_argument_names_single() {
494 let names = parse_argument_names("Review the file {{{filename}}} for issues");
495 assert_eq!(names, vec!["filename"]);
496 }
497
498 #[test]
499 fn test_parse_argument_names_multiple() {
500 let content = "Review {{{file}}} using {{{language}}}. Output to {{{report}}}.";
501 let names = parse_argument_names(content);
502 assert_eq!(names, vec!["file", "language", "report"]);
503 }
504
505 #[test]
506 fn test_parse_argument_names_deduplicates() {
507 let content = "{{{file}}} is the input. Process {{{file}}} and return result.";
508 let names = parse_argument_names(content);
509 assert_eq!(names, vec!["file"]);
510 }
511
512 #[test]
513 fn test_parse_argument_names_preserves_order() {
514 let content = "Do X with {{{first}}}, then Y with {{{second}}}, then Z with {{{first}}} again.";
515 let names = parse_argument_names(content);
516 assert_eq!(names, vec!["first", "second"]);
517 }
518
519 #[test]
520 fn test_parse_argument_names_empty() {
521 let names = parse_argument_names("No placeholders here");
522 assert!(names.is_empty());
523 }
524
525 #[test]
526 fn test_parse_argument_names_with_underscores_and_digits() {
527 let content = "Use {{{my_file_2}}} and {{{report_v3}}}.";
528 let names = parse_argument_names(content);
529 assert_eq!(names, vec!["my_file_2", "report_v3"]);
530 }
531
532 #[test]
533 fn test_substitute_arguments_complete_map() {
534 let content = "Review {{{file}}} in {{{language}}}.";
535 let mut args = HashMap::new();
536 args.insert("file".to_string(), "main.rs".to_string());
537 args.insert("language".to_string(), "Rust".to_string());
538 let result = substitute_arguments(content, &args);
539 assert_eq!(result, "Review main.rs in Rust.");
540 }
541
542 #[test]
543 fn test_substitute_arguments_partial_map() {
544 let content = "Review {{{file}}} in {{{language}}}.";
545 let mut args = HashMap::new();
546 args.insert("file".to_string(), "main.rs".to_string());
547 let result = substitute_arguments(content, &args);
549 assert_eq!(result, "Review main.rs in {{{language}}}.");
550 }
551
552 #[test]
553 fn test_substitute_arguments_empty_map() {
554 let content = "Review {{{file}}} in {{{language}}}.";
555 let args = HashMap::new();
556 let result = substitute_arguments(content, &args);
557 assert_eq!(result, "Review {{{file}}} in {{{language}}}.");
558 }
559
560 #[test]
561 fn test_substitute_arguments_value_with_special_regex_chars() {
562 let content = "Process {{{file}}}.";
563 let mut args = HashMap::new();
564 args.insert("file".to_string(), "src/main.rs (v1.0).txt".to_string());
566 let result = substitute_arguments(content, &args);
567 assert_eq!(result, "Process src/main.rs (v1.0).txt.");
568 }
569
570 #[test]
571 fn test_substitute_arguments_value_with_braces() {
572 let content = "Config: {{{template}}}.";
573 let mut args = HashMap::new();
574 args.insert("template".to_string(), "{{json: true}}".to_string());
575 let result = substitute_arguments(content, &args);
576 assert_eq!(result, "Config: {{json: true}}.");
577 }
578
579 #[test]
580 fn test_substitute_arguments_no_placeholders() {
581 let content = "This is plain text with no arguments.";
582 let mut args = HashMap::new();
583 args.insert("foo".to_string(), "bar".to_string());
584 let result = substitute_arguments(content, &args);
585 assert_eq!(result, "This is plain text with no arguments.");
586 }
587
588 #[test]
589 fn test_substitute_arguments_repeated_placeholder() {
590 let content = "File: {{{name}}}. Again: {{{name}}}.";
591 let mut args = HashMap::new();
592 args.insert("name".to_string(), "test.txt".to_string());
593 let result = substitute_arguments(content, &args);
594 assert_eq!(result, "File: test.txt. Again: test.txt.");
595 }
596
597 #[tokio::test]
598 async fn test_skill_tool_execute_with_args() {
599 clear_all_test_state();
600
601 let skill = LoadedSkill {
603 metadata: SkillMetadata {
604 name: "test_arg_skill".to_string(),
605 description: "A skill with args".to_string(),
606 display_name: None,
607 version: None,
608 allowed_tools: None,
609 argument_hint: None,
610 arg_names: None,
611 when_to_use: None,
612 user_invocable: None,
613 paths: None,
614 hooks: None,
615 effort: None,
616 model: None,
617 context: None,
618 agent: None,
619 shell: None,
620 },
621 content: "Process the file {{{filename}}} using {{{method}}}.".to_string(),
622 base_dir: "".to_string(),
623 };
624 register_skill(skill);
625
626 let tool = SkillTool::new();
627 let input = serde_json::json!({
628 "skill": "test_arg_skill",
629 "args": {
630 "filename": "main.rs",
631 "method": "static analysis"
632 }
633 });
634 let context = ToolContext::default();
635 let result = tool.execute(input, &context).await;
636 assert!(result.is_ok());
637 let content = result.unwrap().content;
638 assert!(content.contains("main.rs"), "Content should contain substituted filename: {}", content);
639 assert!(content.contains("static analysis"), "Content should contain substituted method: {}", content);
640 assert!(!content.contains("{{{filename}}}"), "Placeholder should be substituted: {}", content);
642 assert!(!content.contains("{{{method}}}"), "Placeholder should be substituted: {}", content);
643 }
644
645 #[tokio::test]
646 async fn test_skill_tool_execute_with_partial_args() {
647 clear_all_test_state();
648
649 let skill = LoadedSkill {
650 metadata: SkillMetadata {
651 name: "test_partial_args".to_string(),
652 description: "Partial args test".to_string(),
653 display_name: None,
654 version: None,
655 allowed_tools: None,
656 argument_hint: None,
657 arg_names: None,
658 when_to_use: None,
659 user_invocable: None,
660 paths: None,
661 hooks: None,
662 effort: None,
663 model: None,
664 context: None,
665 agent: None,
666 shell: None,
667 },
668 content: "Target: {{{target}}}, Mode: {{{mode}}}.".to_string(),
669 base_dir: "".to_string(),
670 };
671 register_skill(skill);
672
673 let tool = SkillTool::new();
674 let input = serde_json::json!({
675 "skill": "test_partial_args",
676 "args": {
677 "target": "production"
678 }
679 });
680 let context = ToolContext::default();
681 let result = tool.execute(input, &context).await;
682 assert!(result.is_ok());
683 let content = result.unwrap().content;
684 assert!(content.contains("production"), "Substituted value should appear: {}", content);
685 assert!(content.contains("{{{mode}}}"), "Unsubstituted placeholder should remain: {}", content);
687 }
688}