Skip to main content

crabtalk_runtime/skill/
tool.rs

1//! Tool dispatch and schema registration for the skill tool.
2
3use crate::{RuntimeHook, bridge::RuntimeBridge, skill::loader};
4use serde::Deserialize;
5use wcore::{
6    agent::{AsTool, ToolDescription},
7    model::Tool,
8};
9
10#[derive(Deserialize, schemars::JsonSchema)]
11pub struct Skill {
12    /// Skill name to load. If no exact match, returns fuzzy matches.
13    /// Leave empty to list all available skills.
14    pub name: String,
15}
16
17impl ToolDescription for Skill {
18    const DESCRIPTION: &'static str = "Load a skill by name. Returns its instructions on exact match, or lists matching skills otherwise.";
19}
20
21pub fn tools() -> Vec<Tool> {
22    vec![Skill::as_tool()]
23}
24
25impl<B: RuntimeBridge> RuntimeHook<B> {
26    pub async fn dispatch_skill(&self, args: &str, agent: &str) -> String {
27        let input: Skill = match serde_json::from_str(args) {
28            Ok(v) => v,
29            Err(e) => return format!("invalid arguments: {e}"),
30        };
31        let name = &input.name;
32
33        // Enforce skill scope.
34        if let Some(scope) = self.scopes.get(agent)
35            && !scope.skills.is_empty()
36            && !scope.skills.iter().any(|s| s == name)
37        {
38            return format!("skill not available: {name}");
39        }
40
41        // Guard against path traversal.
42        if name.contains("..") || name.contains('/') || name.contains('\\') {
43            return format!("invalid skill name: {name}");
44        }
45
46        // Try exact load from each skill directory.
47        if !name.is_empty() {
48            for dir in &self.skills.skill_dirs {
49                let skill_dir = dir.join(name);
50                let skill_file = skill_dir.join("SKILL.md");
51                if let Ok(content) = tokio::fs::read_to_string(&skill_file).await {
52                    return match loader::parse_skill_md(&content) {
53                        Ok(skill) => {
54                            let body = skill.body.clone();
55                            self.skills.registry.lock().await.upsert(skill);
56                            let dir_path = skill_dir.display();
57                            format!("{body}\n\nSkill directory: {dir_path}")
58                        }
59                        Err(e) => format!("failed to parse skill: {e}"),
60                    };
61                }
62            }
63        }
64
65        // No exact match — fuzzy search / list all.
66        let query = name.to_lowercase();
67        let allowed = self.scopes.get(agent).map(|s| &s.skills);
68        let registry = self.skills.registry.lock().await;
69        let matches: Vec<String> = registry
70            .skills()
71            .into_iter()
72            .filter(|s| {
73                if let Some(allowed) = allowed
74                    && !allowed.is_empty()
75                    && !allowed.iter().any(|a| a == s.name.as_str())
76                {
77                    return false;
78                }
79                query.is_empty()
80                    || s.name.to_lowercase().contains(&query)
81                    || s.description.to_lowercase().contains(&query)
82            })
83            .map(|s| format!("{}: {}", s.name, s.description))
84            .collect();
85
86        if matches.is_empty() {
87            "no skills found".to_owned()
88        } else {
89            matches.join("\n")
90        }
91    }
92}