Skip to main content

bake_agent_context/agent/context/
skill.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use super::installer::{ContextPackage, Installer, markdown_files};
5use bake::{Error, Result};
6use serde::{Deserialize, Serialize};
7use socketry_markdown::{ParseOptions, mdast::Node, to_mdast};
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12const REGISTRY_VERSION: u32 = 1;
13const REGISTRY_FILE: &str = ".agent-context-skills.json";
14
15/// A skill declared by a Markdown file in a dependency's `context/` directory.
16#[derive(Clone, Debug)]
17pub struct Skill {
18    /// Globally unique installed name, prefixed with the provider crate name.
19    pub name: String,
20    pub description: String,
21    pub package: ContextPackage,
22    pub(crate) source_name: String,
23    assets: Option<PathBuf>,
24    body: String,
25}
26
27impl Skill {
28    /// The package selector accepted by `--package`.
29    pub fn package_selector(&self) -> &str {
30        self.package.selector()
31    }
32}
33
34#[derive(Deserialize)]
35#[serde(deny_unknown_fields)]
36struct ContextFrontmatter {
37    #[serde(rename = "type")]
38    document_type: Option<String>,
39    description: Option<String>,
40}
41
42#[derive(Serialize)]
43struct SkillFrontmatter<'a> {
44    name: &'a str,
45    description: &'a str,
46}
47
48#[derive(Clone, Debug, Deserialize, Serialize)]
49struct Registry {
50    version: u32,
51    skills: BTreeMap<String, SkillOwner>,
52}
53
54#[derive(Clone, Debug, Deserialize, Serialize)]
55struct SkillOwner {
56    package: String,
57    version: String,
58}
59
60impl Default for Registry {
61    fn default() -> Self {
62        Self {
63            version: REGISTRY_VERSION,
64            skills: BTreeMap::new(),
65        }
66    }
67}
68
69/// Find context documents marked with `type: skill` in YAML front matter.
70pub fn list_skills(installer: &Installer, package: Option<&str>) -> Result<Vec<Skill>> {
71    let packages = if let Some(selector) = package {
72        let Some(package) = installer.find_package(selector)? else {
73            return Ok(Vec::new());
74        };
75        vec![package]
76    } else {
77        installer.packages().to_vec()
78    };
79
80    let mut skills = Vec::new();
81    for package in packages {
82        skills.extend(list_package_skills(&package)?);
83    }
84
85    skills.sort_by(|left, right| {
86        left.package
87            .name
88            .cmp(&right.package.name)
89            .then_with(|| left.package.version.cmp(&right.package.version))
90            .then_with(|| left.name.cmp(&right.name))
91    });
92    Ok(skills)
93}
94
95pub(crate) fn list_package_skills(package: &ContextPackage) -> Result<Vec<Skill>> {
96    let mut skills = Vec::new();
97    let mut files = markdown_files(&package.context_path)?;
98    files.sort();
99
100    for source in files {
101        let contents = fs::read_to_string(&source)
102            .map_err(|error| Error::new(format!("cannot read {}: {error}", source.display())))?;
103        let mut options = ParseOptions::default();
104        options.constructs.frontmatter = true;
105        let mut document = to_mdast(&contents, &options).map_err(|error| {
106            Error::new(format!("could not parse {}: {error}", source.display()))
107        })?;
108
109        let Some(frontmatter) = context_frontmatter(&document, &source)? else {
110            continue;
111        };
112        let Some(document_type) = frontmatter.document_type else {
113            continue;
114        };
115        if document_type != "skill" {
116            return Err(Error::new(format!(
117                "unsupported context type {document_type:?} in {}; supported type: skill",
118                source.display()
119            )));
120        }
121
122        if source.parent() != Some(package.context_path.as_path()) {
123            return Err(Error::new(format!(
124                "skill document {} must be directly inside context/",
125                source.display()
126            )));
127        }
128
129        let source_name = source
130            .file_stem()
131            .and_then(|stem| stem.to_str())
132            .ok_or_else(|| Error::new(format!("invalid skill filename: {}", source.display())))?
133            .to_owned();
134        validate_skill_name(&source_name)?;
135
136        let package_prefix = package.name.to_ascii_lowercase().replace('_', "-");
137        let name = format!("{package_prefix}-{source_name}");
138        validate_skill_name(&name)?;
139
140        let description = frontmatter
141            .description
142            .map(|description| description.trim().to_owned())
143            .filter(|description| !description.is_empty())
144            .ok_or_else(|| {
145                Error::new(format!(
146                    "skill {} in crate {} requires a non-empty `description`",
147                    name, package.name
148                ))
149            })?;
150        if description.chars().count() > 1024 {
151            return Err(Error::new(format!(
152                "skill description for {name:?} exceeds the 1024 character limit"
153            )));
154        }
155
156        let assets = package.context_path.join(&source_name);
157        let assets = match fs::symlink_metadata(&assets) {
158            Ok(metadata) if metadata.file_type().is_dir() => Some(assets),
159            Ok(_) => {
160                return Err(Error::new(format!(
161                    "skill assets path {} is not a directory",
162                    assets.display()
163                )));
164            }
165            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
166            Err(error) => {
167                return Err(Error::new(format!(
168                    "cannot inspect skill assets {}: {error}",
169                    assets.display()
170                )));
171            }
172        };
173
174        let Some(children) = document.children_mut() else {
175            return Err(Error::new(format!(
176                "{} is not a Markdown document",
177                source.display()
178            )));
179        };
180        if !matches!(children.first(), Some(Node::Yaml(_))) {
181            return Err(Error::new(format!(
182                "skill {} must use YAML front matter delimited by `---`",
183                source.display()
184            )));
185        }
186        children.remove(0);
187        let body = document.to_markdown();
188
189        skills.push(Skill {
190            name,
191            description,
192            package: package.clone(),
193            source_name,
194            assets,
195            body,
196        });
197    }
198
199    Ok(skills)
200}
201
202/// Install skills from all providers, one provider, or one named skill.
203///
204/// If both filters are omitted, all discovered skills are installed. Existing
205/// project-owned skill directories are never replaced; installed dependency
206/// skills are tracked in a registry under `.agents/skills/`.
207pub fn install_skills(
208    installer: &Installer,
209    package_selector: Option<&str>,
210    skill_name: Option<&str>,
211) -> Result<Vec<String>> {
212    let mut skills = list_skills(installer, package_selector)?;
213    if let Some(skill_name) = skill_name {
214        skills.retain(|skill| skill.name == skill_name);
215        if skills.is_empty() {
216            return Err(Error::new(format!(
217                "no dependency skill named {skill_name:?} was found"
218            )));
219        }
220    }
221
222    if let Some(skill_name) = skill_name
223        && skills.len() > 1
224    {
225        let packages = skills
226            .iter()
227            .map(|skill| skill.package_selector())
228            .collect::<Vec<_>>()
229            .join(", ");
230        return Err(Error::new(format!(
231            "skill {skill_name:?} is provided by multiple crates ({packages}); select one with --package"
232        )));
233    }
234
235    let mut selected_names = HashSet::new();
236    for skill in &skills {
237        if !selected_names.insert(skill.name.as_str()) {
238            return Err(Error::new(format!(
239                "multiple selected crates provide skill {:?}; select one with --package",
240                skill.name
241            )));
242        }
243    }
244
245    let selected_package = if let Some(selector) = package_selector {
246        Some(
247            installer
248                .find_package(selector)?
249                .ok_or_else(|| Error::new(format!("no context found for crate {selector:?}")))?,
250        )
251    } else {
252        None
253    };
254    let reconcile_all = package_selector.is_none() && skill_name.is_none();
255    let reconcile_package = if skill_name.is_none() {
256        selected_package
257            .as_ref()
258            .map(|package| package.name.as_str())
259    } else {
260        None
261    };
262
263    let skills_root = installer.root().join(".agents/skills");
264    ensure_directory(&skills_root)?;
265    let registry_path = skills_root.join(REGISTRY_FILE);
266    let mut registry = load_registry(&registry_path)?;
267    let had_registry = path_exists(&registry_path)?;
268
269    let selected_by_name: HashMap<_, _> = skills
270        .iter()
271        .map(|skill| (skill.name.as_str(), skill))
272        .collect();
273
274    let mut destination_exists = HashMap::new();
275    for skill in &skills {
276        if let Some(owner) = registry.skills.get(&skill.name)
277            && owner.package != skill.package.name
278        {
279            return Err(Error::new(format!(
280                "skill {:?} is already installed from crate {:?}; it cannot be replaced by {:?}",
281                skill.name, owner.package, skill.package.name
282            )));
283        }
284
285        let destination = skills_root.join(&skill.name);
286        let exists = path_exists(&destination)?;
287        destination_exists.insert(skill.name.clone(), exists);
288        if exists
289            && registry
290                .skills
291                .get(&skill.name)
292                .is_none_or(|owner| owner.package != skill.package.name)
293        {
294            return Err(Error::new(format!(
295                "skill destination {} already exists and is not managed by Bake Agent Context",
296                destination.display()
297            )));
298        }
299    }
300
301    let stale_skills: Vec<_> = registry
302        .skills
303        .iter()
304        .filter(|(name, owner)| {
305            !selected_by_name.contains_key(name.as_str())
306                && (reconcile_all
307                    || reconcile_package.is_some_and(|package| owner.package == package))
308        })
309        .map(|(name, _)| name.clone())
310        .collect();
311    let mut stale_exists = HashMap::new();
312    for name in &stale_skills {
313        stale_exists.insert(name.clone(), path_exists(&skills_root.join(name))?);
314    }
315
316    for name in &stale_skills {
317        registry.skills.remove(name);
318    }
319    for skill in &skills {
320        registry.skills.insert(
321            skill.name.clone(),
322            SkillOwner {
323                package: skill.package.name.clone(),
324                version: skill.package.version.clone(),
325            },
326        );
327    }
328    let encoded_registry = serde_json::to_vec_pretty(&registry)
329        .map_err(|error| Error::new(format!("cannot encode skill registry: {error}")))?;
330    let exclude_update =
331        super::exclude::prepare(installer.root(), registry.skills.keys().cloned())?;
332
333    let stage = skills_root.join(format!(".agent-context-staging-{}", std::process::id()));
334    fs::create_dir(&stage)
335        .map_err(|error| Error::new(format!("cannot create {}: {error}", stage.display())))?;
336    let new_skills = stage.join("new");
337    let backups = stage.join("backups");
338    if let Err(error) = fs::create_dir(&new_skills).and_then(|_| fs::create_dir(&backups)) {
339        let _ = fs::remove_dir_all(&stage);
340        return Err(Error::new(format!(
341            "cannot prepare {}: {error}",
342            stage.display()
343        )));
344    }
345
346    for skill in &skills {
347        if let Err(error) = write_staged_skill(skill, &new_skills.join(&skill.name)) {
348            let _ = fs::remove_dir_all(&stage);
349            return Err(error);
350        }
351    }
352
353    let mut changes = Vec::new();
354    for skill in &skills {
355        let destination = skills_root.join(&skill.name);
356        let backup = backups.join(&skill.name);
357        let had_previous = destination_exists[&skill.name];
358        if had_previous && let Err(error) = fs::rename(&destination, &backup) {
359            rollback(&skills_root, &backups, &changes);
360            let _ = fs::remove_dir_all(&stage);
361            return Err(Error::new(format!(
362                "cannot move existing skill {}: {error}",
363                destination.display()
364            )));
365        }
366
367        if let Err(error) = fs::rename(new_skills.join(&skill.name), &destination) {
368            if had_previous {
369                let _ = fs::rename(&backup, &destination);
370            }
371            rollback(&skills_root, &backups, &changes);
372            let _ = fs::remove_dir_all(&stage);
373            return Err(Error::new(format!(
374                "cannot install skill {}: {error}",
375                destination.display()
376            )));
377        }
378        changes.push(AppliedChange::Installed {
379            name: skill.name.clone(),
380            had_previous,
381        });
382    }
383
384    for name in &stale_skills {
385        let destination = skills_root.join(name);
386        if stale_exists[name] {
387            if let Err(error) = fs::rename(&destination, backups.join(name)) {
388                rollback(&skills_root, &backups, &changes);
389                let _ = fs::remove_dir_all(&stage);
390                return Err(Error::new(format!(
391                    "cannot remove stale installed skill {}: {error}",
392                    destination.display()
393                )));
394            }
395            changes.push(AppliedChange::Removed { name: name.clone() });
396        }
397    }
398
399    let staged_registry = stage.join("registry.json");
400    if let Err(error) = fs::write(&staged_registry, encoded_registry) {
401        rollback(&skills_root, &backups, &changes);
402        let _ = fs::remove_dir_all(&stage);
403        return Err(Error::new(format!(
404            "cannot write staged skill registry {}: {error}",
405            staged_registry.display()
406        )));
407    }
408
409    if had_registry && let Err(error) = fs::rename(&registry_path, backups.join("registry.json")) {
410        rollback(&skills_root, &backups, &changes);
411        let _ = fs::remove_dir_all(&stage);
412        return Err(Error::new(format!(
413            "cannot move existing skill registry {}: {error}",
414            registry_path.display()
415        )));
416    }
417    if let Err(error) = fs::rename(&staged_registry, &registry_path) {
418        if had_registry {
419            let _ = fs::rename(backups.join("registry.json"), &registry_path);
420        }
421        rollback(&skills_root, &backups, &changes);
422        let _ = fs::remove_dir_all(&stage);
423        return Err(Error::new(format!(
424            "cannot update skill registry {}: {error}",
425            registry_path.display()
426        )));
427    }
428
429    fs::remove_dir_all(&stage)
430        .map_err(|error| Error::new(format!("cannot remove {}: {error}", stage.display())))?;
431    if let Some(exclude_update) = exclude_update {
432        exclude_update.apply()?;
433    }
434
435    Ok(skills
436        .iter()
437        .map(|skill| format!("{} ({})", skill.name, skill.package_selector()))
438        .collect())
439}
440
441pub(crate) fn frontmatter_description(document: &Node, source: &Path) -> Result<Option<String>> {
442    Ok(context_frontmatter(document, source)?
443        .and_then(|frontmatter| frontmatter.description)
444        .map(|description| description.trim().to_owned())
445        .filter(|description| !description.is_empty()))
446}
447
448fn context_frontmatter(document: &Node, source: &Path) -> Result<Option<ContextFrontmatter>> {
449    let Some(children) = document.children() else {
450        return Err(Error::new(format!(
451            "{} is not a Markdown document",
452            source.display()
453        )));
454    };
455    let Some(Node::Yaml(frontmatter)) = children.first() else {
456        return Ok(None);
457    };
458
459    serde_yaml_ng::from_str(&frontmatter.value)
460        .map(Some)
461        .map_err(|error| {
462            Error::new(format!(
463                "invalid YAML front matter in {}: {error}",
464                source.display()
465            ))
466        })
467}
468
469fn validate_skill_name(name: &str) -> Result<()> {
470    let valid = !name.is_empty()
471        && name.len() <= 64
472        && !name.starts_with('-')
473        && !name.ends_with('-')
474        && !name.contains("--")
475        && name
476            .bytes()
477            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
478    if valid {
479        Ok(())
480    } else {
481        Err(Error::new(format!(
482            "invalid skill name {name:?}; use 1–64 lowercase ASCII letters, digits, or single hyphens"
483        )))
484    }
485}
486
487fn write_staged_skill(skill: &Skill, destination: &Path) -> Result<()> {
488    fs::create_dir_all(destination)
489        .map_err(|error| Error::new(format!("cannot create {}: {error}", destination.display())))?;
490
491    if let Some(assets) = &skill.assets {
492        copy_skill_assets(assets, destination, true)?;
493    }
494
495    let metadata = SkillFrontmatter {
496        name: &skill.name,
497        description: &skill.description,
498    };
499    let yaml = serde_yaml_ng::to_string(&metadata)
500        .map_err(|error| Error::new(format!("cannot encode skill front matter: {error}")))?;
501    let mut output = format!("---\n{yaml}---\n\n");
502    output.push_str(&skill.body);
503    if !output.ends_with('\n') {
504        output.push('\n');
505    }
506
507    let skill_file = destination.join("SKILL.md");
508    fs::write(&skill_file, output)
509        .map_err(|error| Error::new(format!("cannot write {}: {error}", skill_file.display())))
510}
511
512fn copy_skill_assets(source: &Path, destination: &Path, top_level: bool) -> Result<()> {
513    let mut entries = fs::read_dir(source)
514        .map_err(|error| Error::new(format!("cannot read {}: {error}", source.display())))?
515        .collect::<std::io::Result<Vec<_>>>()?;
516    entries.sort_by_key(|entry| entry.file_name());
517
518    for entry in entries {
519        let source_path = entry.path();
520        let destination_path = destination.join(entry.file_name());
521        let metadata = fs::symlink_metadata(&source_path).map_err(|error| {
522            Error::new(format!("cannot inspect {}: {error}", source_path.display()))
523        })?;
524        let file_type = metadata.file_type();
525
526        if file_type.is_symlink() {
527            return Err(Error::new(format!(
528                "skill assets cannot contain symbolic links: {}",
529                source_path.display()
530            )));
531        } else if file_type.is_dir() {
532            fs::create_dir(&destination_path).map_err(|error| {
533                Error::new(format!(
534                    "cannot create {}: {error}",
535                    destination_path.display()
536                ))
537            })?;
538            copy_skill_assets(&source_path, &destination_path, false)?;
539        } else if file_type.is_file() {
540            if top_level
541                && entry
542                    .file_name()
543                    .to_string_lossy()
544                    .eq_ignore_ascii_case("SKILL.md")
545            {
546                return Err(Error::new(format!(
547                    "{} is reserved for the generated skill instructions",
548                    source_path.display()
549                )));
550            }
551            fs::copy(&source_path, &destination_path).map_err(|error| {
552                Error::new(format!(
553                    "cannot copy {} to {}: {error}",
554                    source_path.display(),
555                    destination_path.display()
556                ))
557            })?;
558        } else {
559            return Err(Error::new(format!(
560                "unsupported skill asset: {}",
561                source_path.display()
562            )));
563        }
564    }
565    Ok(())
566}
567
568fn ensure_directory(path: &Path) -> Result<()> {
569    match fs::symlink_metadata(path) {
570        Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
571        Ok(_) => Err(Error::new(format!(
572            "skill installation path {} is not a regular directory",
573            path.display()
574        ))),
575        Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir_all(path)
576            .map_err(|error| Error::new(format!("cannot create {}: {error}", path.display()))),
577        Err(error) => Err(Error::new(format!(
578            "cannot inspect {}: {error}",
579            path.display()
580        ))),
581    }
582}
583
584pub(crate) fn installed_skill_names(root: &Path) -> Result<Vec<String>> {
585    let registry_path = root.join(".agents/skills").join(REGISTRY_FILE);
586    let registry = load_registry(&registry_path)?;
587    Ok(registry.skills.keys().cloned().collect())
588}
589
590fn load_registry(path: &Path) -> Result<Registry> {
591    let metadata = match fs::symlink_metadata(path) {
592        Ok(metadata) => metadata,
593        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
594            return Ok(Registry::default());
595        }
596        Err(error) => {
597            return Err(Error::new(format!(
598                "cannot inspect {}: {error}",
599                path.display()
600            )));
601        }
602    };
603    if !metadata.file_type().is_file() {
604        return Err(Error::new(format!(
605            "skill registry {} is not a regular file",
606            path.display()
607        )));
608    }
609
610    let bytes = fs::read(path)
611        .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
612    let registry: Registry = serde_json::from_slice(&bytes).map_err(|error| {
613        Error::new(format!(
614            "invalid skill registry {}: {error}",
615            path.display()
616        ))
617    })?;
618    if registry.version != REGISTRY_VERSION {
619        return Err(Error::new(format!(
620            "unsupported skill registry version {} in {}",
621            registry.version,
622            path.display()
623        )));
624    }
625    for name in registry.skills.keys() {
626        validate_skill_name(name)?;
627    }
628    Ok(registry)
629}
630
631fn path_exists(path: &Path) -> Result<bool> {
632    match fs::symlink_metadata(path) {
633        Ok(_) => Ok(true),
634        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
635        Err(error) => Err(Error::new(format!(
636            "cannot inspect {}: {error}",
637            path.display()
638        ))),
639    }
640}
641
642enum AppliedChange {
643    Installed { name: String, had_previous: bool },
644    Removed { name: String },
645}
646
647fn rollback(root: &Path, backups: &Path, changes: &[AppliedChange]) {
648    for change in changes.iter().rev() {
649        match change {
650            AppliedChange::Installed { name, had_previous } => {
651                let destination = root.join(name);
652                let _ = remove_existing(&destination);
653                if *had_previous {
654                    let _ = fs::rename(backups.join(name), destination);
655                }
656            }
657            AppliedChange::Removed { name } => {
658                let _ = fs::rename(backups.join(name), root.join(name));
659            }
660        }
661    }
662}
663
664fn remove_existing(path: &Path) -> Result<()> {
665    let metadata = match fs::symlink_metadata(path) {
666        Ok(metadata) => metadata,
667        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
668        Err(error) => {
669            return Err(Error::new(format!(
670                "cannot inspect {}: {error}",
671                path.display()
672            )));
673        }
674    };
675    let result = if metadata.file_type().is_dir() {
676        fs::remove_dir_all(path)
677    } else {
678        fs::remove_file(path)
679    };
680    result.map_err(|error| Error::new(format!("cannot remove {}: {error}", path.display())))
681}