1use super::document::Document;
5use super::installer::{Specification, remove_existing};
6use super::ownership::installed_skill_owners;
7#[cfg(test)]
8use super::test_filesystem as filesystem;
9use bake::{Error, Result};
10use serde_yaml_ng::Value;
11use std::collections::HashSet;
12#[cfg(not(test))]
13use std::fs as filesystem;
14use std::path::{Path, PathBuf};
15
16#[derive(Clone, Debug)]
18pub struct SkillDefinition {
19 pub name: String,
21 pub description: String,
22 pub specification: Specification,
23 pub path: PathBuf,
25 document: Document,
26 assets: Option<PathBuf>,
27 installation_content: String,
28}
29
30impl SkillDefinition {
31 pub fn package_selector(&self) -> &str {
33 self.specification.selector()
34 }
35
36 pub fn content(&self) -> &str {
38 self.document.content()
39 }
40
41 pub fn source_path(&self) -> &Path {
43 &self.document.path
44 }
45
46 pub fn installation_content(&self) -> &str {
48 &self.installation_content
49 }
50
51 pub(crate) fn from_document(
53 specification: &Specification,
54 document: &Document,
55 ) -> Result<Self> {
56 let source = &document.path;
57 if source.parent() != Some(specification.context_path.as_path()) {
58 return Err(Error::new(format!(
59 "skill document {} must be directly inside context/",
60 source.display()
61 )));
62 }
63
64 let source_name = source_name(source)?;
66 let name = match document.frontmatter.get("name") {
67 Some(Value::String(name)) => name.clone(),
68 None => format!(
69 "{}-{source_name}",
70 specification.name.to_ascii_lowercase().replace('_', "-")
71 ),
72 _ => {
73 return Err(Error::new(format!(
74 "invalid skill name in {}",
75 source.display()
76 )));
77 }
78 };
79 validate_skill_name(&name)?;
80 let description = document
81 .frontmatter
82 .get("description")
83 .and_then(Value::as_str)
84 .filter(|value| !value.trim().is_empty() && value.trim().chars().count() <= 1024)
85 .ok_or_else(|| {
86 Error::new(format!(
87 "skill {name:?} in {} requires a description of 1–1024 characters",
88 source.display()
89 ))
90 })?
91 .to_owned();
92
93 let assets = skill_assets_path(&specification.context_path, &source_name)?;
95 let mut frontmatter = document.frontmatter.clone();
96 frontmatter.insert("name".into(), name.clone().into());
97 let mut metadata = document.metadata().cloned().unwrap_or_default();
98 metadata.insert("ecosystem".into(), "cargo".into());
99 metadata.insert("package".into(), specification.name.clone().into());
100 metadata.insert("version".into(), specification.version.clone().into());
101 frontmatter.insert("metadata".into(), Value::Mapping(metadata));
102 let installation_content = document.render(frontmatter);
103
104 Ok(Self {
105 name,
106 description,
107 specification: specification.clone(),
108 path: source.file_name().unwrap().into(),
109 document: document.clone(),
110 assets,
111 installation_content,
112 })
113 }
114
115 pub(crate) fn install(&self, destination: &Path) -> Result<()> {
117 filesystem::create_dir_all(destination).map_err(|error| {
118 Error::new(format!("cannot create {}: {error}", destination.display()))
119 })?;
120 let path = destination.join("SKILL.md");
121 if let Err(error) = filesystem::write(&path, &self.installation_content) {
122 let _ = filesystem::remove_dir_all(destination);
124 return Err(Error::new(format!(
125 "cannot write {}: {error}",
126 path.display()
127 )));
128 }
129 if let Some(assets) = &self.assets {
130 copy_skill_assets(assets, destination, true)?;
131 }
132 Ok(())
133 }
134}
135
136fn skill_assets_path(context_path: &Path, source_name: &str) -> Result<Option<PathBuf>> {
137 let assets = context_path.join(source_name);
138 match filesystem::symlink_metadata(&assets) {
139 Ok(metadata) if metadata.file_type().is_dir() => Ok(Some(assets)),
140 Ok(_) => Err(Error::new(format!(
141 "skill assets path {} is not a directory",
142 assets.display()
143 ))),
144 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
145 Err(error) => Err(Error::new(format!(
146 "cannot inspect skill assets {}: {error}",
147 assets.display()
148 ))),
149 }
150}
151
152pub(crate) fn install_definitions(
154 root: &Path,
155 skills: &[SkillDefinition],
156 package: Option<&str>,
157 reconcile: bool,
158) -> Result<Vec<String>> {
159 let mut names = HashSet::new();
161 for skill in skills {
162 if !names.insert(skill.name.clone()) {
163 return Err(Error::new(format!(
164 "multiple selected crates provide skill {:?}; select one with --package",
165 skill.name
166 )));
167 }
168 }
169 let skills_root = root.join(".agents/skills");
170 let owners = installed_skill_owners(&skills_root)?;
171 for skill in skills {
172 let destination = skills_root.join(&skill.name);
173 if path_exists(&destination)?
174 && owners.get(&skill.name).is_none_or(|owner| {
175 owner.ecosystem != "cargo" || owner.package != skill.specification.name
176 })
177 {
178 return Err(Error::new(format!(
179 "skill destination {} belongs to a project or another provider",
180 destination.display()
181 )));
182 }
183 }
184 let stale: Vec<_> = owners
185 .iter()
186 .filter(|(name, owner)| {
187 reconcile
188 && owner.ecosystem == "cargo"
189 && package.is_none_or(|package| owner.package == package)
190 && !names.contains(*name)
191 })
192 .map(|(name, _)| name.clone())
193 .collect();
194
195 filesystem::create_dir_all(&skills_root)
197 .map_err(|error| Error::new(format!("cannot create {}: {error}", skills_root.display())))?;
198 for skill in skills {
199 let destination = skills_root.join(&skill.name);
200 remove_existing(&destination)?;
201 skill.install(&destination)?;
202 }
203
204 for name in &stale {
206 remove_existing(&skills_root.join(name))?;
207 }
208 let remaining = owners
209 .keys()
210 .filter(|name| !stale.contains(name))
211 .cloned()
212 .chain(names);
213 if let Some(update) = super::exclude::prepare(root, remaining)? {
214 update.apply()?;
215 }
216 Ok(skills.iter().map(|skill| skill.name.clone()).collect())
217}
218
219pub(crate) fn validate_skill_name(name: &str) -> Result<()> {
220 let valid = !name.is_empty()
221 && name.len() <= 64
222 && !name.starts_with('-')
223 && !name.ends_with('-')
224 && !name.contains("--")
225 && name
226 .bytes()
227 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
228 if valid {
229 Ok(())
230 } else {
231 Err(Error::new(format!(
232 "invalid skill name {name:?}; use 1–64 lowercase ASCII letters, digits, or single hyphens"
233 )))
234 }
235}
236
237fn source_name(source: &Path) -> Result<String> {
238 let Some(stem) = source.file_stem().and_then(|stem| stem.to_str()) else {
239 return Err(Error::new(format!(
240 "invalid skill filename: {}",
241 source.display()
242 )));
243 };
244 Ok(stem.to_owned())
245}
246
247fn copy_skill_assets(source: &Path, destination: &Path, top_level: bool) -> Result<()> {
248 let entries = filesystem::read_dir(source)
249 .map_err(|error| Error::new(format!("cannot read {}: {error}", source.display())))?;
250 let mut entries = entries.collect::<std::io::Result<Vec<_>>>()?;
251 entries.sort_by_key(|entry| entry.file_name());
252
253 for entry in entries {
254 let source_path = entry.path();
255 let destination_path = destination.join(entry.file_name());
256 copy_skill_asset(&source_path, &destination_path, top_level)?;
257 }
258
259 Ok(())
260}
261
262fn copy_skill_asset(source: &Path, destination: &Path, top_level: bool) -> Result<()> {
263 let metadata = inspect_skill_asset(source)?;
264 let file_type = metadata.file_type();
265
266 let name = source.file_name().unwrap_or_default().to_string_lossy();
267 if top_level && name.eq_ignore_ascii_case("SKILL.md") {
268 return Err(Error::new(format!(
269 "{} is reserved for generated skill files",
270 source.display()
271 )));
272 }
273
274 if file_type.is_symlink() {
275 return Err(Error::new(format!(
276 "skill assets cannot contain symbolic links: {}",
277 source.display()
278 )));
279 } else if file_type.is_dir() {
280 filesystem::create_dir(destination).map_err(|error| {
281 Error::new(format!("cannot create {}: {error}", destination.display()))
282 })?;
283 copy_skill_assets(source, destination, false)?;
284 } else if file_type.is_file() {
285 filesystem::copy(source, destination)
286 .map(|_| ())
287 .map_err(|error| {
288 Error::new(format!(
289 "cannot copy {} to {}: {error}",
290 source.display(),
291 destination.display()
292 ))
293 })?;
294 } else {
295 return Err(Error::new(format!(
296 "unsupported skill asset: {}",
297 source.display()
298 )));
299 }
300 Ok(())
301}
302
303fn inspect_skill_asset(path: &Path) -> Result<filesystem::Metadata> {
304 filesystem::symlink_metadata(path)
305 .map_err(|error| Error::new(format!("cannot inspect {}: {error}", path.display())))
306}
307
308fn path_exists(path: &Path) -> Result<bool> {
309 match filesystem::symlink_metadata(path) {
310 Ok(_) => Ok(true),
311 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
312 Err(error) => Err(Error::new(format!(
313 "cannot inspect {}: {error}",
314 path.display()
315 ))),
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::super::installer::{Definition, Installer};
322 use super::*;
323 use std::fs;
324 use tempfile::tempdir;
325
326 fn list_skills(installer: &Installer, selector: Option<&str>) -> Result<Vec<SkillDefinition>> {
327 let providers = if let Some(selector) = selector {
328 installer.find_provider(selector)?.into_iter().collect()
329 } else {
330 installer.providers()?
331 };
332
333 let mut skills = Vec::new();
334 for provider in providers {
335 for definition in provider.definitions {
336 if let Definition::Skill(skill) = definition {
337 skills.push(skill);
338 }
339 }
340 }
341 skills.sort_by(|left, right| {
342 left.specification
343 .name
344 .cmp(&right.specification.name)
345 .then_with(|| left.specification.version.cmp(&right.specification.version))
346 .then_with(|| left.name.cmp(&right.name))
347 });
348 Ok(skills)
349 }
350
351 fn install_skills(installer: &Installer, selector: Option<&str>) -> Result<Vec<String>> {
352 let skills = list_skills(installer, selector)?;
353 let package = if selector.is_some() {
354 skills
355 .first()
356 .map(|skill| skill.specification.name.as_str())
357 } else {
358 None
359 };
360 install_definitions(installer.root(), &skills, package, true)
361 }
362
363 fn specification(root: &Path, name: &str, version: &str) -> Specification {
364 let path = root.join(format!("{name}-{version}/context"));
365 fs::create_dir_all(&path).unwrap();
366 Specification::for_test(name, version, path)
367 }
368
369 fn write_skill(package: &Specification, name: &str, fields: &str, body: &str) {
370 fs::write(
371 package.context_path.join(format!("{name}.md")),
372 format!("---\nmetadata:\n type: skill\n{fields}---\n{body}"),
373 )
374 .unwrap();
375 }
376
377 fn seed_owner(path: &Path, ecosystem: &str, package: &str) {
378 fs::create_dir_all(path).unwrap();
379 fs::write(path.join("SKILL.md"), format!("---\nmetadata:\n type: skill\n ecosystem: {ecosystem}\n package: {package}\n version: '1.0'\n---\nInstructions")).unwrap();
380 }
381
382 #[test]
383 fn preserves_authored_names_descriptions_and_metadata() {
384 let directory = tempdir().unwrap();
385 let package = specification(directory.path(), "provider", "2.0.0");
386 fs::write(
387 package.context_path.join("workflow.md"),
388 include_str!("../tests/fixtures/SKILL.md"),
389 )
390 .unwrap();
391 let installer = Installer::for_test(directory.path(), vec![package.clone()]);
392 let skills = list_skills(&installer, None).unwrap();
393 assert_eq!(skills[0].name, "provider-workflow");
394 assert_eq!(
395 skills[0].source_path(),
396 package.context_path.join("workflow.md")
397 );
398 assert!(skills[0].content().contains("author: Provider"));
399 assert!(
400 skills[0]
401 .installation_content()
402 .contains("ecosystem: cargo")
403 );
404 assert!(skills[0].installation_content().contains("version: 2.0.0"));
405 write_skill(
406 &package,
407 "workflow",
408 "name: custom-workflow\ndescription: ' Run workflow. '\n",
409 "\nBody",
410 );
411 let skills = list_skills(&installer, None).unwrap();
412 assert_eq!(skills[0].name, "custom-workflow");
413 assert_eq!(skills[0].description, " Run workflow. ");
414 install_skills(&installer, None).unwrap();
415 assert!(
416 directory
417 .path()
418 .join(".agents/skills/custom-workflow/SKILL.md")
419 .is_file()
420 );
421 }
422
423 #[test]
424 fn sorts_providers_and_detects_ambiguous_installed_names() {
425 let directory = tempdir().unwrap();
426 let root = directory.path();
427 let first = specification(root, "a_provider", "1.0.0");
428 let second = specification(root, "a_provider", "2.0.0");
429 let last = specification(root, "z-provider", "1.0.0");
430 fs::write(first.context_path.join("guide.md"), "# Guide\n").unwrap();
431 for package in [&last, &second, &first] {
432 write_skill(package, "zeta", "description: Zeta.\n", "Z");
433 write_skill(package, "alpha", "description: Alpha.\n", "A");
434 }
435 let installer = Installer::for_test(root, vec![last, second, first]);
436 assert!(
437 list_skills(&installer, Some("missing-provider"))
438 .unwrap()
439 .is_empty()
440 );
441 let skills = list_skills(&installer, None).unwrap();
442 assert_eq!(skills[0].name, "a-provider-alpha");
443 assert_eq!(skills[0].specification.version, "1.0.0");
444 assert_eq!(skills.last().unwrap().name, "z-provider-zeta");
445 assert!(
446 install_skills(&installer, None)
447 .unwrap_err()
448 .to_string()
449 .contains("multiple selected")
450 );
451 assert!(install_skills(&installer, Some("a_provider")).is_err());
452 assert!(list_skills(&installer, Some("a_provider")).is_err());
453 }
454
455 #[test]
456 fn validates_names_and_description_boundaries() {
457 let directory = tempdir().unwrap();
458 let package = specification(directory.path(), "provider", "1.0.0");
459 let installer = Installer::for_test(directory.path(), vec![package.clone()]);
460 for fields in [
461 "name: 123\ndescription: Valid.\n".to_owned(),
462 "name: null\ndescription: Valid.\n".to_owned(),
463 "name: Bad_Name\ndescription: Valid.\n".to_owned(),
464 "description: []\n".to_owned(),
465 "description: ' '\n".to_owned(),
466 format!("description: {}\n", "é".repeat(1025)),
467 ] {
468 write_skill(&package, "workflow", &fields, "Body");
469 assert!(list_skills(&installer, None).is_err(), "{fields}");
470 }
471 write_skill(
472 &package,
473 "workflow",
474 &format!(
475 "name: {}\ndescription: ' {} '\n",
476 "a".repeat(64),
477 "é".repeat(1024)
478 ),
479 "Body",
480 );
481 assert_eq!(list_skills(&installer, None).unwrap()[0].name.len(), 64);
482 for name in ["", "-a", "a-", "a--b", "UPPER", &"a".repeat(65)] {
483 assert!(validate_skill_name(name).is_err());
484 }
485 assert!(source_name(Path::new("/")).is_err());
486 }
487
488 #[test]
489 fn reconciles_only_the_selected_ecosystem_and_package() {
490 let directory = tempdir().unwrap();
491 let root = directory.path();
492 let package = specification(root, "provider", "1.0.0");
493 write_skill(&package, "workflow", "description: Workflow.\n", "Body");
494 let installer = Installer::for_test(root, vec![package.clone()]);
495 let skills = root.join(".agents/skills");
496 seed_owner(&skills.join("stale-provider"), "cargo", "provider");
497 seed_owner(&skills.join("other-workflow"), "cargo", "other");
498 seed_owner(&skills.join("ruby-workflow"), "gem", "provider");
499 install_skills(&installer, Some("provider")).unwrap();
500 assert!(!skills.join("stale-provider").exists());
501 assert!(skills.join("other-workflow").exists());
502 assert!(skills.join("ruby-workflow").exists());
503 fs::remove_file(package.context_path.join("workflow.md")).unwrap();
504 install_skills(&installer, Some("provider")).unwrap();
505 assert!(!skills.join("provider-workflow").exists());
506 install_skills(&Installer::for_test(root, vec![]), None).unwrap();
507 assert!(!skills.join("other-workflow").exists());
508 assert!(skills.join("ruby-workflow").exists());
509 }
510
511 #[test]
512 fn rejects_other_providers_and_accepts_the_same_owner_at_a_new_version() {
513 let directory = tempdir().unwrap();
514 let package = specification(directory.path(), "provider", "2.0.0");
515 write_skill(&package, "workflow", "description: Workflow.\n", "Body");
516 let installer = Installer::for_test(directory.path(), vec![package]);
517 let destination = directory.path().join(".agents/skills/provider-workflow");
518 for (ecosystem, package) in [("gem", "provider"), ("cargo", "other")] {
519 seed_owner(&destination, ecosystem, package);
520 assert!(install_skills(&installer, None).is_err());
521 }
522 seed_owner(&destination, "cargo", "provider");
523 install_skills(&installer, None).unwrap();
524 assert!(
525 fs::read_to_string(destination.join("SKILL.md"))
526 .unwrap()
527 .contains("version: 2.0.0")
528 );
529 }
530
531 #[test]
532 fn resource_failure_leaves_identifiable_progress_and_retry_refreshes_it() {
533 let directory = tempdir().unwrap();
534 let package = specification(directory.path(), "provider", "1.0.0");
535 write_skill(&package, "workflow", "description: Workflow.\n", "Body");
536 let resources = package.context_path.join("workflow");
537 fs::create_dir_all(resources.join("references")).unwrap();
538 fs::write(resources.join("references/data.md"), "---\n[invalid YAML\n").unwrap();
539 fs::write(resources.join("notes.txt"), "opaque asset").unwrap();
540 let installer = Installer::for_test(directory.path(), vec![package.clone()]);
541 let failure_path = resources.join("notes.txt");
542 let _failure = filesystem::fail_once(filesystem::Operation::Copy, move |path| {
543 path == failure_path
544 });
545 assert!(install_skills(&installer, None).is_err());
546 let skills = directory.path().join(".agents/skills");
547 assert_eq!(
548 installed_skill_owners(&skills).unwrap()["provider-workflow"].package,
549 "provider"
550 );
551 install_skills(&installer, None).unwrap();
552 assert_eq!(
553 fs::read_to_string(skills.join("provider-workflow/notes.txt")).unwrap(),
554 "opaque asset"
555 );
556 fs::remove_file(resources.join("notes.txt")).unwrap();
557 install_skills(&installer, None).unwrap();
558 assert!(!skills.join("provider-workflow/notes.txt").exists());
559 }
560
561 #[test]
562 fn reserves_only_the_top_level_instruction_filename() {
563 let directory = tempdir().unwrap();
564 let source = directory.path().join("source");
565 let destination = directory.path().join("destination");
566 fs::create_dir(&source).unwrap();
567 fs::create_dir(&destination).unwrap();
568 for name in ["SKILL.md", "skill.MD"] {
569 fs::write(source.join(name), "conflict").unwrap();
570 assert!(copy_skill_assets(&source, &destination, true).is_err());
571 fs::remove_file(source.join(name)).unwrap();
572 fs::create_dir(source.join(name)).unwrap();
573 assert!(copy_skill_assets(&source, &destination, true).is_err());
574 fs::remove_dir(source.join(name)).unwrap();
575 }
576 fs::create_dir(source.join("references")).unwrap();
577 fs::write(source.join("references/SKILL.md"), "opaque").unwrap();
578 copy_skill_assets(&source, &destination, true).unwrap();
579 assert_eq!(
580 fs::read_to_string(destination.join("references/SKILL.md")).unwrap(),
581 "opaque"
582 );
583 }
584 #[test]
585 fn instruction_write_failure_cleans_the_directory_and_retry_succeeds() {
586 let directory = tempdir().unwrap();
587 let root = directory.path();
588 let package = specification(root, "provider", "1.0.0");
589 write_skill(&package, "workflow", "description: Workflow.\n", "Body");
590 let installer = Installer::for_test(root, vec![package]);
591 let destination = root.join(".agents/skills/provider-workflow");
592 let instructions = destination.join("SKILL.md");
593 let _failure = filesystem::fail_once(filesystem::Operation::Write, move |path| {
594 path == instructions
595 });
596 assert!(
597 install_skills(&installer, None)
598 .unwrap_err()
599 .to_string()
600 .contains("cannot write")
601 );
602 assert!(!destination.exists());
603 install_skills(&installer, None).unwrap();
604 assert_eq!(
605 installed_skill_owners(&root.join(".agents/skills")).unwrap()["provider-workflow"]
606 .package,
607 "provider"
608 );
609 }
610
611 #[test]
612 fn reports_discovery_and_refresh_io_failures() {
613 use filesystem::Operation;
614 let directory = tempdir().unwrap();
615 let root = directory.path();
616 let package = specification(root, "provider", "1.0.0");
617 write_skill(&package, "workflow", "description: Workflow.\n", "Body");
618 let installer = Installer::for_test(root, vec![package.clone()]);
619 let skills = root.join(".agents/skills");
620 let destination = skills.join("provider-workflow");
621 for (operation, path) in [
622 (Operation::ReadDirectory, package.context_path.clone()),
623 (Operation::Inspect, package.context_path.join("workflow")),
624 (Operation::Inspect, destination.clone()),
625 (Operation::CreateDirectoryTree, destination.clone()),
626 ] {
627 let _failure = filesystem::fail_once(operation, move |candidate| candidate == path);
628 assert!(
629 install_skills(&installer, None)
630 .unwrap_err()
631 .to_string()
632 .contains("injected filesystem failure")
633 );
634 }
635 install_skills(&installer, None).unwrap();
636 let failure_path = destination.clone();
637 let _failure = filesystem::fail_once(Operation::RemoveDirectoryTree, move |path| {
638 path == failure_path
639 });
640 assert!(
641 install_skills(&installer, None)
642 .unwrap_err()
643 .to_string()
644 .contains("cannot remove")
645 );
646 fs::remove_file(package.context_path.join("workflow.md")).unwrap();
647 let _failure = filesystem::fail_once(Operation::RemoveDirectoryTree, move |path| {
648 path == destination
649 });
650 assert!(
651 install_skills(&installer, None)
652 .unwrap_err()
653 .to_string()
654 .contains("cannot remove")
655 );
656 }
657
658 #[test]
659 fn reports_resource_failures_and_keeps_the_specification_identity() {
660 use filesystem::Operation;
661 let directory = tempdir().unwrap();
662 let root = directory.path();
663 let package = specification(root, "provider", "1.0.0");
664 write_skill(&package, "workflow", "description: Workflow.\n", "Body");
665 let resources = package.context_path.join("workflow");
666 fs::create_dir_all(resources.join("references")).unwrap();
667 fs::write(resources.join("references/guide.md"), "# Guide").unwrap();
668 let installer = Installer::for_test(root, vec![package]);
669 let destination = root.join(".agents/skills/provider-workflow");
670 for (operation, path) in [
671 (Operation::ReadDirectory, resources.clone()),
672 (Operation::ReadDirectoryEntry, resources.clone()),
673 (Operation::Inspect, resources.join("references")),
674 (Operation::CreateDirectory, destination.join("references")),
675 (Operation::ReadDirectory, resources.join("references")),
676 ] {
677 let _failure = filesystem::fail_once(operation, move |candidate| candidate == path);
678 assert!(
679 install_skills(&installer, None)
680 .unwrap_err()
681 .to_string()
682 .contains("injected filesystem failure")
683 );
684 assert_eq!(
685 installed_skill_owners(&root.join(".agents/skills")).unwrap()["provider-workflow"]
686 .package,
687 "provider"
688 );
689 }
690 }
691
692 #[test]
693 fn reports_git_exclusion_errors_after_installing_identifiable_skills() {
694 let directory = tempdir().unwrap();
695 let root = directory.path();
696 assert!(
697 std::process::Command::new("git")
698 .args(["init", "--quiet"])
699 .arg(root)
700 .status()
701 .unwrap()
702 .success()
703 );
704 let package = specification(root, "provider", "1.0.0");
705 write_skill(&package, "workflow", "description: Workflow.\n", "Body");
706 let installer = Installer::for_test(root, vec![package]);
707 let exclude = root.join(".git/info/exclude");
708 fs::write(&exclude, "# BEGIN bake-agent-context\n").unwrap();
709 assert!(install_skills(&installer, None).is_err());
710 fs::write(&exclude, "# Project exclusions\n").unwrap();
711 let _failure =
712 filesystem::fail_once(filesystem::Operation::Write, move |path| path == exclude);
713 assert!(
714 install_skills(&installer, None)
715 .unwrap_err()
716 .to_string()
717 .contains("injected filesystem failure")
718 );
719 assert_eq!(
720 installed_skill_owners(&root.join(".agents/skills")).unwrap()["provider-workflow"]
721 .package,
722 "provider"
723 );
724 }
725
726 #[cfg(unix)]
727 #[test]
728 fn rejects_non_utf8_skill_filenames() {
729 use std::os::unix::ffi::OsStringExt;
730 let directory = tempdir().unwrap();
731 let package = specification(directory.path(), "provider", "1.0.0");
732 let name = std::ffi::OsString::from_vec(b"invalid-\xff.md".to_vec());
733 let document = Document::parse(
734 "---\nmetadata: {type: skill}\ndescription: Workflow.\n---\n".to_owned(),
735 &package.context_path.join(name),
736 )
737 .unwrap();
738 assert!(
739 SkillDefinition::from_document(&package, &document)
740 .unwrap_err()
741 .to_string()
742 .contains("invalid skill filename")
743 );
744 }
745}