main
claude/code-feature-parity-q003hm
claude/elegant-carson-l1menh
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/agent-tools-multiedit-glob-todos-remember
feature/background-commands
feature/commit-coauthor-attribution
feature/headless-mode
feature/init-command
feature/load-local-model-explicitly
feature/session-persistence-compaction
feature/sigit-code-cloud
feature/subagent-tool
feature/tool-permission-system
feature/tui-repo-tabs
feature/tui-tabs
main
release/v1.3.1
| 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 | } |