1#[cfg(test)]
9#[path = "skills_tests.rs"]
10mod tests;
11
12use anyhow::{Context, Result, bail};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use std::fs;
16use std::path::{Path, PathBuf};
17
18const SKILL_PREFIX: &str = "zag-";
19const IMPORT_METADATA_FILE: &str = ".import-metadata.json";
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ImportMetadata {
24 pub source_provider: String,
25 pub source_hash: String,
26 pub imported_at: String,
27}
28
29pub fn hash_skill_md(skill_dir: &Path) -> Result<String> {
31 let content = fs::read(skill_dir.join("SKILL.md"))
32 .with_context(|| format!("Failed to read SKILL.md in {}", skill_dir.display()))?;
33 let mut hasher = Sha256::new();
34 hasher.update(&content);
35 Ok(hex::encode(hasher.finalize()))
36}
37
38pub fn write_import_metadata(skill_dir: &Path, provider: &str, hash: &str) -> Result<()> {
40 let meta = ImportMetadata {
41 source_provider: provider.to_string(),
42 source_hash: hash.to_string(),
43 imported_at: chrono::Utc::now().to_rfc3339(),
44 };
45 let path = skill_dir.join(IMPORT_METADATA_FILE);
46 let json = serde_json::to_string_pretty(&meta)?;
47 fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?;
48 Ok(())
49}
50
51pub fn read_import_metadata(skill_dir: &Path) -> Option<ImportMetadata> {
53 let path = skill_dir.join(IMPORT_METADATA_FILE);
54 let content = fs::read_to_string(&path).ok()?;
55 serde_json::from_str(&content).ok()
56}
57
58fn is_real_dir(path: &Path) -> bool {
60 path.exists()
61 && !path
62 .symlink_metadata()
63 .map(|m| m.file_type().is_symlink())
64 .unwrap_or(false)
65}
66
67#[derive(Debug, Clone, Serialize)]
69pub struct Skill {
70 pub name: String,
71 pub description: String,
72 pub body: String,
74 pub dir: PathBuf,
76}
77
78#[derive(Debug, Deserialize)]
79struct SkillFrontmatter {
80 name: String,
81 #[serde(default)]
82 description: String,
83}
84
85pub fn skills_dir() -> PathBuf {
87 dirs::home_dir()
88 .unwrap_or_else(|| PathBuf::from("."))
89 .join(".zag")
90 .join("skills")
91}
92
93pub fn provider_skills_dir(provider: &str) -> Option<PathBuf> {
100 let home = dirs::home_dir()?;
101 match provider {
102 "claude" => Some(home.join(".claude").join("skills")),
103 "gemini" => Some(home.join(".gemini").join("skills")),
104 "copilot" => Some(home.join(".copilot").join("skills")),
105 "codex" => Some(home.join(".agents").join("skills")),
106 _ => None,
107 }
108}
109
110pub fn parse_skill(dir: &Path) -> Result<Skill> {
112 let skill_file = dir.join("SKILL.md");
113 let content = fs::read_to_string(&skill_file)
114 .with_context(|| format!("Failed to read {}", skill_file.display()))?;
115
116 let (frontmatter, body) = split_frontmatter(&content)?;
117
118 let meta: SkillFrontmatter = serde_yaml::from_str(&frontmatter).with_context(|| {
119 format!(
120 "Failed to parse YAML frontmatter in {}",
121 skill_file.display()
122 )
123 })?;
124
125 Ok(Skill {
126 name: meta.name,
127 description: meta.description,
128 body: body.trim().to_string(),
129 dir: dir.to_path_buf(),
130 })
131}
132
133fn split_frontmatter(content: &str) -> Result<(String, String)> {
136 let lines: Vec<&str> = content.lines().collect();
137 if lines.is_empty() || lines[0].trim() != "---" {
138 bail!("SKILL.md must start with --- (YAML frontmatter)");
139 }
140
141 let end = lines
142 .iter()
143 .skip(1)
144 .position(|l| l.trim() == "---")
145 .context("SKILL.md frontmatter not closed with ---")?;
146
147 let frontmatter = lines[1..=end].join("\n");
148 let body = lines[end + 2..].join("\n");
149
150 Ok((frontmatter, body))
151}
152
153pub fn load_all_skills() -> Result<Vec<Skill>> {
156 let dir = skills_dir();
157 if !dir.exists() {
158 return Ok(Vec::new());
159 }
160
161 let mut skills = Vec::new();
162 for entry in fs::read_dir(&dir)
163 .with_context(|| format!("Failed to read skills directory {}", dir.display()))?
164 {
165 let entry = entry?;
166 let path = entry.path();
167 if !path.is_dir() {
168 continue;
169 }
170 match parse_skill(&path) {
171 Ok(skill) => skills.push(skill),
172 Err(e) => {
173 log::warn!("Skipping skill at {}: {}", path.display(), e);
174 }
175 }
176 }
177
178 skills.sort_by(|a, b| a.name.cmp(&b.name));
179 Ok(skills)
180}
181
182pub fn sync_skills_for_provider(provider: &str, skills: &[Skill]) -> Result<usize> {
187 let Some(target_dir) = provider_skills_dir(provider) else {
188 return Ok(0);
189 };
190
191 fs::create_dir_all(&target_dir).with_context(|| {
192 format!(
193 "Failed to create {} skills directory {}",
194 provider,
195 target_dir.display()
196 )
197 })?;
198
199 let mut skipped = 0usize;
201 for skill in skills {
202 let native_path = target_dir.join(&skill.name);
204 if is_real_dir(&native_path) {
205 let native_hash = hash_skill_md(&native_path).ok();
206 let import_meta = read_import_metadata(&skill.dir);
207 if let (Some(nh), Some(meta)) = (&native_hash, &import_meta)
208 && *nh != meta.source_hash
209 {
210 log::warn!(
211 "Skill '{}' has diverged from native {} version",
212 skill.name,
213 provider
214 );
215 }
216 skipped += 1;
217 continue;
218 }
219
220 let link_name = format!("{}{}", SKILL_PREFIX, skill.name);
221 let link_path = target_dir.join(&link_name);
222 let target = &skill.dir;
223
224 if link_path.exists() || link_path.symlink_metadata().is_ok() {
226 let is_correct_symlink = link_path.symlink_metadata().is_ok()
227 && fs::read_link(&link_path)
228 .map(|t| t == *target)
229 .unwrap_or(false);
230 if is_correct_symlink {
231 continue;
232 }
233 if link_path.is_dir() && link_path.symlink_metadata().is_err() {
234 log::warn!(
236 "Skipping {}: a real directory already exists there",
237 link_path.display()
238 );
239 continue;
240 }
241 fs::remove_file(&link_path)
242 .or_else(|_| remove_symlink_dir(&link_path))
243 .with_context(|| format!("Failed to remove stale entry {}", link_path.display()))?;
244 }
245
246 create_symlink_dir(target, &link_path).with_context(|| {
247 format!(
248 "Failed to create symlink {} -> {}",
249 link_path.display(),
250 target.display()
251 )
252 })?;
253
254 log::debug!(
255 "Linked skill '{}' for {}: {} -> {}",
256 skill.name,
257 provider,
258 link_path.display(),
259 target.display()
260 );
261 }
262
263 let skill_names: std::collections::HashSet<String> =
265 skills.iter().map(|s| s.name.clone()).collect();
266
267 if let Ok(entries) = fs::read_dir(&target_dir) {
268 for entry in entries.flatten() {
269 let path = entry.path();
270 let file_name = entry.file_name();
271 let name = file_name.to_string_lossy();
272
273 if !name.starts_with(SKILL_PREFIX) {
274 continue;
275 }
276
277 if path.symlink_metadata().is_err() {
279 continue;
280 }
281 if path.is_dir() && path.symlink_metadata().is_ok() {
282 } else if !path
284 .symlink_metadata()
285 .map(|m| m.file_type().is_symlink())
286 .unwrap_or(false)
287 {
288 continue;
289 }
290
291 let skill_name = name.trim_start_matches(SKILL_PREFIX);
292 let should_remove =
294 !skill_names.contains(skill_name) || is_real_dir(&target_dir.join(skill_name));
295 if should_remove {
296 let _ = fs::remove_file(&path).or_else(|_| remove_symlink_dir(&path));
297 log::debug!("Removed stale skill symlink: {}", path.display());
298 }
299 }
300 }
301
302 Ok(skipped)
303}
304
305#[cfg(unix)]
306fn create_symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
307 std::os::unix::fs::symlink(target, link)
308}
309
310#[cfg(not(unix))]
311fn create_symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
312 std::os::windows::fs::symlink_dir(target, link)
313}
314
315#[cfg(unix)]
316fn remove_symlink_dir(path: &Path) -> std::io::Result<()> {
317 fs::remove_file(path)
319}
320
321#[cfg(not(unix))]
322fn remove_symlink_dir(path: &Path) -> std::io::Result<()> {
323 fs::remove_dir(path)
324}
325
326pub fn format_skills_for_system_prompt(skills: &[Skill]) -> String {
328 if skills.is_empty() {
329 return String::new();
330 }
331
332 let mut out = String::from("\n\n## Agent Skills\n\nThe following skills are available:\n");
333 for skill in skills {
334 out.push_str(&format!("\n### Skill: {}\n", skill.name));
335 if !skill.description.is_empty() {
336 out.push_str(&format!("_{}_\n\n", skill.description));
337 }
338 if !skill.body.is_empty() {
339 out.push_str(&skill.body);
340 out.push('\n');
341 }
342 }
343 out
344}
345
346pub fn setup_skills(provider: &str, system_prompt: &mut Option<String>) -> Result<()> {
351 let skills = load_all_skills()?;
352 if skills.is_empty() {
353 return Ok(());
354 }
355
356 if provider_skills_dir(provider).is_some() {
357 let skipped = sync_skills_for_provider(provider, &skills)?;
359 let synced = skills.len() - skipped;
360 if skipped > 0 {
361 log::info!(
362 "Synced {synced} skill(s) for {provider} (skipped {skipped} native duplicate(s))"
363 );
364 } else {
365 log::info!("Synced {synced} skill(s) for {provider}");
366 }
367 } else {
368 let injected = format_skills_for_system_prompt(&skills);
370 match system_prompt {
371 Some(sp) => sp.push_str(&injected),
372 None => *system_prompt = Some(injected),
373 }
374 log::debug!(
375 "Injected {} skill(s) into system prompt for {}",
376 skills.len(),
377 provider
378 );
379 }
380
381 Ok(())
382}
383
384pub fn list_skills() -> Result<Vec<Skill>> {
386 load_all_skills()
387}
388
389pub fn get_skill(name: &str) -> Result<Skill> {
391 let dir = skills_dir().join(name);
392 if !dir.exists() {
393 bail!("Skill '{}' not found at {}", name, dir.display());
394 }
395 parse_skill(&dir)
396}
397
398pub fn add_skill(name: &str, description: &str) -> Result<PathBuf> {
401 let dir = skills_dir().join(name);
402 if dir.exists() {
403 bail!("Skill '{}' already exists at {}", name, dir.display());
404 }
405 fs::create_dir_all(&dir)
406 .with_context(|| format!("Failed to create skill directory {}", dir.display()))?;
407
408 let skill_md = dir.join("SKILL.md");
409 let content = format!(
410 "---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n\nDescribe what this skill does here.\n"
411 );
412 fs::write(&skill_md, &content)
413 .with_context(|| format!("Failed to write {}", skill_md.display()))?;
414
415 Ok(dir)
416}
417
418pub fn remove_skill(name: &str) -> Result<()> {
420 let dir = skills_dir().join(name);
421 if !dir.exists() {
422 bail!("Skill '{}' not found at {}", name, dir.display());
423 }
424
425 for provider in &["claude", "gemini", "copilot", "codex"] {
427 if let Some(provider_dir) = provider_skills_dir(provider) {
428 let link = provider_dir.join(format!("{SKILL_PREFIX}{name}"));
429 if link.symlink_metadata().is_ok() {
430 let _ = fs::remove_file(&link).or_else(|_| remove_symlink_dir(&link));
431 log::debug!("Removed {} symlink: {}", provider, link.display());
432 }
433 }
434 }
435
436 fs::remove_dir_all(&dir)
438 .with_context(|| format!("Failed to remove skill directory {}", dir.display()))?;
439
440 Ok(())
441}
442
443pub fn import_skills(from_provider: &str) -> Result<Vec<String>> {
447 let Some(source_dir) = provider_skills_dir(from_provider) else {
448 bail!("Provider '{from_provider}' does not have a native skill directory");
449 };
450
451 if !source_dir.exists() {
452 bail!(
453 "No skill directory found for '{}' at {}",
454 from_provider,
455 source_dir.display()
456 );
457 }
458
459 let dest_dir = skills_dir();
460 fs::create_dir_all(&dest_dir)?;
461
462 let mut imported = Vec::new();
463
464 for entry in fs::read_dir(&source_dir)
465 .with_context(|| format!("Failed to read {}", source_dir.display()))?
466 {
467 let entry = entry?;
468 let path = entry.path();
469 let file_name = entry.file_name();
470 let name = file_name.to_string_lossy();
471
472 if name.starts_with(SKILL_PREFIX) {
474 continue;
475 }
476
477 if !path.is_dir() {
479 continue;
480 }
481
482 if !path.join("SKILL.md").exists() {
484 continue;
485 }
486
487 let dest = dest_dir.join(name.as_ref());
488 if dest.exists() {
489 if read_import_metadata(&dest).is_none()
491 && let Ok(source_hash) = hash_skill_md(&path)
492 {
493 let _ = write_import_metadata(&dest, from_provider, &source_hash);
494 log::info!("Backfilled import metadata for skill '{name}'");
495 }
496 log::debug!("Skipping '{name}': already exists in ~/.zag/skills/");
497 continue;
498 }
499
500 copy_dir_all(&path, &dest).with_context(|| format!("Failed to copy skill '{name}'"))?;
501
502 if let Ok(source_hash) = hash_skill_md(&path) {
504 let _ = write_import_metadata(&dest, from_provider, &source_hash);
505 }
506
507 imported.push(name.to_string());
508 }
509
510 Ok(imported)
511}
512
513fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
515 fs::create_dir_all(dst)?;
516 for entry in fs::read_dir(src)? {
517 let entry = entry?;
518 let ty = entry.file_type()?;
519 let dst_path = dst.join(entry.file_name());
520 if ty.is_dir() {
521 copy_dir_all(&entry.path(), &dst_path)?;
522 } else {
523 fs::copy(entry.path(), dst_path)?;
524 }
525 }
526 Ok(())
527}