1use 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#[derive(Clone, Debug)]
17pub struct Skill {
18 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 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
69pub 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
202pub 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(®istry_path)?;
267 let had_registry = path_exists(®istry_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(®istry)
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(®istry_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, ®istry_path) {
418 if had_registry {
419 let _ = fs::rename(backups.join("registry.json"), ®istry_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(®istry_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}