Skip to main content

sac/skills/
mod.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use anyhow::{anyhow, Context, Result};
7use serde::Deserialize;
8use serde_json::{json, Value};
9
10use crate::paths::sac_home_dir;
11use crate::sandbox::{MountSpec, SandboxSession};
12use crate::tools::{require_str, ToolResult, ToolRuntime};
13use crate::types::{FunctionDef, ToolDefinition};
14
15const SKILL_FILENAME: &str = "SKILL.md";
16const MAX_SCAN_DEPTH: usize = 6;
17const MAX_SCAN_DIRS: usize = 2_000;
18const MAX_RESOURCE_ENTRIES: usize = 64;
19const PROJECT_SAC_SKILLS_GUEST_ROOT: &str = "/sac/skills/project/sac";
20const PROJECT_AGENTS_SKILLS_GUEST_ROOT: &str = "/sac/skills/project/agents";
21const USER_SAC_HOME_SKILLS_GUEST_ROOT: &str = "/sac/skills/user/sac-home";
22const USER_AGENTS_HOME_SKILLS_GUEST_ROOT: &str = "/sac/skills/user/agents-home";
23
24mod discovery;
25mod frontmatter;
26mod registry;
27mod resources;
28mod tool;
29
30pub use registry::SkillRegistry;
31pub use tool::{auto_mounts, execute_activate_skill};
32
33use discovery::*;
34use frontmatter::*;
35use resources::*;
36
37#[derive(Clone, Debug)]
38pub struct SkillRecord {
39    pub name: String,
40    pub description: String,
41    pub compatibility: Option<String>,
42    pub skill_md_path: PathBuf,
43    pub skill_root_host: PathBuf,
44    pub skill_root_visible: PathBuf,
45    pub body: String,
46    pub resources: Vec<String>,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct SkillCatalogEntry {
51    pub name: String,
52    pub description: String,
53    pub compatibility: Option<String>,
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use crate::sandbox::{SandboxSpec, DEFAULT_SANDBOX_IMAGE, DEFAULT_SANDBOX_WORKDIR};
60    use crate::test_env_lock;
61    use std::time::{SystemTime, UNIX_EPOCH};
62
63    fn temp_dir(label: &str) -> PathBuf {
64        let unique = SystemTime::now()
65            .duration_since(UNIX_EPOCH)
66            .unwrap()
67            .as_nanos();
68        let path = std::env::temp_dir().join(format!("sac_skills_test_{}_{}", label, unique));
69        fs::create_dir_all(&path).unwrap();
70        path
71    }
72
73    fn write_skill(root: &Path, name: &str, description: &str, body: &str) -> PathBuf {
74        let dir = root.join(name);
75        fs::create_dir_all(&dir).unwrap();
76        fs::write(
77            dir.join(SKILL_FILENAME),
78            format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"),
79        )
80        .unwrap();
81        dir
82    }
83
84    #[test]
85    fn project_sources_override_user_sources() {
86        let _guard = test_env_lock();
87        let root = temp_dir("precedence");
88        let repo = root.join("repo");
89        fs::create_dir_all(repo.join(".git")).unwrap();
90        let project_skills = repo.join(".sac/skills");
91        let agents_skills = repo.join(".agents/skills");
92        let user_skills = root.join("home/.config/sac/skills");
93        fs::create_dir_all(&project_skills).unwrap();
94        fs::create_dir_all(&agents_skills).unwrap();
95        fs::create_dir_all(&user_skills).unwrap();
96
97        write_skill(&user_skills, "build", "user", "user body");
98        write_skill(
99            &agents_skills,
100            "build",
101            "project agents",
102            "project agents body",
103        );
104        write_skill(&project_skills, "build", "project sac", "project sac body");
105
106        let previous_sac_home = std::env::var_os("SAC_HOME");
107        unsafe {
108            std::env::set_var("SAC_HOME", root.join("home/.config/sac"));
109        }
110
111        let registry = SkillRegistry::load(Some(&repo), None).unwrap().unwrap();
112        match previous_sac_home {
113            Some(value) => unsafe { std::env::set_var("SAC_HOME", value) },
114            None => unsafe { std::env::remove_var("SAC_HOME") },
115        }
116        let entry = registry
117            .catalog_entries()
118            .into_iter()
119            .find(|entry| entry.name == "build")
120            .unwrap();
121        assert_eq!(entry.description, "project sac");
122        let activated = registry.activate("build", false);
123        assert!(activated.content.contains("project sac body"));
124    }
125
126    #[test]
127    fn missing_description_skips_skill() {
128        let _guard = test_env_lock();
129        let root = temp_dir("missing_desc");
130        let skill_root = root.join("repo/.agents/skills/foo");
131        let sac_home = root.join("home/.config/sac");
132        let home = root.join("home");
133        fs::create_dir_all(&skill_root).unwrap();
134        fs::create_dir_all(&sac_home).unwrap();
135        fs::create_dir_all(&home).unwrap();
136        fs::create_dir_all(root.join("repo/.git")).unwrap();
137        fs::write(
138            skill_root.join(SKILL_FILENAME),
139            "---\nname: foo\n---\n\nbody\n",
140        )
141        .unwrap();
142
143        let previous_sac_home = std::env::var_os("SAC_HOME");
144        let previous_home = std::env::var_os("HOME");
145        unsafe {
146            std::env::set_var("SAC_HOME", &sac_home);
147            std::env::set_var("HOME", &home);
148        }
149
150        let registry = SkillRegistry::load(Some(&root.join("repo")), None).unwrap();
151
152        match previous_sac_home {
153            Some(value) => unsafe { std::env::set_var("SAC_HOME", value) },
154            None => unsafe { std::env::remove_var("SAC_HOME") },
155        }
156        match previous_home {
157            Some(value) => unsafe { std::env::set_var("HOME", value) },
158            None => unsafe { std::env::remove_var("HOME") },
159        }
160
161        assert!(registry.is_none());
162    }
163
164    #[test]
165    fn activation_uses_guest_path_when_sandboxed() {
166        let root = temp_dir("sandboxed_path");
167        let repo = root.join("repo");
168        fs::create_dir_all(repo.join(".git")).unwrap();
169        let project_skills = repo.join(".agents/skills");
170        fs::create_dir_all(&project_skills).unwrap();
171        let skill_dir = write_skill(&project_skills, "lint", "lint code", "body");
172
173        let sandbox = SandboxSession::new_for_test(SandboxSpec {
174            image: DEFAULT_SANDBOX_IMAGE.to_string(),
175            mounts: vec![
176                MountSpec {
177                    host: repo.clone(),
178                    guest: PathBuf::from(DEFAULT_SANDBOX_WORKDIR),
179                    read_only: false,
180                },
181                MountSpec {
182                    host: project_skills.clone(),
183                    guest: PathBuf::from(PROJECT_AGENTS_SKILLS_GUEST_ROOT),
184                    read_only: true,
185                },
186            ],
187            workdir: PathBuf::from(DEFAULT_SANDBOX_WORKDIR),
188            gpu_devices: Vec::new(),
189            shm_size: Some("0".to_string()),
190        });
191
192        let registry = SkillRegistry::load(Some(&repo), Some(&sandbox))
193            .unwrap()
194            .unwrap();
195        let activated = registry.activate("lint", false);
196        assert!(
197            activated.content.contains("/workspace/.agents/skills/lint")
198                || activated
199                    .content
200                    .contains(&format!("{}/lint", PROJECT_AGENTS_SKILLS_GUEST_ROOT))
201        );
202        assert!(activated.content.contains("body"));
203        assert_eq!(skill_dir, project_skills.join("lint"));
204    }
205
206    #[test]
207    fn auto_mounts_skip_paths_already_covered_by_workspace_mount() {
208        let _guard = test_env_lock();
209        let root = temp_dir("auto_mounts_covered");
210        let repo = root.join("repo");
211        let sac_home = root.join("home/.config/sac");
212        let home = root.join("home");
213        fs::create_dir_all(repo.join(".git")).unwrap();
214        fs::create_dir_all(repo.join(".agents/skills")).unwrap();
215        fs::create_dir_all(&sac_home).unwrap();
216        fs::create_dir_all(&home).unwrap();
217
218        let previous_sac_home = std::env::var_os("SAC_HOME");
219        let previous_home = std::env::var_os("HOME");
220        unsafe {
221            std::env::set_var("SAC_HOME", &sac_home);
222            std::env::set_var("HOME", &home);
223        }
224
225        let mounts = auto_mounts(
226            &repo,
227            &[MountSpec {
228                host: repo.clone(),
229                guest: PathBuf::from(DEFAULT_SANDBOX_WORKDIR),
230                read_only: false,
231            }],
232        )
233        .unwrap();
234
235        match previous_sac_home {
236            Some(value) => unsafe { std::env::set_var("SAC_HOME", value) },
237            None => unsafe { std::env::remove_var("SAC_HOME") },
238        }
239        match previous_home {
240            Some(value) => unsafe { std::env::set_var("HOME", value) },
241            None => unsafe { std::env::remove_var("HOME") },
242        }
243
244        assert!(mounts.is_empty());
245    }
246
247    #[test]
248    fn repair_frontmatter_handles_unquoted_colons() {
249        let frontmatter = "name: lint\ndescription: Use when handling foo:bar tasks\n";
250        let parsed = parse_frontmatter(frontmatter).unwrap();
251        assert_eq!(
252            parsed.description.as_deref(),
253            Some("Use when handling foo:bar tasks")
254        );
255    }
256
257    #[test]
258    fn repeated_activation_returns_short_notice() {
259        let root = temp_dir("activation_dedupe");
260        let repo = root.join("repo");
261        fs::create_dir_all(repo.join(".git")).unwrap();
262        let project_skills = repo.join(".agents/skills");
263        fs::create_dir_all(&project_skills).unwrap();
264        write_skill(&project_skills, "lint", "lint code", "full body");
265
266        let registry = SkillRegistry::load(Some(&repo), None).unwrap().unwrap();
267        let first = registry.activate("lint", false);
268        let second = registry.activate("lint", true);
269
270        assert!(first.content.contains("full body"));
271        assert!(second.content.contains("already active"));
272        assert!(!second.content.contains("full body"));
273    }
274}