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 {} 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        // No native skills — inject into system prompt
372        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
387/// List all skills (alias for load_all_skills, used by the subcommand).
388pub fn list_skills() -> Result<Vec<Skill>> {
389    load_all_skills()
390}
391
392/// Get a single skill by name from `~/.zag/skills/<name>/`.
393pub 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
401/// Create a new skill skeleton at `~/.zag/skills/<name>/SKILL.md`.
402/// Returns the path to the new skill directory.
403pub 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
422/// Remove a skill and all its provider symlinks.
423pub 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    // Remove provider symlinks first
430    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    // Remove the skill directory
441    fs::remove_dir_all(&dir)
442        .with_context(|| format!("Failed to remove skill directory {}", dir.display()))?;
443
444    Ok(())
445}
446
447/// Import skills from a provider's native skill directory into `~/.zag/skills/`.
448/// Skips directories already prefixed with `agent-` (our own symlinks).
449/// Returns names of imported skills.
450pub 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        // Skip our own zag-* symlinks
480        if name.starts_with(SKILL_PREFIX) {
481            continue;
482        }
483
484        // Only handle directories
485        if !path.is_dir() {
486            continue;
487        }
488
489        // Skip if SKILL.md is missing
490        if !path.join("SKILL.md").exists() {
491            continue;
492        }
493
494        let dest = dest_dir.join(name.as_ref());
495        if dest.exists() {
496            // Backfill metadata for previously imported skills that lack it
497            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        // Write import metadata with hash of the source SKILL.md
510        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
520/// Recursively copy a directory.
521fn 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}