feature/sigit-agent-profile
claude/github-issue-40-0au5ua
claude/linked-issue-acp-tui-u4vvpa
claude/minor-release-cut-vkugow
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/acp-cancel-request
feature/acp-session-list-cursor
feature/acp-v1-conformance
feature/agent-tools-multiedit-glob-todos-remember
feature/cargo-workspace
feature/hooks
feature/load-local-model-explicitly
feature/sigit-agent-profile
feature/sigit-code-cloud
feature/xcode-mcp-server
fix/acp-replay-loaded-session-history
fix/acp-replay-reasoning-and-attachments
fix/dsml-doubled-bar-markers
main
release/v1.5.10
release/v1.5.6
release/v1.6.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 | 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 | } |