crabtalk_runtime/skill/
tool.rs1use crate::{Env, host::Host, 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 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<H: Host> Env<H> {
26 pub async fn dispatch_skill(&self, args: &str, agent: &str) -> Result<String, String> {
27 let input: Skill =
28 serde_json::from_str(args).map_err(|e| format!("invalid arguments: {e}"))?;
29 let name = &input.name;
30
31 if let Some(scope) = self.scopes.get(agent)
33 && !scope.skills.is_empty()
34 && !scope.skills.iter().any(|s| s == name)
35 {
36 return Err(format!("skill not available: {name}"));
37 }
38
39 if name.contains("..") || name.contains('/') || name.contains('\\') {
41 return Err(format!("invalid skill name: {name}"));
42 }
43
44 if !name.is_empty() {
46 for dir in &self.skills.skill_dirs {
47 let skill_dir = dir.join(name);
48 let skill_file = skill_dir.join("SKILL.md");
49 if let Ok(content) = tokio::fs::read_to_string(&skill_file).await {
50 return match loader::parse_skill_md(&content) {
51 Ok(skill) => {
52 let body = skill.body.clone();
53 self.skills.registry.lock().await.upsert(skill);
54 let dir_path = skill_dir.display();
55 Ok(format!("{body}\n\nSkill directory: {dir_path}"))
56 }
57 Err(e) => Err(format!("failed to parse skill: {e}")),
58 };
59 }
60 }
61 }
62
63 let query = name.to_lowercase();
65 let allowed = self.scopes.get(agent).map(|s| &s.skills);
66 let registry = self.skills.registry.lock().await;
67 let matches: Vec<String> = registry
68 .skills
69 .iter()
70 .filter(|s| {
71 if let Some(allowed) = allowed
72 && !allowed.is_empty()
73 && !allowed.iter().any(|a| a == s.name.as_str())
74 {
75 return false;
76 }
77 query.is_empty()
78 || s.name.to_lowercase().contains(&query)
79 || s.description.to_lowercase().contains(&query)
80 })
81 .map(|s| format!("{}: {}", s.name, s.description))
82 .collect();
83
84 if matches.is_empty() {
87 Ok("no skills found".to_owned())
88 } else {
89 Ok(matches.join("\n"))
90 }
91 }
92}