1 //! Agent Skills support for siGit Code.
2 //!
3 //! Implements the open [Agent Skills](https://agentskills.io) format: a skill is
4 //! a directory containing a `SKILL.md` file with YAML frontmatter (`name` +
5 //! `description`, plus optional fields) followed by Markdown instructions. Skills
6 //! may bundle `scripts/`, `references/`, and `assets/` the agent loads on demand.
7 //!
8 //! Loading follows the spec's *progressive disclosure*:
9 //!
10 //! 1. **Discovery** — at turn-build time we scan the skill roots and load only
11 //! each skill's `name` and `description` into the `skill` tool's description,
12 //! so the model knows what's available for a small context cost.
13 //! 2. **Activation** — when a task matches, the model calls the `skill` tool with
14 //! a name; [`activate_skill`] reads the full `SKILL.md` body into context.
15 //! 3. **Execution** — the model follows the instructions, reading bundled files
16 //! (under the reported skill directory) with the normal file/command tools.
17 //!
18 //! Skills are discovered from, in priority order (earlier wins on name clashes):
19 //!
20 //! - `<cwd>/.sigit/skills/` and `<cwd>/.claude/skills/` (project-local)
21 //! - `$SIGIT_CONFIG_DIR/skills/` (default `~/.config/sigit/skills/`)
22 //! - `~/.claude/skills/` (shared with the broader ecosystem)
23
24 use std::path::{Path, PathBuf};
25
26 use serde_json::{Value, json};
27
28 use crate::frontmatter::{extract_frontmatter, parse_frontmatter_fields, strip_frontmatter};
29
30 /// The agent-facing tool name used to activate a skill.
31 pub const SKILL_TOOL_NAME: &str = "skill";
32
33 /// Hard cap on how many skills we advertise, to bound the tool description size.
34 const MAX_ADVERTISED_SKILLS: usize = 100;
35
36 /// A discovered skill: its identifying metadata plus where it lives on disk.
37 #[derive(Debug, Clone, PartialEq, Eq)]
38 pub struct Skill {
39 /// The `name` from frontmatter. Lowercase alphanumeric + single hyphens.
40 pub name: String,
41 /// The `description` from frontmatter: what the skill does and when to use it.
42 pub description: String,
43 /// Optional `license` field.
44 pub license: Option<String>,
45 /// Optional `compatibility` field (environment requirements).
46 pub compatibility: Option<String>,
47 /// The skill's root directory (the one holding `SKILL.md`).
48 pub dir: PathBuf,
49 }
50
51 impl Skill {
52 /// Absolute path to this skill's `SKILL.md`.
53 fn skill_md(&self) -> PathBuf {
54 self.dir.join("SKILL.md")
55 }
56 }
57
58 /// JSON Schema for the `skill` tool's arguments.
59 pub fn skill_tool_schema() -> Value {
60 json!({
61 "type": "object",
62 "properties": {
63 "name": {
64 "type": "string",
65 "description": "The name of the skill to activate, exactly as listed in this tool's description."
66 }
67 },
68 "required": ["name"],
69 "additionalProperties": false
70 })
71 }
72
73 /// Build the `skill` tool description, embedding the discovery list (each skill's
74 /// `name` and `description`) so the model can decide when to activate one.
75 pub fn skill_tool_description(skills: &[Skill]) -> String {
76 let mut out = String::from(
77 "Activate an Agent Skill to load its full instructions into context. \
78 Skills are reusable, on-demand capabilities — specialized knowledge and \
79 step-by-step workflows packaged as a folder. Only the name and description \
80 of each skill are loaded up front; calling this tool with a skill's `name` \
81 reads its full instructions (and tells you the skill's directory, so you \
82 can read any bundled scripts, references, or assets with the file and \
83 command tools). Activate a skill as soon as the user's task matches one of \
84 the descriptions below; follow its instructions over your defaults.\n\n\
85 Available skills:\n",
86 );
87 for skill in skills.iter().take(MAX_ADVERTISED_SKILLS) {
88 out.push_str("- ");
89 out.push_str(&skill.name);
90 out.push_str(": ");
91 out.push_str(&skill.description);
92 out.push('\n');
93 }
94 out
95 }
96
97 /// Human-readable list of discovered skills, for the `/skills` slash command.
98 pub fn format_skills_list() -> String {
99 let skills = discover_skills();
100 if skills.is_empty() {
101 return "No skills found. Add a skill folder (with a SKILL.md) under \
102 .sigit/skills/ or .claude/skills/ in your project, or under \
103 ~/.config/sigit/skills/. See https://agentskills.io."
104 .to_string();
105 }
106
107 let mut lines = vec![format!("{} skill(s) available:", skills.len())];
108 for skill in &skills {
109 lines.push(format!("- {}: {}", skill.name, skill.description));
110 }
111 lines.push(String::new());
112 lines.push(
113 "I activate a skill automatically when your task matches its description.".to_string(),
114 );
115 lines.join("\n")
116 }
117
118 /// Execute the `skill` tool: parse the requested name and return the full
119 /// `SKILL.md` body, prefixed with the skill's directory so relative references
120 /// (e.g. `scripts/foo.py`, `references/REFERENCE.md`) can be resolved.
121 pub fn activate_skill(arguments: &str) -> String {
122 let args: Value = match serde_json::from_str(arguments) {
123 Ok(v) => v,
124 Err(err) => return format!("Error: failed to parse arguments: {err}"),
125 };
126
127 let name = match args.get("name").and_then(Value::as_str) {
128 Some(n) => n.trim(),
129 None => return "Error: missing required parameter \"name\"".to_string(),
130 };
131
132 // Re-discover so activation always reflects the skills on disk right now.
133 let skills = discover_skills();
134 let Some(skill) = skills.iter().find(|s| s.name == name) else {
135 if skills.is_empty() {
136 return format!("Error: no skill named \"{name}\" is available (no skills found).");
137 }
138 let available = skills
139 .iter()
140 .map(|s| s.name.as_str())
141 .collect::<Vec<_>>()
142 .join(", ");
143 return format!("Error: no skill named \"{name}\". Available skills: {available}.");
144 };
145
146 let body = match read_skill_body(&skill.skill_md()) {
147 Ok(body) => body,
148 Err(err) => {
149 return format!(
150 "Error: could not read SKILL.md for \"{name}\" at {}: {err}",
151 skill.skill_md().display()
152 );
153 }
154 };
155
156 // Surface the optional metadata so the agent (and user) can sanity-check
157 // environment requirements before following the instructions.
158 let mut notes = String::new();
159 if let Some(compatibility) = &skill.compatibility {
160 notes.push_str(&format!("Compatibility: {compatibility}\n"));
161 }
162 if let Some(license) = &skill.license {
163 notes.push_str(&format!("License: {license}\n"));
164 }
165 if !notes.is_empty() {
166 notes.push('\n');
167 }
168
169 let dir = skill.dir.display();
170 format!(
171 "Skill \"{name}\" activated. Its directory is {dir} — resolve any relative \
172 file references (scripts/, references/, assets/) against that path. Follow \
173 these instructions:\n\n{notes}{body}"
174 )
175 }
176
177 /// Discover all valid skills across the known roots. Earlier roots win when two
178 /// skills share a `name`. The result is sorted by name for stable output.
179 pub fn discover_skills() -> Vec<Skill> {
180 let mut skills: Vec<Skill> = Vec::new();
181 let mut seen_names: Vec<String> = Vec::new();
182
183 for root in skill_roots() {
184 collect_skills_from_root(&root, &mut skills, &mut seen_names);
185 }
186
187 skills.sort_by(|a, b| a.name.cmp(&b.name));
188 skills
189 }
190
191 /// The skill directories to scan, in priority order.
192 fn skill_roots() -> Vec<PathBuf> {
193 let mut roots = Vec::new();
194
195 // Project-local skills win over user-global ones.
196 if let Ok(cwd) = std::env::current_dir() {
197 roots.push(cwd.join(".sigit").join("skills"));
198 roots.push(cwd.join(".claude").join("skills"));
199 }
200
201 // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
202 if let Some(config_dir) = sigit_config_dir() {
203 roots.push(config_dir.join("skills"));
204 }
205
206 // Shared with the broader Agent Skills ecosystem.
207 if let Some(home) = home_dir() {
208 roots.push(home.join(".claude").join("skills"));
209 }
210
211 roots
212 }
213
214 fn sigit_config_dir() -> Option<PathBuf> {
215 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
216 && !dir.is_empty()
217 {
218 return Some(PathBuf::from(dir));
219 }
220 home_dir().map(|home| home.join(".config").join("sigit"))
221 }
222
223 fn home_dir() -> Option<PathBuf> {
224 std::env::var("HOME").ok().map(PathBuf::from)
225 }
226
227 /// Scan a single root for skill subdirectories, appending newly-seen skills.
228 fn collect_skills_from_root(root: &Path, skills: &mut Vec<Skill>, seen_names: &mut Vec<String>) {
229 let entries = match std::fs::read_dir(root) {
230 Ok(entries) => entries,
231 // Most roots won't exist; that's expected, not an error.
232 Err(_) => return,
233 };
234
235 for entry in entries.flatten() {
236 let dir = entry.path();
237 if !dir.is_dir() {
238 continue;
239 }
240
241 let skill_md = dir.join("SKILL.md");
242 if !skill_md.is_file() {
243 continue;
244 }
245
246 let contents = match std::fs::read_to_string(&skill_md) {
247 Ok(contents) => contents,
248 Err(error) => {
249 log::warn!("skipping skill at {}: {error}", skill_md.display());
250 continue;
251 }
252 };
253
254 let skill = match parse_skill(&contents, &dir) {
255 Ok(skill) => skill,
256 Err(error) => {
257 log::warn!("skipping invalid skill at {}: {error}", skill_md.display());
258 continue;
259 }
260 };
261
262 // First root to define a name wins; later duplicates are ignored.
263 if seen_names.iter().any(|name| name == &skill.name) {
264 log::debug!(
265 "skill \"{}\" at {} shadowed by an earlier definition",
266 skill.name,
267 dir.display()
268 );
269 continue;
270 }
271
272 seen_names.push(skill.name.clone());
273 skills.push(skill);
274 }
275 }
276
277 /// Parse a `SKILL.md` into a [`Skill`], validating the required fields against
278 /// the Agent Skills spec. `dir` is the skill's root directory.
279 fn parse_skill(contents: &str, dir: &Path) -> Result<Skill, String> {
280 let frontmatter = extract_frontmatter(contents)
281 .ok_or_else(|| "missing YAML frontmatter (expected leading `---` block)".to_string())?;
282
283 let fields = parse_frontmatter_fields(frontmatter);
284
285 let name = fields
286 .iter()
287 .find(|(k, _)| k == "name")
288 .map(|(_, v)| v.clone())
289 .ok_or_else(|| "frontmatter is missing required field `name`".to_string())?;
290 validate_name(&name)?;
291
292 let description = fields
293 .iter()
294 .find(|(k, _)| k == "description")
295 .map(|(_, v)| v.clone())
296 .ok_or_else(|| "frontmatter is missing required field `description`".to_string())?;
297 if description.is_empty() {
298 return Err("`description` must not be empty".to_string());
299 }
300 if description.chars().count() > 1024 {
301 return Err("`description` exceeds the 1024-character limit".to_string());
302 }
303
304 // The spec requires `name` to match the parent directory name. Warn but stay
305 // lenient — the frontmatter name is the identity used for activation.
306 if let Some(dir_name) = dir.file_name().and_then(|n| n.to_str())
307 && dir_name != name
308 {
309 log::warn!(
310 "skill name \"{name}\" does not match its directory \"{dir_name}\" at {}",
311 dir.display()
312 );
313 }
314
315 let license = fields
316 .iter()
317 .find(|(k, _)| k == "license")
318 .map(|(_, v)| v.clone())
319 .filter(|v| !v.is_empty());
320 let compatibility = fields
321 .iter()
322 .find(|(k, _)| k == "compatibility")
323 .map(|(_, v)| v.clone())
324 .filter(|v| !v.is_empty());
325
326 Ok(Skill {
327 name,
328 description,
329 license,
330 compatibility,
331 dir: dir.to_path_buf(),
332 })
333 }
334
335 /// Validate the `name` field per the Agent Skills spec: 1-64 chars, lowercase
336 /// alphanumeric and hyphens only, no leading/trailing or consecutive hyphens.
337 pub(crate) fn validate_name(name: &str) -> Result<(), String> {
338 let len = name.chars().count();
339 if len == 0 {
340 return Err("`name` must not be empty".to_string());
341 }
342 if len > 64 {
343 return Err("`name` exceeds the 64-character limit".to_string());
344 }
345 if !name
346 .chars()
347 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
348 {
349 return Err("`name` may only contain lowercase letters, digits, and hyphens".to_string());
350 }
351 if name.starts_with('-') || name.ends_with('-') {
352 return Err("`name` must not start or end with a hyphen".to_string());
353 }
354 if name.contains("--") {
355 return Err("`name` must not contain consecutive hyphens".to_string());
356 }
357 Ok(())
358 }
359
360 /// Read the Markdown body of a `SKILL.md` (everything after the frontmatter),
361 /// falling back to the whole file if no frontmatter delimiter is found.
362 fn read_skill_body(skill_md: &Path) -> Result<String, String> {
363 let contents = std::fs::read_to_string(skill_md).map_err(|e| e.to_string())?;
364 Ok(strip_frontmatter(&contents).trim().to_string())
365 }
366
367 #[cfg(test)]
368 mod tests {
369 use super::*;
370 use std::fs;
371
372 fn unique_dir(name: &str) -> PathBuf {
373 let nanos = std::time::SystemTime::now()
374 .duration_since(std::time::UNIX_EPOCH)
375 .unwrap()
376 .as_nanos();
377 std::env::temp_dir().join(format!("sigit-skills-test-{name}-{nanos}"))
378 }
379
380 #[test]
381 fn validate_name_accepts_valid_names() {
382 assert!(validate_name("pdf-processing").is_ok());
383 assert!(validate_name("data-analysis").is_ok());
384 assert!(validate_name("code-review").is_ok());
385 assert!(validate_name("a").is_ok());
386 assert!(validate_name("skill1").is_ok());
387 }
388
389 #[test]
390 fn validate_name_rejects_invalid_names() {
391 assert!(validate_name("").is_err());
392 assert!(validate_name("PDF-Processing").is_err());
393 assert!(validate_name("-pdf").is_err());
394 assert!(validate_name("pdf-").is_err());
395 assert!(validate_name("pdf--processing").is_err());
396 assert!(validate_name("pdf_processing").is_err());
397 assert!(validate_name(&"a".repeat(65)).is_err());
398 }
399
400 #[test]
401 fn parse_skill_requires_name_and_description() {
402 let dir = Path::new("/tmp/example-skill");
403 assert!(parse_skill("---\ndescription: x\n---\n", dir).is_err());
404 assert!(parse_skill("---\nname: x\n---\n", dir).is_err());
405 let ok = parse_skill(
406 "---\nname: example-skill\ndescription: does things\n---\nbody",
407 dir,
408 );
409 assert!(ok.is_ok());
410 let skill = ok.unwrap();
411 assert_eq!(skill.name, "example-skill");
412 assert_eq!(skill.description, "does things");
413 }
414
415 #[test]
416 fn discover_and_activate_roundtrip() {
417 let _guard = crate::ENV_TEST_LOCK
418 .lock()
419 .unwrap_or_else(|poisoned| poisoned.into_inner());
420 let root = unique_dir("roundtrip");
421 let skills_root = root.join(".sigit").join("skills");
422 let skill_dir = skills_root.join("hello-world");
423 fs::create_dir_all(&skill_dir).unwrap();
424 fs::write(
425 skill_dir.join("SKILL.md"),
426 "---\nname: hello-world\ndescription: Say hello. Use when greeting.\n---\n\nGreet the user warmly.\n",
427 )
428 .unwrap();
429
430 // discover_skills() reads the current directory, so run from `root`.
431 let prev = std::env::current_dir().unwrap();
432 std::env::set_current_dir(&root).unwrap();
433
434 let skills = discover_skills();
435 let found = skills.iter().find(|s| s.name == "hello-world");
436 assert!(found.is_some(), "expected to discover hello-world");
437 assert_eq!(found.unwrap().description, "Say hello. Use when greeting.");
438
439 let activated = activate_skill(r#"{"name": "hello-world"}"#);
440 assert!(activated.contains("Greet the user warmly."));
441 assert!(activated.contains("activated"));
442
443 let missing = activate_skill(r#"{"name": "nope"}"#);
444 assert!(missing.contains("no skill named"));
445
446 std::env::set_current_dir(prev).unwrap();
447 let _ = fs::remove_dir_all(&root);
448 }
449
450 #[test]
451 fn skill_tool_description_lists_skills() {
452 let skills = vec![Skill {
453 name: "pdf-processing".to_string(),
454 description: "Extract PDF text".to_string(),
455 license: None,
456 compatibility: None,
457 dir: PathBuf::from("/x/pdf-processing"),
458 }];
459 let desc = skill_tool_description(&skills);
460 assert!(desc.contains("Available skills:"));
461 assert!(desc.contains("- pdf-processing: Extract PDF text"));
462 }
463
464 #[test]
465 fn skill_tool_schema_requires_name() {
466 let schema = skill_tool_schema();
467 assert_eq!(schema["type"], "object");
468 assert_eq!(schema["required"][0], "name");
469 }
470 }