Skip to main content

ai_agent/tools/
skill.rs

1// Source: ~/claudecode/openclaudecode/src/tools/SkillTool/SkillTool.ts
2//! Skill tool - invoke external skills.
3//!
4//! Provides a tool for the agent to invoke external skills with inline or forked execution.
5
6use 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
20/// Global skill registry
21static LOADED_SKILLS: OnceLock<Mutex<HashMap<String, LoadedSkill>>> = OnceLock::new();
22
23/// Remote skill cache (for ant-only remote skill search)
24static 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
44/// Regex for triple-brace argument placeholders: `{{{name}}}`
45fn 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
52/// Parse argument names from skill content.
53///
54/// Finds all `{{{name}}}` patterns and returns a deduplicated list
55/// in first-occurrence order.
56pub 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
69/// Substitute `{{{arg_name}}}` placeholders in content with values from the args map.
70///
71/// Placeholders whose key is not present in the map are left unchanged.
72pub 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: &regex::Captures| {
78            let key = cap[1].to_string();
79            if let Some(val) = args.get(&key) {
80                val.clone()
81            } else {
82                // Leave unchanged if not in map
83                cap[0].to_string()
84            }
85        })
86        .to_string()
87}
88
89/// Register skills from a directory
90pub 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
103/// Register a single skill
104pub 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
110/// Register multiple skills at once
111pub 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
119/// Get a skill by name (standalone function for external use)
120pub fn get_skill(name: &str) -> Option<LoadedSkill> {
121    let guard = get_skills_map().lock().ok()?;
122    guard.get(name).cloned()
123}
124
125/// Get all skill names including remote
126pub 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
139/// Skill tool - invoke a skill by name.
140/// Supports inline and forked execution modes, MCP skill integration,
141/// permission rules with prefix matching, and remote skill search (ant-only).
142pub 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        // Parse args into a HashMap for argument substitution
205        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        // Handle prefix matching for skill groups (e.g., "review:*")
221        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        // Try local skills first
264        if let Some(skill) = self.get_skill(skill_name) {
265            let substituted_content = substitute_arguments(&skill.content, &args_map);
266
267            // Substitute ${CLAUDE_SKILL_DIR} and ${CLAUDE_SESSION_ID} environment
268            // variables in the skill content (matches loadSkillsDir.ts lines 362-368).
269            let substituted_content = substitute_env_vars_in_skill(
270                &substituted_content,
271                &skill.base_dir,
272            );
273
274            // Execute any embedded shell commands in the skill prompt
275            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            // In fork mode, we would spawn a sub-agent with this skill
295            // In inline mode, we return the content for the model to use
296            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        // Try remote skills (ant-only feature in TS)
323        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        // Skill not found - list available skills
342        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    /// Get a skill by name
378    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    /// Get skill group names (prefixes before ':')
384    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
408/// Reset the global skill registries for test isolation.
409pub fn reset_skills_for_testing() {
410    {
411        let guard = get_skills_map().lock().unwrap();
412        drop(guard);
413    }
414    // LOADED_SKILLS is initialized once and can't be reset without unsafe
415    // Just clear any accumulated skills by reinitializing the inner map
416    if let Ok(mut skills) = get_skills_map().lock() {
417        // Re-init from directory
418        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        // Should either find matching skills or report none found
477        let content = result.unwrap().content;
478        // The content should mention either skills matching or not found
479        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    // --- Argument substitution tests ---
491
492    #[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        // language is missing
548        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        // Value contains regex special characters
565        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        // Register a skill with argument placeholders
602        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        // The original placeholders should be gone
641        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        // Unsubstituted placeholder should remain
686        assert!(content.contains("{{{mode}}}"), "Unsubstituted placeholder should remain: {}", content);
687    }
688}