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 {} skill(s) for {} (skipped {} native duplicate(s))",
363 synced,
364 provider,
365 skipped
366 );
367 } else {
368 log::info!("Synced {} skill(s) for {}", synced, provider);
369 }
370 } else {
371 let injected = format_skills_for_system_prompt(&skills);
373 match system_prompt {
374 Some(sp) => sp.push_str(&injected),
375 None => *system_prompt = Some(injected),
376 }
377 log::debug!(
378 "Injected {} skill(s) into system prompt for {}",
379 skills.len(),
380 provider
381 );
382 }
383
384 Ok(())
385}
386
387pub fn list_skills() -> Result<Vec<Skill>> {
389 load_all_skills()
390}
391
392pub fn get_skill(name: &str) -> Result<Skill> {
394 let dir = skills_dir().join(name);
395 if !dir.exists() {
396 bail!("Skill '{}' not found at {}", name, dir.display());
397 }
398 parse_skill(&dir)
399}
400
401pub fn add_skill(name: &str, description: &str) -> Result<PathBuf> {
404 let dir = skills_dir().join(name);
405 if dir.exists() {
406 bail!("Skill '{}' already exists at {}", name, dir.display());
407 }
408 fs::create_dir_all(&dir)
409 .with_context(|| format!("Failed to create skill directory {}", dir.display()))?;
410
411 let skill_md = dir.join("SKILL.md");
412 let content = format!(
413 "---\nname: {}\ndescription: {}\n---\n\n# {}\n\nDescribe what this skill does here.\n",
414 name, description, name
415 );
416 fs::write(&skill_md, &content)
417 .with_context(|| format!("Failed to write {}", skill_md.display()))?;
418
419 Ok(dir)
420}
421
422pub fn remove_skill(name: &str) -> Result<()> {
424 let dir = skills_dir().join(name);
425 if !dir.exists() {
426 bail!("Skill '{}' not found at {}", name, dir.display());
427 }
428
429 for provider in &["claude", "gemini", "copilot", "codex"] {
431 if let Some(provider_dir) = provider_skills_dir(provider) {
432 let link = provider_dir.join(format!("{}{}", SKILL_PREFIX, name));
433 if link.symlink_metadata().is_ok() {
434 let _ = fs::remove_file(&link).or_else(|_| remove_symlink_dir(&link));
435 log::debug!("Removed {} symlink: {}", provider, link.display());
436 }
437 }
438 }
439
440 fs::remove_dir_all(&dir)
442 .with_context(|| format!("Failed to remove skill directory {}", dir.display()))?;
443
444 Ok(())
445}
446
447pub fn import_skills(from_provider: &str) -> Result<Vec<String>> {
451 let Some(source_dir) = provider_skills_dir(from_provider) else {
452 bail!(
453 "Provider '{}' does not have a native skill directory",
454 from_provider
455 );
456 };
457
458 if !source_dir.exists() {
459 bail!(
460 "No skill directory found for '{}' at {}",
461 from_provider,
462 source_dir.display()
463 );
464 }
465
466 let dest_dir = skills_dir();
467 fs::create_dir_all(&dest_dir)?;
468
469 let mut imported = Vec::new();
470
471 for entry in fs::read_dir(&source_dir)
472 .with_context(|| format!("Failed to read {}", source_dir.display()))?
473 {
474 let entry = entry?;
475 let path = entry.path();
476 let file_name = entry.file_name();
477 let name = file_name.to_string_lossy();
478
479 if name.starts_with(SKILL_PREFIX) {
481 continue;
482 }
483
484 if !path.is_dir() {
486 continue;
487 }
488
489 if !path.join("SKILL.md").exists() {
491 continue;
492 }
493
494 let dest = dest_dir.join(name.as_ref());
495 if dest.exists() {
496 if read_import_metadata(&dest).is_none()
498 && let Ok(source_hash) = hash_skill_md(&path)
499 {
500 let _ = write_import_metadata(&dest, from_provider, &source_hash);
501 log::info!("Backfilled import metadata for skill '{}'", name);
502 }
503 log::debug!("Skipping '{}': already exists in ~/.zag/skills/", name);
504 continue;
505 }
506
507 copy_dir_all(&path, &dest).with_context(|| format!("Failed to copy skill '{}'", name))?;
508
509 if let Ok(source_hash) = hash_skill_md(&path) {
511 let _ = write_import_metadata(&dest, from_provider, &source_hash);
512 }
513
514 imported.push(name.to_string());
515 }
516
517 Ok(imported)
518}
519
520fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
522 fs::create_dir_all(dst)?;
523 for entry in fs::read_dir(src)? {
524 let entry = entry?;
525 let ty = entry.file_type()?;
526 let dst_path = dst.join(entry.file_name());
527 if ty.is_dir() {
528 copy_dir_all(&entry.path(), &dst_path)?;
529 } else {
530 fs::copy(entry.path(), dst_path)?;
531 }
532 }
533 Ok(())
534}