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 roots win over user-global ones. A multi-root project has
196 // more than one of them (see `workspace.rs`), ordered primary root first.
197 for dir in crate::workspace::project_dirs() {
198 roots.push(dir.join(".sigit").join("skills"));
199 roots.push(dir.join(".claude").join("skills"));
200 }
201
202 // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
203 if let Some(config_dir) = sigit_config_dir() {
204 roots.push(config_dir.join("skills"));
205 }
206
207 // Shared with the broader Agent Skills ecosystem.
208 if let Some(home) = home_dir() {
209 roots.push(home.join(".claude").join("skills"));
210 }
211
212 roots
213 }
214
215 fn sigit_config_dir() -> Option<PathBuf> {
216 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
217 && !dir.is_empty()
218 {
219 return Some(PathBuf::from(dir));
220 }
221 home_dir().map(|home| home.join(".config").join("sigit"))
222 }
223
224 fn home_dir() -> Option<PathBuf> {
225 std::env::var("HOME").ok().map(PathBuf::from)
226 }
227
228 /// Scan a single root for skill subdirectories, appending newly-seen skills.
229 fn collect_skills_from_root(root: &Path, skills: &mut Vec<Skill>, seen_names: &mut Vec<String>) {
230 let entries = match std::fs::read_dir(root) {
231 Ok(entries) => entries,
232 // Most roots won't exist; that's expected, not an error.
233 Err(_) => return,
234 };
235
236 for entry in entries.flatten() {
237 let dir = entry.path();
238 if !dir.is_dir() {
239 continue;
240 }
241
242 let skill_md = dir.join("SKILL.md");
243 if !skill_md.is_file() {
244 continue;
245 }
246
247 let contents = match std::fs::read_to_string(&skill_md) {
248 Ok(contents) => contents,
249 Err(error) => {
250 log::warn!("skipping skill at {}: {error}", skill_md.display());
251 continue;
252 }
253 };
254
255 let skill = match parse_skill(&contents, &dir) {
256 Ok(skill) => skill,
257 Err(error) => {
258 log::warn!("skipping invalid skill at {}: {error}", skill_md.display());
259 continue;
260 }
261 };
262
263 // First root to define a name wins; later duplicates are ignored.
264 if seen_names.iter().any(|name| name == &skill.name) {
265 log::debug!(
266 "skill \"{}\" at {} shadowed by an earlier definition",
267 skill.name,
268 dir.display()
269 );
270 continue;
271 }
272
273 seen_names.push(skill.name.clone());
274 skills.push(skill);
275 }
276 }
277
278 /// Parse a `SKILL.md` into a [`Skill`], validating the required fields against
279 /// the Agent Skills spec. `dir` is the skill's root directory.
280 fn parse_skill(contents: &str, dir: &Path) -> Result<Skill, String> {
281 let frontmatter = extract_frontmatter(contents)
282 .ok_or_else(|| "missing YAML frontmatter (expected leading `---` block)".to_string())?;
283
284 let fields = parse_frontmatter_fields(frontmatter);
285
286 let name = fields
287 .iter()
288 .find(|(k, _)| k == "name")
289 .map(|(_, v)| v.clone())
290 .ok_or_else(|| "frontmatter is missing required field `name`".to_string())?;
291 validate_name(&name)?;
292
293 let description = fields
294 .iter()
295 .find(|(k, _)| k == "description")
296 .map(|(_, v)| v.clone())
297 .ok_or_else(|| "frontmatter is missing required field `description`".to_string())?;
298 if description.is_empty() {
299 return Err("`description` must not be empty".to_string());
300 }
301 if description.chars().count() > 1024 {
302 return Err("`description` exceeds the 1024-character limit".to_string());
303 }
304
305 // The spec requires `name` to match the parent directory name. Warn but stay
306 // lenient — the frontmatter name is the identity used for activation.
307 if let Some(dir_name) = dir.file_name().and_then(|n| n.to_str())
308 && dir_name != name
309 {
310 log::warn!(
311 "skill name \"{name}\" does not match its directory \"{dir_name}\" at {}",
312 dir.display()
313 );
314 }
315
316 let license = fields
317 .iter()
318 .find(|(k, _)| k == "license")
319 .map(|(_, v)| v.clone())
320 .filter(|v| !v.is_empty());
321 let compatibility = fields
322 .iter()
323 .find(|(k, _)| k == "compatibility")
324 .map(|(_, v)| v.clone())
325 .filter(|v| !v.is_empty());
326
327 Ok(Skill {
328 name,
329 description,
330 license,
331 compatibility,
332 dir: dir.to_path_buf(),
333 })
334 }
335
336 /// Validate the `name` field per the Agent Skills spec: 1-64 chars, lowercase
337 /// alphanumeric and hyphens only, no leading/trailing or consecutive hyphens.
338 pub(crate) fn validate_name(name: &str) -> Result<(), String> {
339 let len = name.chars().count();
340 if len == 0 {
341 return Err("`name` must not be empty".to_string());
342 }
343 if len > 64 {
344 return Err("`name` exceeds the 64-character limit".to_string());
345 }
346 if !name
347 .chars()
348 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
349 {
350 return Err("`name` may only contain lowercase letters, digits, and hyphens".to_string());
351 }
352 if name.starts_with('-') || name.ends_with('-') {
353 return Err("`name` must not start or end with a hyphen".to_string());
354 }
355 if name.contains("--") {
356 return Err("`name` must not contain consecutive hyphens".to_string());
357 }
358 Ok(())
359 }
360
361 /// Read the Markdown body of a `SKILL.md` (everything after the frontmatter),
362 /// falling back to the whole file if no frontmatter delimiter is found.
363 fn read_skill_body(skill_md: &Path) -> Result<String, String> {
364 let contents = std::fs::read_to_string(skill_md).map_err(|e| e.to_string())?;
365 Ok(strip_frontmatter(&contents).trim().to_string())
366 }
367
368 #[cfg(test)]
369 mod tests {
370 use super::*;
371 use std::fs;
372
373 fn unique_dir(name: &str) -> PathBuf {
374 let nanos = std::time::SystemTime::now()
375 .duration_since(std::time::UNIX_EPOCH)
376 .unwrap()
377 .as_nanos();
378 std::env::temp_dir().join(format!("sigit-skills-test-{name}-{nanos}"))
379 }
380
381 #[test]
382 fn validate_name_accepts_valid_names() {
383 assert!(validate_name("pdf-processing").is_ok());
384 assert!(validate_name("data-analysis").is_ok());
385 assert!(validate_name("code-review").is_ok());
386 assert!(validate_name("a").is_ok());
387 assert!(validate_name("skill1").is_ok());
388 }
389
390 #[test]
391 fn validate_name_rejects_invalid_names() {
392 assert!(validate_name("").is_err());
393 assert!(validate_name("PDF-Processing").is_err());
394 assert!(validate_name("-pdf").is_err());
395 assert!(validate_name("pdf-").is_err());
396 assert!(validate_name("pdf--processing").is_err());
397 assert!(validate_name("pdf_processing").is_err());
398 assert!(validate_name(&"a".repeat(65)).is_err());
399 }
400
401 #[test]
402 fn parse_skill_requires_name_and_description() {
403 let dir = Path::new("/tmp/example-skill");
404 assert!(parse_skill("---\ndescription: x\n---\n", dir).is_err());
405 assert!(parse_skill("---\nname: x\n---\n", dir).is_err());
406 let ok = parse_skill(
407 "---\nname: example-skill\ndescription: does things\n---\nbody",
408 dir,
409 );
410 assert!(ok.is_ok());
411 let skill = ok.unwrap();
412 assert_eq!(skill.name, "example-skill");
413 assert_eq!(skill.description, "does things");
414 }
415
416 #[test]
417 fn discover_and_activate_roundtrip() {
418 let _guard = crate::ENV_TEST_LOCK
419 .lock()
420 .unwrap_or_else(|poisoned| poisoned.into_inner());
421 let root = unique_dir("roundtrip");
422 let skills_root = root.join(".sigit").join("skills");
423 let skill_dir = skills_root.join("hello-world");
424 fs::create_dir_all(&skill_dir).unwrap();
425 fs::write(
426 skill_dir.join("SKILL.md"),
427 "---\nname: hello-world\ndescription: Say hello. Use when greeting.\n---\n\nGreet the user warmly.\n",
428 )
429 .unwrap();
430
431 // discover_skills() reads the current directory, so run from `root`.
432 let prev = std::env::current_dir().unwrap();
433 std::env::set_current_dir(&root).unwrap();
434
435 let skills = discover_skills();
436 let found = skills.iter().find(|s| s.name == "hello-world");
437 assert!(found.is_some(), "expected to discover hello-world");
438 assert_eq!(found.unwrap().description, "Say hello. Use when greeting.");
439
440 let activated = activate_skill(r#"{"name": "hello-world"}"#);
441 assert!(activated.contains("Greet the user warmly."));
442 assert!(activated.contains("activated"));
443
444 let missing = activate_skill(r#"{"name": "nope"}"#);
445 assert!(missing.contains("no skill named"));
446
447 std::env::set_current_dir(prev).unwrap();
448 let _ = fs::remove_dir_all(&root);
449 }
450
451 #[test]
452 fn skill_tool_description_lists_skills() {
453 let skills = vec![Skill {
454 name: "pdf-processing".to_string(),
455 description: "Extract PDF text".to_string(),
456 license: None,
457 compatibility: None,
458 dir: PathBuf::from("/x/pdf-processing"),
459 }];
460 let desc = skill_tool_description(&skills);
461 assert!(desc.contains("Available skills:"));
462 assert!(desc.contains("- pdf-processing: Extract PDF text"));
463 }
464
465 #[test]
466 fn skill_tool_schema_requires_name() {
467 let schema = skill_tool_schema();
468 assert_eq!(schema["type"], "object");
469 assert_eq!(schema["required"][0], "name");
470 }
471 }