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
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(®istry_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, ®istry_path) {
416 if had_registry {
417 let _ = fs::rename(backups.join("registry.json"), ®istry_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}