Skip to main content

zag_agent/
skills.rs

1/// Provider-agnostic skill management.
2///
3/// Skills are stored at `~/.zag/skills/<skill-name>/` and symlinked into each
4/// provider's native skill location when running agents.
5///
6/// Providers with native skill support (Claude, Gemini, Copilot) get directory symlinks.
7/// Providers without (Codex, Ollama) get skill content injected into the system prompt.
8#[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/// Metadata written when a skill is imported from a provider.
22#[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
29/// Compute SHA-256 hash of a skill's SKILL.md file.
30pub 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
38/// Write import metadata to a skill directory.
39pub 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
51/// Read import metadata from a skill directory. Returns None if missing or invalid.
52pub 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
58/// Check if a path is a real directory (not a symlink).
59fn 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/// A parsed agent skill.
68#[derive(Debug, Clone, Serialize)]
69pub struct Skill {
70    pub name: String,
71    pub description: String,
72    /// Markdown body (everything after the frontmatter)
73    pub body: String,
74    /// Path to the skill directory
75    pub dir: PathBuf,
76}
77
78#[derive(Debug, Deserialize)]
79struct SkillFrontmatter {
80    name: String,
81    #[serde(default)]
82    description: String,
83}
84
85/// Returns `~/.zag/skills/`.
86pub fn skills_dir() -> PathBuf {
87    dirs::home_dir()
88        .unwrap_or_else(|| PathBuf::from("."))
89        .join(".zag")
90        .join("skills")
91}
92
93/// Returns the provider's native skill directory, or `None` if the provider has no native support.
94///
95/// - Claude: `~/.claude/skills/`
96/// - Gemini: `~/.gemini/skills/` (also supports `~/.agents/skills/`)
97/// - Copilot: `~/.copilot/skills/`
98/// - Codex: `~/.agents/skills/` (agentskills.io standard)
99pub 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
110/// Parse a skill from its directory. Reads `<dir>/SKILL.md`.
111pub 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
133/// Split YAML frontmatter from markdown body.
134/// Returns `(frontmatter, body)`. Frontmatter is between the first two `---` lines.
135fn 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
153/// Load all skills from `~/.zag/skills/`.
154/// Silently skips directories without a valid `SKILL.md`.
155pub 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
182/// Sync skills for a provider that supports native skills (Claude, Gemini, Copilot).
183/// Creates `<provider_skills_dir>/zag-<name>` → `~/.zag/skills/<name>` symlinks.
184/// Removes stale symlinks for skills that no longer exist.
185/// Returns the number of skills skipped because the provider already has them natively.
186pub 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    // Create/update symlinks for current skills
200    let mut skipped = 0usize;
201    for skill in skills {
202        // Skip if the provider already has this skill natively (not via our symlink)
203        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        // Remove existing entry if it's wrong or stale
225        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                // Real directory — don't touch it
235                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    // Remove stale symlinks (our zag-* prefixed ones whose source no longer exists)
264    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            // Only touch symlinks we created
278            if path.symlink_metadata().is_err() {
279                continue;
280            }
281            if path.is_dir() && path.symlink_metadata().is_ok() {
282                // It's a symlink (to a dir)
283            } 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            // Remove if the skill no longer exists OR if the provider now has it natively
293            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    // On Unix, symlinks to directories are removed with remove_file
318    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
326/// Format skills as system prompt content (for providers without native skill support).
327pub 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
346/// Orchestrate skill setup for the given provider.
347///
348/// - Providers with native skills (claude, gemini, copilot): create directory symlinks.
349/// - Providers without (codex, ollama): append skill content to system_prompt.
350pub 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        // Native skills support — symlink skill directories
358        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        // No native skills — inject into system prompt
369        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
384/// List all skills (alias for load_all_skills, used by the subcommand).
385pub fn list_skills() -> Result<Vec<Skill>> {
386    load_all_skills()
387}
388
389/// Get a single skill by name from `~/.zag/skills/<name>/`.
390pub 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
398/// Create a new skill skeleton at `~/.zag/skills/<name>/SKILL.md`.
399/// Returns the path to the new skill directory.
400pub 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
418/// Remove a skill and all its provider symlinks.
419pub 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    // Remove provider symlinks first
426    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    // Remove the skill directory
437    fs::remove_dir_all(&dir)
438        .with_context(|| format!("Failed to remove skill directory {}", dir.display()))?;
439
440    Ok(())
441}
442
443/// Import skills from a provider's native skill directory into `~/.zag/skills/`.
444/// Skips directories already prefixed with `agent-` (our own symlinks).
445/// Returns names of imported skills.
446pub 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        // Skip our own zag-* symlinks
473        if name.starts_with(SKILL_PREFIX) {
474            continue;
475        }
476
477        // Only handle directories
478        if !path.is_dir() {
479            continue;
480        }
481
482        // Skip if SKILL.md is missing
483        if !path.join("SKILL.md").exists() {
484            continue;
485        }
486
487        let dest = dest_dir.join(name.as_ref());
488        if dest.exists() {
489            // Backfill metadata for previously imported skills that lack it
490            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        // Write import metadata with hash of the source SKILL.md
503        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
513/// Recursively copy a directory.
514fn 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}