1use super::*;
2
3#[derive(Clone)]
4pub struct SkillRegistry {
5 skills: Arc<HashMap<String, SkillRecord>>,
6}
7
8impl SkillRegistry {
9 pub fn load(
10 workspace_dir: Option<&Path>,
11 sandbox: Option<&SandboxSession>,
12 ) -> Result<Option<Arc<Self>>> {
13 let sources = discover_skill_sources(workspace_dir)?;
14 if sources.is_empty() {
15 return Ok(None);
16 }
17
18 let mut skills = HashMap::new();
19 let mut shadowed = HashSet::new();
20
21 for source in sources {
22 let visible_root = match visible_root_for_source(&source, sandbox) {
23 Some(path) => path,
24 None => continue,
25 };
26 for skill_dir in discover_skill_dirs(&source.host_root)? {
27 let skill_md_path = skill_dir.join(SKILL_FILENAME);
28 let Some(parsed) = parse_skill_file(&skill_md_path)? else {
29 continue;
30 };
31
32 let relative = skill_dir
33 .strip_prefix(&source.host_root)
34 .unwrap_or_else(|_| Path::new(""));
35 let skill_root_visible = join_path(&visible_root, relative);
36 let record = SkillRecord {
37 name: parsed.name.clone(),
38 description: parsed.description,
39 compatibility: parsed.compatibility,
40 skill_md_path,
41 skill_root_host: skill_dir.clone(),
42 skill_root_visible,
43 body: parsed.body,
44 resources: list_skill_resources(&skill_dir)?,
45 };
46
47 if skills.contains_key(&parsed.name) {
48 shadowed.insert(parsed.name);
49 continue;
50 }
51 skills.insert(parsed.name.clone(), record);
52 }
53 }
54
55 for name in shadowed {
56 eprintln!(
57 "Skill '{}' is shadowed by a higher-precedence definition",
58 name
59 );
60 }
61
62 if skills.is_empty() {
63 return Ok(None);
64 }
65
66 Ok(Some(Arc::new(Self {
67 skills: Arc::new(skills),
68 })))
69 }
70
71 pub fn tool_definition(&self) -> ToolDefinition {
72 let mut names: Vec<String> = self.skills.keys().cloned().collect();
73 names.sort();
74 ToolDefinition {
75 def_type: "function".to_string(),
76 function: FunctionDef {
77 name: "activate_skill".to_string(),
78 description:
79 "Load the full instructions for an available skill by name before proceeding."
80 .to_string(),
81 parameters: json!({
82 "type": "object",
83 "properties": {
84 "name": {
85 "type": "string",
86 "enum": names,
87 "description": "Name of the skill to activate"
88 }
89 },
90 "required": ["name"]
91 }),
92 },
93 }
94 }
95
96 pub fn catalog_message(&self) -> Option<String> {
97 let catalog = self.catalog_entries();
98 if catalog.is_empty() {
99 return None;
100 }
101
102 let mut content = String::from(
103 "The following skills provide specialized instructions for specific tasks. \
104 When a task clearly matches a skill's description, call activate_skill(name) \
105 before proceeding. After activation, follow the returned instructions and \
106 resolve any relative paths against the returned skill directory.\n\n<available_skills>",
107 );
108
109 for entry in catalog {
110 content.push_str("\n <skill>");
111 content.push_str(&format!("\n <name>{}</name>", escape_xml(&entry.name)));
112 content.push_str(&format!(
113 "\n <description>{}</description>",
114 escape_xml(&entry.description)
115 ));
116 if let Some(compatibility) = entry.compatibility {
117 content.push_str(&format!(
118 "\n <compatibility>{}</compatibility>",
119 escape_xml(&compatibility)
120 ));
121 }
122 content.push_str("\n </skill>");
123 }
124 content.push_str("\n</available_skills>");
125 Some(content)
126 }
127
128 pub fn catalog_entries(&self) -> Vec<SkillCatalogEntry> {
129 let mut entries: Vec<SkillCatalogEntry> = self
130 .skills
131 .values()
132 .map(|skill| SkillCatalogEntry {
133 name: skill.name.clone(),
134 description: skill.description.clone(),
135 compatibility: skill.compatibility.clone(),
136 })
137 .collect();
138 entries.sort_by(|left, right| left.name.cmp(&right.name));
139 entries
140 }
141
142 pub fn has_skill(&self, name: &str) -> bool {
143 self.skills.contains_key(name)
144 }
145
146 pub fn activate(&self, name: &str, already_active: bool) -> ToolResult {
147 let Some(skill) = self.skills.get(name) else {
148 return ToolResult {
149 content: format!("Error: unknown skill '{}'", name),
150 is_error: true,
151 };
152 };
153
154 let mut content = String::new();
155 if already_active {
156 content.push_str(&format!(
157 "<skill_content name=\"{}\" already_active=\"true\">\n",
158 escape_xml(&skill.name)
159 ));
160 content.push_str(
161 "This skill is already active in the current conversation, so its instructions are not being re-injected.\n\n",
162 );
163 } else {
164 content.push_str(&format!(
165 "<skill_content name=\"{}\">\n",
166 escape_xml(&skill.name)
167 ));
168 if let Some(compatibility) = &skill.compatibility {
169 content.push_str(&format!("Compatibility: {}\n\n", compatibility));
170 }
171 content.push_str(&skill.body);
172 if !skill.body.ends_with('\n') {
173 content.push('\n');
174 }
175 content.push('\n');
176 }
177
178 content.push_str(&format!(
179 "Skill directory: {}\n",
180 skill.skill_root_visible.display()
181 ));
182 content.push_str("Relative paths in this skill are relative to the skill directory.\n");
183 if !skill.resources.is_empty() {
184 content.push_str("<skill_resources>\n");
185 for resource in &skill.resources {
186 content.push_str(&format!(" <file>{}</file>\n", escape_xml(resource)));
187 }
188 content.push_str("</skill_resources>\n");
189 }
190 content.push_str("</skill_content>");
191
192 ToolResult {
193 content,
194 is_error: false,
195 }
196 }
197}
198
199#[cfg(test)]
200impl SkillRegistry {
201 pub(crate) fn load_for_test(records: Vec<SkillRecord>) -> Self {
202 let skills = records
203 .into_iter()
204 .map(|record| (record.name.clone(), record))
205 .collect();
206 Self {
207 skills: Arc::new(skills),
208 }
209 }
210}