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
331    let stage = skills_root.join(format!(".agent-context-staging-{}", std::process::id()));
332    fs::create_dir(&stage)
333        .map_err(|error| Error::new(format!("cannot create {}: {error}", stage.display())))?;
334    let new_skills = stage.join("new");
335    let backups = stage.join("backups");
336    if let Err(error) = fs::create_dir(&new_skills).and_then(|_| fs::create_dir(&backups)) {
337        let _ = fs::remove_dir_all(&stage);
338        return Err(Error::new(format!(
339            "cannot prepare {}: {error}",
340            stage.display()
341        )));
342    }
343
344    for skill in &skills {
345        if let Err(error) = write_staged_skill(skill, &new_skills.join(&skill.name)) {
346            let _ = fs::remove_dir_all(&stage);
347            return Err(error);
348        }
349    }
350
351    let mut changes = Vec::new();
352    for skill in &skills {
353        let destination = skills_root.join(&skill.name);
354        let backup = backups.join(&skill.name);
355        let had_previous = destination_exists[&skill.name];
356        if had_previous && let Err(error) = fs::rename(&destination, &backup) {
357            rollback(&skills_root, &backups, &changes);
358            let _ = fs::remove_dir_all(&stage);
359            return Err(Error::new(format!(
360                "cannot move existing skill {}: {error}",
361                destination.display()
362            )));
363        }
364
365        if let Err(error) = fs::rename(new_skills.join(&skill.name), &destination) {
366            if had_previous {
367                let _ = fs::rename(&backup, &destination);
368            }
369            rollback(&skills_root, &backups, &changes);
370            let _ = fs::remove_dir_all(&stage);
371            return Err(Error::new(format!(
372                "cannot install skill {}: {error}",
373                destination.display()
374            )));
375        }
376        changes.push(AppliedChange::Installed {
377            name: skill.name.clone(),
378            had_previous,
379        });
380    }
381
382    for name in &stale_skills {
383        let destination = skills_root.join(name);
384        if stale_exists[name] {
385            if let Err(error) = fs::rename(&destination, backups.join(name)) {
386                rollback(&skills_root, &backups, &changes);
387                let _ = fs::remove_dir_all(&stage);
388                return Err(Error::new(format!(
389                    "cannot remove stale installed skill {}: {error}",
390                    destination.display()
391                )));
392            }
393            changes.push(AppliedChange::Removed { name: name.clone() });
394        }
395    }
396
397    let staged_registry = stage.join("registry.json");
398    if let Err(error) = fs::write(&staged_registry, encoded_registry) {
399        rollback(&skills_root, &backups, &changes);
400        let _ = fs::remove_dir_all(&stage);
401        return Err(Error::new(format!(
402            "cannot write staged skill registry {}: {error}",
403            staged_registry.display()
404        )));
405    }
406
407    if had_registry && let Err(error) = fs::rename(&registry_path, backups.join("registry.json")) {
408        rollback(&skills_root, &backups, &changes);
409        let _ = fs::remove_dir_all(&stage);
410        return Err(Error::new(format!(
411            "cannot move existing skill registry {}: {error}",
412            registry_path.display()
413        )));
414    }
415    if let Err(error) = fs::rename(&staged_registry, &registry_path) {
416        if had_registry {
417            let _ = fs::rename(backups.join("registry.json"), &registry_path);
418        }
419        rollback(&skills_root, &backups, &changes);
420        let _ = fs::remove_dir_all(&stage);
421        return Err(Error::new(format!(
422            "cannot update skill registry {}: {error}",
423            registry_path.display()
424        )));
425    }
426
427    fs::remove_dir_all(&stage)
428        .map_err(|error| Error::new(format!("cannot remove {}: {error}", stage.display())))?;
429
430    Ok(skills
431        .iter()
432        .map(|skill| format!("{} ({})", skill.name, skill.package_selector()))
433        .collect())
434}
435
436pub(crate) fn frontmatter_description(document: &Node, source: &Path) -> Result<Option<String>> {
437    Ok(context_frontmatter(document, source)?
438        .and_then(|frontmatter| frontmatter.description)
439        .map(|description| description.trim().to_owned())
440        .filter(|description| !description.is_empty()))
441}
442
443fn context_frontmatter(document: &Node, source: &Path) -> Result<Option<ContextFrontmatter>> {
444    let Some(children) = document.children() else {
445        return Err(Error::new(format!(
446            "{} is not a Markdown document",
447            source.display()
448        )));
449    };
450    let Some(Node::Yaml(frontmatter)) = children.first() else {
451        return Ok(None);
452    };
453
454    serde_yaml_ng::from_str(&frontmatter.value)
455        .map(Some)
456        .map_err(|error| {
457            Error::new(format!(
458                "invalid YAML front matter in {}: {error}",
459                source.display()
460            ))
461        })
462}
463
464fn validate_skill_name(name: &str) -> Result<()> {
465    let valid = !name.is_empty()
466        && name.len() <= 64
467        && !name.starts_with('-')
468        && !name.ends_with('-')
469        && !name.contains("--")
470        && name
471            .bytes()
472            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
473    if valid {
474        Ok(())
475    } else {
476        Err(Error::new(format!(
477            "invalid skill name {name:?}; use 1–64 lowercase ASCII letters, digits, or single hyphens"
478        )))
479    }
480}
481
482fn write_staged_skill(skill: &Skill, destination: &Path) -> Result<()> {
483    fs::create_dir_all(destination)
484        .map_err(|error| Error::new(format!("cannot create {}: {error}", destination.display())))?;
485
486    if let Some(assets) = &skill.assets {
487        copy_skill_assets(assets, destination, true)?;
488    }
489
490    let metadata = SkillFrontmatter {
491        name: &skill.name,
492        description: &skill.description,
493    };
494    let yaml = serde_yaml_ng::to_string(&metadata)
495        .map_err(|error| Error::new(format!("cannot encode skill front matter: {error}")))?;
496    let mut output = format!("---\n{yaml}---\n\n");
497    output.push_str(&skill.body);
498    if !output.ends_with('\n') {
499        output.push('\n');
500    }
501
502    let skill_file = destination.join("SKILL.md");
503    fs::write(&skill_file, output)
504        .map_err(|error| Error::new(format!("cannot write {}: {error}", skill_file.display())))
505}
506
507fn copy_skill_assets(source: &Path, destination: &Path, top_level: bool) -> Result<()> {
508    let mut entries = fs::read_dir(source)
509        .map_err(|error| Error::new(format!("cannot read {}: {error}", source.display())))?
510        .collect::<std::io::Result<Vec<_>>>()?;
511    entries.sort_by_key(|entry| entry.file_name());
512
513    for entry in entries {
514        let source_path = entry.path();
515        let destination_path = destination.join(entry.file_name());
516        let metadata = fs::symlink_metadata(&source_path).map_err(|error| {
517            Error::new(format!("cannot inspect {}: {error}", source_path.display()))
518        })?;
519        let file_type = metadata.file_type();
520
521        if file_type.is_symlink() {
522            return Err(Error::new(format!(
523                "skill assets cannot contain symbolic links: {}",
524                source_path.display()
525            )));
526        } else if file_type.is_dir() {
527            fs::create_dir(&destination_path).map_err(|error| {
528                Error::new(format!(
529                    "cannot create {}: {error}",
530                    destination_path.display()
531                ))
532            })?;
533            copy_skill_assets(&source_path, &destination_path, false)?;
534        } else if file_type.is_file() {
535            if top_level
536                && entry
537                    .file_name()
538                    .to_string_lossy()
539                    .eq_ignore_ascii_case("SKILL.md")
540            {
541                return Err(Error::new(format!(
542                    "{} is reserved for the generated skill instructions",
543                    source_path.display()
544                )));
545            }
546            fs::copy(&source_path, &destination_path).map_err(|error| {
547                Error::new(format!(
548                    "cannot copy {} to {}: {error}",
549                    source_path.display(),
550                    destination_path.display()
551                ))
552            })?;
553        } else {
554            return Err(Error::new(format!(
555                "unsupported skill asset: {}",
556                source_path.display()
557            )));
558        }
559    }
560    Ok(())
561}
562
563fn ensure_directory(path: &Path) -> Result<()> {
564    match fs::symlink_metadata(path) {
565        Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
566        Ok(_) => Err(Error::new(format!(
567            "skill installation path {} is not a regular directory",
568            path.display()
569        ))),
570        Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir_all(path)
571            .map_err(|error| Error::new(format!("cannot create {}: {error}", path.display()))),
572        Err(error) => Err(Error::new(format!(
573            "cannot inspect {}: {error}",
574            path.display()
575        ))),
576    }
577}
578
579fn load_registry(path: &Path) -> Result<Registry> {
580    let metadata = match fs::symlink_metadata(path) {
581        Ok(metadata) => metadata,
582        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
583            return Ok(Registry::default());
584        }
585        Err(error) => {
586            return Err(Error::new(format!(
587                "cannot inspect {}: {error}",
588                path.display()
589            )));
590        }
591    };
592    if !metadata.file_type().is_file() {
593        return Err(Error::new(format!(
594            "skill registry {} is not a regular file",
595            path.display()
596        )));
597    }
598
599    let bytes = fs::read(path)
600        .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
601    let registry: Registry = serde_json::from_slice(&bytes).map_err(|error| {
602        Error::new(format!(
603            "invalid skill registry {}: {error}",
604            path.display()
605        ))
606    })?;
607    if registry.version != REGISTRY_VERSION {
608        return Err(Error::new(format!(
609            "unsupported skill registry version {} in {}",
610            registry.version,
611            path.display()
612        )));
613    }
614    for name in registry.skills.keys() {
615        validate_skill_name(name)?;
616    }
617    Ok(registry)
618}
619
620fn path_exists(path: &Path) -> Result<bool> {
621    match fs::symlink_metadata(path) {
622        Ok(_) => Ok(true),
623        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
624        Err(error) => Err(Error::new(format!(
625            "cannot inspect {}: {error}",
626            path.display()
627        ))),
628    }
629}
630
631enum AppliedChange {
632    Installed { name: String, had_previous: bool },
633    Removed { name: String },
634}
635
636fn rollback(root: &Path, backups: &Path, changes: &[AppliedChange]) {
637    for change in changes.iter().rev() {
638        match change {
639            AppliedChange::Installed { name, had_previous } => {
640                let destination = root.join(name);
641                let _ = remove_existing(&destination);
642                if *had_previous {
643                    let _ = fs::rename(backups.join(name), destination);
644                }
645            }
646            AppliedChange::Removed { name } => {
647                let _ = fs::rename(backups.join(name), root.join(name));
648            }
649        }
650    }
651}
652
653fn remove_existing(path: &Path) -> Result<()> {
654    let metadata = match fs::symlink_metadata(path) {
655        Ok(metadata) => metadata,
656        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
657        Err(error) => {
658            return Err(Error::new(format!(
659                "cannot inspect {}: {error}",
660                path.display()
661            )));
662        }
663    };
664    let result = if metadata.file_type().is_dir() {
665        fs::remove_dir_all(path)
666    } else {
667        fs::remove_file(path)
668    };
669    result.map_err(|error| Error::new(format!("cannot remove {}: {error}", path.display())))
670}