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}