Skip to main content

sac/skills/
registry.rs

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}