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 /// The agent-facing tool name used to activate a skill.
29 pub const SKILL_TOOL_NAME: &str = "skill";
30
31 /// Hard cap on how many skills we advertise, to bound the tool description size.
32 const MAX_ADVERTISED_SKILLS: usize = 100;
33
34 /// A discovered skill: its identifying metadata plus where it lives on disk.
35 #[derive(Debug, Clone, PartialEq, Eq)]
36 pub struct Skill {
37 /// The `name` from frontmatter. Lowercase alphanumeric + single hyphens.
38 pub name: String,
39 /// The `description` from frontmatter: what the skill does and when to use it.
40 pub description: String,
41 /// Optional `license` field.
42 pub license: Option<String>,
43 /// Optional `compatibility` field (environment requirements).
44 pub compatibility: Option<String>,
45 /// The skill's root directory (the one holding `SKILL.md`).
46 pub dir: PathBuf,
47 }
48
49 impl Skill {
50 /// Absolute path to this skill's `SKILL.md`.
51 fn skill_md(&self) -> PathBuf {
52 self.dir.join("SKILL.md")
53 }
54 }
55
56 /// JSON Schema for the `skill` tool's arguments.
57 pub fn skill_tool_schema() -> Value {
58 json!({
59 "type": "object",
60 "properties": {
61 "name": {
62 "type": "string",
63 "description": "The name of the skill to activate, exactly as listed in this tool's description."
64 }
65 },
66 "required": ["name"],
67 "additionalProperties": false
68 })
69 }
70
71 /// Build the `skill` tool description, embedding the discovery list (each skill's
72 /// `name` and `description`) so the model can decide when to activate one.
73 pub fn skill_tool_description(skills: &[Skill]) -> String {
74 let mut out = String::from(
75 "Activate an Agent Skill to load its full instructions into context. \
76 Skills are reusable, on-demand capabilities — specialized knowledge and \
77 step-by-step workflows packaged as a folder. Only the name and description \
78 of each skill are loaded up front; calling this tool with a skill's `name` \
79 reads its full instructions (and tells you the skill's directory, so you \
80 can read any bundled scripts, references, or assets with the file and \
81 command tools). Activate a skill as soon as the user's task matches one of \
82 the descriptions below; follow its instructions over your defaults.\n\n\
83 Available skills:\n",
84 );
85 for skill in skills.iter().take(MAX_ADVERTISED_SKILLS) {
86 out.push_str("- ");
87 out.push_str(&skill.name);
88 out.push_str(": ");
89 out.push_str(&skill.description);
90 out.push('\n');
91 }
92 out
93 }
94
95 /// Human-readable list of discovered skills, for the `/skills` slash command.
96 pub fn format_skills_list() -> String {
97 let skills = discover_skills();
98 if skills.is_empty() {
99 return "No skills found. Add a skill folder (with a SKILL.md) under \
100 .sigit/skills/ or .claude/skills/ in your project, or under \
101 ~/.config/sigit/skills/. See https://agentskills.io."
102 .to_string();
103 }
104
105 let mut lines = vec![format!("{} skill(s) available:", skills.len())];
106 for skill in &skills {
107 lines.push(format!("- {}: {}", skill.name, skill.description));
108 }
109 lines.push(String::new());
110 lines.push(
111 "I activate a skill automatically when your task matches its description.".to_string(),
112 );
113 lines.join("\n")
114 }
115
116 /// Execute the `skill` tool: parse the requested name and return the full
117 /// `SKILL.md` body, prefixed with the skill's directory so relative references
118 /// (e.g. `scripts/foo.py`, `references/REFERENCE.md`) can be resolved.
119 pub fn activate_skill(arguments: &str) -> String {
120 let args: Value = match serde_json::from_str(arguments) {
121 Ok(v) => v,
122 Err(err) => return format!("Error: failed to parse arguments: {err}"),
123 };
124
125 let name = match args.get("name").and_then(Value::as_str) {
126 Some(n) => n.trim(),
127 None => return "Error: missing required parameter \"name\"".to_string(),
128 };
129
130 // Re-discover so activation always reflects the skills on disk right now.
131 let skills = discover_skills();
132 let Some(skill) = skills.iter().find(|s| s.name == name) else {
133 if skills.is_empty() {
134 return format!("Error: no skill named \"{name}\" is available (no skills found).");
135 }
136 let available = skills
137 .iter()
138 .map(|s| s.name.as_str())
139 .collect::<Vec<_>>()
140 .join(", ");
141 return format!("Error: no skill named \"{name}\". Available skills: {available}.");
142 };
143
144 let body = match read_skill_body(&skill.skill_md()) {
145 Ok(body) => body,
146 Err(err) => {
147 return format!(
148 "Error: could not read SKILL.md for \"{name}\" at {}: {err}",
149 skill.skill_md().display()
150 );
151 }
152 };
153
154 // Surface the optional metadata so the agent (and user) can sanity-check
155 // environment requirements before following the instructions.
156 let mut notes = String::new();
157 if let Some(compatibility) = &skill.compatibility {
158 notes.push_str(&format!("Compatibility: {compatibility}\n"));
159 }
160 if let Some(license) = &skill.license {
161 notes.push_str(&format!("License: {license}\n"));
162 }
163 if !notes.is_empty() {
164 notes.push('\n');
165 }
166
167 let dir = skill.dir.display();
168 format!(
169 "Skill \"{name}\" activated. Its directory is {dir} — resolve any relative \
170 file references (scripts/, references/, assets/) against that path. Follow \
171 these instructions:\n\n{notes}{body}"
172 )
173 }
174
175 /// Discover all valid skills across the known roots. Earlier roots win when two
176 /// skills share a `name`. The result is sorted by name for stable output.
177 pub fn discover_skills() -> Vec<Skill> {
178 let mut skills: Vec<Skill> = Vec::new();
179 let mut seen_names: Vec<String> = Vec::new();
180
181 for root in skill_roots() {
182 collect_skills_from_root(&root, &mut skills, &mut seen_names);
183 }
184
185 skills.sort_by(|a, b| a.name.cmp(&b.name));
186 skills
187 }
188
189 /// The skill directories to scan, in priority order.
190 fn skill_roots() -> Vec<PathBuf> {
191 let mut roots = Vec::new();
192
193 // Project-local skills win over user-global ones.
194 if let Ok(cwd) = std::env::current_dir() {
195 roots.push(cwd.join(".sigit").join("skills"));
196 roots.push(cwd.join(".claude").join("skills"));
197 }
198
199 // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
200 if let Some(config_dir) = sigit_config_dir() {
201 roots.push(config_dir.join("skills"));
202 }
203
204 // Shared with the broader Agent Skills ecosystem.
205 if let Some(home) = home_dir() {
206 roots.push(home.join(".claude").join("skills"));
207 }
208
209 roots
210 }
211
212 fn sigit_config_dir() -> Option<PathBuf> {
213 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
214 && !dir.is_empty()
215 {
216 return Some(PathBuf::from(dir));
217 }
218 home_dir().map(|home| home.join(".config").join("sigit"))
219 }
220
221 fn home_dir() -> Option<PathBuf> {
222 std::env::var("HOME").ok().map(PathBuf::from)
223 }
224
225 /// Scan a single root for skill subdirectories, appending newly-seen skills.
226 fn collect_skills_from_root(root: &Path, skills: &mut Vec<Skill>, seen_names: &mut Vec<String>) {
227 let entries = match std::fs::read_dir(root) {
228 Ok(entries) => entries,
229 // Most roots won't exist; that's expected, not an error.
230 Err(_) => return,
231 };
232
233 for entry in entries.flatten() {
234 let dir = entry.path();
235 if !dir.is_dir() {
236 continue;
237 }
238
239 let skill_md = dir.join("SKILL.md");
240 if !skill_md.is_file() {
241 continue;
242 }
243
244 let contents = match std::fs::read_to_string(&skill_md) {
245 Ok(contents) => contents,
246 Err(error) => {
247 log::warn!("skipping skill at {}: {error}", skill_md.display());
248 continue;
249 }
250 };
251
252 let skill = match parse_skill(&contents, &dir) {
253 Ok(skill) => skill,
254 Err(error) => {
255 log::warn!("skipping invalid skill at {}: {error}", skill_md.display());
256 continue;
257 }
258 };
259
260 // First root to define a name wins; later duplicates are ignored.
261 if seen_names.iter().any(|name| name == &skill.name) {
262 log::debug!(
263 "skill \"{}\" at {} shadowed by an earlier definition",
264 skill.name,
265 dir.display()
266 );
267 continue;
268 }
269
270 seen_names.push(skill.name.clone());
271 skills.push(skill);
272 }
273 }
274
275 /// Parse a `SKILL.md` into a [`Skill`], validating the required fields against
276 /// the Agent Skills spec. `dir` is the skill's root directory.
277 fn parse_skill(contents: &str, dir: &Path) -> Result<Skill, String> {
278 let frontmatter = extract_frontmatter(contents)
279 .ok_or_else(|| "missing YAML frontmatter (expected leading `---` block)".to_string())?;
280
281 let fields = parse_frontmatter_fields(frontmatter);
282
283 let name = fields
284 .iter()
285 .find(|(k, _)| k == "name")
286 .map(|(_, v)| v.clone())
287 .ok_or_else(|| "frontmatter is missing required field `name`".to_string())?;
288 validate_name(&name)?;
289
290 let description = fields
291 .iter()
292 .find(|(k, _)| k == "description")
293 .map(|(_, v)| v.clone())
294 .ok_or_else(|| "frontmatter is missing required field `description`".to_string())?;
295 if description.is_empty() {
296 return Err("`description` must not be empty".to_string());
297 }
298 if description.chars().count() > 1024 {
299 return Err("`description` exceeds the 1024-character limit".to_string());
300 }
301
302 // The spec requires `name` to match the parent directory name. Warn but stay
303 // lenient — the frontmatter name is the identity used for activation.
304 if let Some(dir_name) = dir.file_name().and_then(|n| n.to_str())
305 && dir_name != name
306 {
307 log::warn!(
308 "skill name \"{name}\" does not match its directory \"{dir_name}\" at {}",
309 dir.display()
310 );
311 }
312
313 let license = fields
314 .iter()
315 .find(|(k, _)| k == "license")
316 .map(|(_, v)| v.clone())
317 .filter(|v| !v.is_empty());
318 let compatibility = fields
319 .iter()
320 .find(|(k, _)| k == "compatibility")
321 .map(|(_, v)| v.clone())
322 .filter(|v| !v.is_empty());
323
324 Ok(Skill {
325 name,
326 description,
327 license,
328 compatibility,
329 dir: dir.to_path_buf(),
330 })
331 }
332
333 /// Validate the `name` field per the Agent Skills spec: 1-64 chars, lowercase
334 /// alphanumeric and hyphens only, no leading/trailing or consecutive hyphens.
335 fn validate_name(name: &str) -> Result<(), String> {
336 let len = name.chars().count();
337 if len == 0 {
338 return Err("`name` must not be empty".to_string());
339 }
340 if len > 64 {
341 return Err("`name` exceeds the 64-character limit".to_string());
342 }
343 if !name
344 .chars()
345 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
346 {
347 return Err("`name` may only contain lowercase letters, digits, and hyphens".to_string());
348 }
349 if name.starts_with('-') || name.ends_with('-') {
350 return Err("`name` must not start or end with a hyphen".to_string());
351 }
352 if name.contains("--") {
353 return Err("`name` must not contain consecutive hyphens".to_string());
354 }
355 Ok(())
356 }
357
358 /// Extract the YAML frontmatter block from a `SKILL.md`: the text between a
359 /// leading `---` line and the next `---` line. Returns `None` if absent.
360 fn extract_frontmatter(contents: &str) -> Option<&str> {
361 // Strip an optional UTF-8 BOM and leading blank lines before the opener.
362 let trimmed = contents.trim_start_matches('\u{feff}');
363 let mut rest = trimmed;
364 loop {
365 let line_end = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
366 let (line, after) = rest.split_at(line_end);
367 if line.trim().is_empty() {
368 rest = after;
369 continue;
370 }
371 if line.trim() != "---" {
372 return None;
373 }
374 // `after` now begins just past the opening `---` line.
375 let body = after;
376 let mut search = body;
377 let mut offset = 0;
378 loop {
379 let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
380 let (l, a) = search.split_at(end);
381 if l.trim() == "---" {
382 return Some(&body[..offset]);
383 }
384 if a.is_empty() {
385 return None;
386 }
387 offset += end;
388 search = a;
389 }
390 }
391 }
392
393 /// Parse top-level `key: value` scalar pairs from frontmatter, skipping nested
394 /// mappings (indented lines) and comments. Quoted values are unquoted. We only
395 /// need scalar metadata (`name`, `description`, `license`, `compatibility`).
396 fn parse_frontmatter_fields(frontmatter: &str) -> Vec<(String, String)> {
397 let mut fields = Vec::new();
398 for line in frontmatter.lines() {
399 // Indented lines belong to a nested mapping/sequence — skip them.
400 if line.starts_with(char::is_whitespace) {
401 continue;
402 }
403 let line = line.trim_end();
404 if line.is_empty() || line.starts_with('#') {
405 continue;
406 }
407 let Some((key, value)) = line.split_once(':') else {
408 continue;
409 };
410 let key = key.trim();
411 if key.is_empty() {
412 continue;
413 }
414 let value = unquote(value.trim());
415 fields.push((key.to_string(), value));
416 }
417 fields
418 }
419
420 /// Strip a single layer of matching single or double quotes; otherwise return
421 /// the value unchanged. Also drops a trailing `# comment` on unquoted scalars.
422 fn unquote(value: &str) -> String {
423 if value.len() >= 2 {
424 let bytes = value.as_bytes();
425 let first = bytes[0];
426 let last = bytes[value.len() - 1];
427 if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
428 return value[1..value.len() - 1].to_string();
429 }
430 }
431 value.to_string()
432 }
433
434 /// Read the Markdown body of a `SKILL.md` (everything after the frontmatter),
435 /// falling back to the whole file if no frontmatter delimiter is found.
436 fn read_skill_body(skill_md: &Path) -> Result<String, String> {
437 let contents = std::fs::read_to_string(skill_md).map_err(|e| e.to_string())?;
438 Ok(strip_frontmatter(&contents).trim().to_string())
439 }
440
441 /// Return the content after the frontmatter block, or the whole input if there
442 /// is no frontmatter.
443 fn strip_frontmatter(contents: &str) -> &str {
444 let trimmed = contents.trim_start_matches('\u{feff}');
445 let after_opener = match trimmed.strip_prefix("---") {
446 Some(rest) => match rest.strip_prefix('\n') {
447 Some(rest) => rest,
448 None => return contents,
449 },
450 None => return contents,
451 };
452 // Find the closing `---` line.
453 let mut search = after_opener;
454 let mut offset = 0;
455 loop {
456 let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
457 let (line, after) = search.split_at(end);
458 if line.trim() == "---" {
459 return &after_opener[offset + end..];
460 }
461 if after.is_empty() {
462 return contents;
463 }
464 offset += end;
465 search = after;
466 }
467 }
468
469 #[cfg(test)]
470 mod tests {
471 use super::*;
472 use std::fs;
473
474 fn unique_dir(name: &str) -> PathBuf {
475 let nanos = std::time::SystemTime::now()
476 .duration_since(std::time::UNIX_EPOCH)
477 .unwrap()
478 .as_nanos();
479 std::env::temp_dir().join(format!("sigit-skills-test-{name}-{nanos}"))
480 }
481
482 #[test]
483 fn validate_name_accepts_valid_names() {
484 assert!(validate_name("pdf-processing").is_ok());
485 assert!(validate_name("data-analysis").is_ok());
486 assert!(validate_name("code-review").is_ok());
487 assert!(validate_name("a").is_ok());
488 assert!(validate_name("skill1").is_ok());
489 }
490
491 #[test]
492 fn validate_name_rejects_invalid_names() {
493 assert!(validate_name("").is_err());
494 assert!(validate_name("PDF-Processing").is_err());
495 assert!(validate_name("-pdf").is_err());
496 assert!(validate_name("pdf-").is_err());
497 assert!(validate_name("pdf--processing").is_err());
498 assert!(validate_name("pdf_processing").is_err());
499 assert!(validate_name(&"a".repeat(65)).is_err());
500 }
501
502 #[test]
503 fn extract_frontmatter_reads_block() {
504 let md = "---\nname: foo\ndescription: bar\n---\n\nBody here.\n";
505 let fm = extract_frontmatter(md).expect("frontmatter");
506 assert!(fm.contains("name: foo"));
507 assert!(fm.contains("description: bar"));
508 assert!(!fm.contains("Body here"));
509 }
510
511 #[test]
512 fn extract_frontmatter_none_without_delimiter() {
513 assert!(extract_frontmatter("no frontmatter here").is_none());
514 assert!(extract_frontmatter("---\nname: foo\n").is_none());
515 }
516
517 #[test]
518 fn parse_fields_handles_quotes_and_nesting() {
519 let fm = "name: pdf-processing\ndescription: \"Extract PDF text\"\nmetadata:\n author: me\n version: \"1.0\"\nlicense: Apache-2.0\n";
520 let fields = parse_frontmatter_fields(fm);
521 let get = |k: &str| {
522 fields
523 .iter()
524 .find(|(key, _)| key == k)
525 .map(|(_, v)| v.clone())
526 };
527 assert_eq!(get("name").as_deref(), Some("pdf-processing"));
528 assert_eq!(get("description").as_deref(), Some("Extract PDF text"));
529 assert_eq!(get("license").as_deref(), Some("Apache-2.0"));
530 // Nested keys under `metadata:` are skipped.
531 assert!(get("author").is_none());
532 assert!(get("version").is_none());
533 }
534
535 #[test]
536 fn parse_skill_requires_name_and_description() {
537 let dir = Path::new("/tmp/example-skill");
538 assert!(parse_skill("---\ndescription: x\n---\n", dir).is_err());
539 assert!(parse_skill("---\nname: x\n---\n", dir).is_err());
540 let ok = parse_skill(
541 "---\nname: example-skill\ndescription: does things\n---\nbody",
542 dir,
543 );
544 assert!(ok.is_ok());
545 let skill = ok.unwrap();
546 assert_eq!(skill.name, "example-skill");
547 assert_eq!(skill.description, "does things");
548 }
549
550 #[test]
551 fn strip_frontmatter_returns_body() {
552 let md = "---\nname: foo\ndescription: bar\n---\n\n# Heading\n\nText.\n";
553 assert_eq!(strip_frontmatter(md).trim(), "# Heading\n\nText.");
554 }
555
556 #[test]
557 fn strip_frontmatter_passes_through_without_block() {
558 assert_eq!(strip_frontmatter("just body"), "just body");
559 }
560
561 #[test]
562 fn discover_and_activate_roundtrip() {
563 let root = unique_dir("roundtrip");
564 let skills_root = root.join(".sigit").join("skills");
565 let skill_dir = skills_root.join("hello-world");
566 fs::create_dir_all(&skill_dir).unwrap();
567 fs::write(
568 skill_dir.join("SKILL.md"),
569 "---\nname: hello-world\ndescription: Say hello. Use when greeting.\n---\n\nGreet the user warmly.\n",
570 )
571 .unwrap();
572
573 // discover_skills() reads the current directory, so run from `root`.
574 let prev = std::env::current_dir().unwrap();
575 std::env::set_current_dir(&root).unwrap();
576
577 let skills = discover_skills();
578 let found = skills.iter().find(|s| s.name == "hello-world");
579 assert!(found.is_some(), "expected to discover hello-world");
580 assert_eq!(found.unwrap().description, "Say hello. Use when greeting.");
581
582 let activated = activate_skill(r#"{"name": "hello-world"}"#);
583 assert!(activated.contains("Greet the user warmly."));
584 assert!(activated.contains("activated"));
585
586 let missing = activate_skill(r#"{"name": "nope"}"#);
587 assert!(missing.contains("no skill named"));
588
589 std::env::set_current_dir(prev).unwrap();
590 let _ = fs::remove_dir_all(&root);
591 }
592
593 #[test]
594 fn skill_tool_description_lists_skills() {
595 let skills = vec![Skill {
596 name: "pdf-processing".to_string(),
597 description: "Extract PDF text".to_string(),
598 license: None,
599 compatibility: None,
600 dir: PathBuf::from("/x/pdf-processing"),
601 }];
602 let desc = skill_tool_description(&skills);
603 assert!(desc.contains("Available skills:"));
604 assert!(desc.contains("- pdf-processing: Extract PDF text"));
605 }
606
607 #[test]
608 fn skill_tool_schema_requires_name() {
609 let schema = skill_tool_schema();
610 assert_eq!(schema["type"], "object");
611 assert_eq!(schema["required"][0], "name");
612 }
613 }