Skip to main content

bake_agent_context/
installer.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use super::package::Package;
5use super::skill::SkillDefinition;
6#[cfg(test)]
7use super::test_filesystem as fs;
8use bake::{Error, Result};
9use serde::Deserialize;
10use std::collections::{HashMap, HashSet};
11use std::env;
12use std::ffi::{OsStr, OsString};
13#[cfg(not(test))]
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18/// Resolved package metadata for a Cargo context provider.
19#[derive(Clone, Debug)]
20pub struct Specification {
21    pub name: String,
22    pub version: String,
23    pub description: Option<String>,
24    pub context_path: PathBuf,
25    selector: String,
26}
27
28impl Specification {
29    /// The name accepted by the task's `--package` option.
30    pub fn selector(&self) -> &str {
31        &self.selector
32    }
33
34    #[cfg(test)]
35    pub(super) fn for_test(name: &str, version: &str, context_path: PathBuf) -> Self {
36        Self {
37            name: name.to_owned(),
38            version: version.to_owned(),
39            description: Some(format!("{name} documentation")),
40            context_path,
41            selector: format!("{name}@{version}"),
42        }
43    }
44}
45
46/// An ordinary file loaded from a provider's `context/` directory.
47#[derive(Clone, Debug)]
48pub struct FileDefinition {
49    /// The path relative to the provider's `context/` directory.
50    pub path: PathBuf,
51    /// The source file path.
52    pub source_path: PathBuf,
53    pub(crate) document: Option<super::document::Document>,
54}
55
56/// One loaded context definition.
57#[derive(Clone, Debug)]
58pub enum Definition {
59    File(FileDefinition),
60    Skill(SkillDefinition),
61}
62
63impl Definition {
64    /// The name shown by `agent:context:list`.
65    pub fn display_name(&self) -> String {
66        match self {
67            Self::File(file) => file
68                .path
69                .to_string_lossy()
70                .replace(std::path::MAIN_SEPARATOR, "/"),
71            Self::Skill(skill) => format!(
72                "{} (skill)",
73                skill.path.file_stem().unwrap().to_string_lossy()
74            ),
75        }
76    }
77
78    /// The source path relative to the provider's context directory.
79    pub fn path(&self) -> &Path {
80        match self {
81            Self::File(file) => &file.path,
82            Self::Skill(skill) => &skill.path,
83        }
84    }
85
86    /// Read the authored content of this definition.
87    pub fn content(&self) -> Result<String> {
88        match self {
89            Self::File(file) => file.content(),
90            Self::Skill(skill) => Ok(skill.content().to_owned()),
91        }
92    }
93
94    /// Whether the definition installs as an Agent Skill.
95    pub fn is_skill(&self) -> bool {
96        matches!(self, Self::Skill(_))
97    }
98}
99
100impl FileDefinition {
101    /// Read the authored content of the file.
102    pub fn content(&self) -> Result<String> {
103        if let Some(document) = &self.document {
104            return Ok(document.content.clone());
105        }
106
107        read_context_file(&self.source_path)
108    }
109}
110
111/// The provider and skill names refreshed by an installation.
112#[derive(Debug)]
113pub struct Installation {
114    pub context: Vec<String>,
115    pub skills: Vec<String>,
116}
117
118/// Discovers, lists, reads, and installs context from resolved Cargo dependencies.
119#[derive(Clone, Debug)]
120pub struct Installer {
121    root: PathBuf,
122    context_path: PathBuf,
123    specifications: Vec<Specification>,
124}
125
126impl Installer {
127    /// Resolve the project's Cargo packages and find dependencies with `context/` directories.
128    pub fn discover(root: impl Into<PathBuf>) -> Result<Self> {
129        let root = root.into();
130        let cargo = cargo_executable(env::var_os("CARGO"));
131        Self::discover_with_cargo(root, cargo)
132    }
133
134    fn discover_with_cargo(root: PathBuf, cargo: OsString) -> Result<Self> {
135        let manifest = root.join("Cargo.toml");
136        let output = run_cargo_metadata(&root, &cargo)?;
137        if !output.status.success() {
138            return Err(cargo_metadata_failure(
139                &manifest,
140                output.status,
141                &output.stderr,
142            ));
143        }
144
145        let metadata = parse_metadata(&output.stdout)?;
146        Self::discover_from_metadata(root, metadata)
147    }
148
149    #[cfg(test)]
150    pub(super) fn for_test(root: &Path, specifications: Vec<Specification>) -> Self {
151        Self {
152            root: root.to_path_buf(),
153            context_path: root.join(".agents/context"),
154            specifications,
155        }
156    }
157
158    fn discover_from_metadata(root: PathBuf, metadata: CargoMetadata) -> Result<Self> {
159        let workspace_members: HashSet<_> = metadata.workspace_members.into_iter().collect();
160        let resolved_packages: HashSet<_> = metadata
161            .resolve
162            .map(|resolve| {
163                resolve
164                    .nodes
165                    .into_iter()
166                    .map(|node| node.package_id)
167                    .collect()
168            })
169            .unwrap_or_default();
170
171        let mut candidates = Vec::new();
172        for package in metadata.packages {
173            if workspace_members.contains(&package.package_id)
174                || (!resolved_packages.is_empty()
175                    && !resolved_packages.contains(&package.package_id))
176            {
177                continue;
178            }
179
180            let Some(package_root) = package.manifest_path.parent() else {
181                continue;
182            };
183            let context_path = package_root.join("context");
184            match fs::symlink_metadata(&context_path) {
185                Ok(metadata) if metadata.file_type().is_dir() => {}
186                Ok(_) => continue,
187                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
188                Err(error) => {
189                    return Err(Error::new(format!(
190                        "cannot inspect {}: {error}",
191                        context_path.display()
192                    )));
193                }
194            };
195            candidates.push(Specification {
196                name: package.name,
197                version: package.version,
198                description: package.description,
199                context_path,
200                selector: String::new(),
201            });
202        }
203
204        let mut name_counts = HashMap::new();
205        for specification in &candidates {
206            *name_counts
207                .entry(specification.name.clone())
208                .or_insert(0usize) += 1;
209        }
210        for specification in &mut candidates {
211            specification.selector = if name_counts[&specification.name] == 1 {
212                specification.name.clone()
213            } else {
214                format!("{}@{}", specification.name, specification.version)
215            };
216        }
217        candidates.sort_by(|left, right| {
218            left.name
219                .cmp(&right.name)
220                .then_with(|| left.version.cmp(&right.version))
221        });
222
223        Ok(Self {
224            context_path: root.join(".agents/context"),
225            root,
226            specifications: candidates,
227        })
228    }
229
230    pub fn root(&self) -> &Path {
231        &self.root
232    }
233
234    pub fn context_path(&self) -> &Path {
235        &self.context_path
236    }
237
238    pub fn specifications(&self) -> &[Specification] {
239        &self.specifications
240    }
241
242    pub fn find_specification(&self, selector: &str) -> Result<Option<Specification>> {
243        let matches: Vec<_> = self
244            .specifications
245            .iter()
246            .filter(|specification| {
247                specification.selector == selector || specification.name == selector
248            })
249            .collect();
250
251        match matches.as_slice() {
252            [] => Ok(None),
253            [specification] => Ok(Some((*specification).clone())),
254            _ => {
255                let selectors = matches
256                    .iter()
257                    .map(|specification| specification.selector.as_str())
258                    .collect::<Vec<_>>()
259                    .join(", ");
260                Err(Error::new(format!(
261                    "multiple versions of crate {selector:?} provide context; choose one of: {selectors}"
262                )))
263            }
264        }
265    }
266
267    /// Load providers with context directories, including empty providers.
268    pub fn providers(&self) -> Result<Vec<Package>> {
269        self.specifications
270            .iter()
271            .cloned()
272            .map(Package::load)
273            .collect()
274    }
275
276    /// Load one provider by its package selector.
277    pub fn find_provider(&self, selector: &str) -> Result<Option<Package>> {
278        self.find_specification(selector)?
279            .map(Package::load)
280            .transpose()
281    }
282
283    /// Read the authored content of a selected definition.
284    pub fn show_context_file(&self, selector: &str, file: &str) -> Result<Option<String>> {
285        let Some(package) = self.find_provider(selector)? else {
286            return Ok(None);
287        };
288        package
289            .find_definition(file)?
290            .map(Definition::content)
291            .transpose()
292    }
293
294    /// Install context and skills from the same loaded provider documents.
295    pub fn install(&self, selector: Option<&str>) -> Result<Installation> {
296        // Resolve the requested scope and load each provider once:
297        let loaded =
298            if let Some(selector) = selector {
299                vec![self.find_provider(selector)?.ok_or_else(|| {
300                    Error::new(format!("no context found for crate {selector:?}"))
301                })?]
302            } else {
303                self.providers()?
304            };
305        let definitions: Vec<_> = loaded
306            .iter()
307            .flat_map(|package| package.definitions.iter())
308            .filter_map(|definition| match definition {
309                Definition::Skill(skill) => Some(skill.clone()),
310                Definition::File(_) => None,
311            })
312            .collect();
313        let package = selector
314            .and_then(|_| loaded.first())
315            .map(|package| package.specification.name.as_str());
316
317        // Apply skill installation rules, then write ordinary definitions:
318        let skills = super::skill::install_definitions(&self.root, &definitions, package, true)?;
319        let mut context_names = Vec::new();
320        for package in loaded {
321            if package.install_files(&self.context_path.join(package.specification.selector()))? {
322                context_names.push(package.specification.selector().to_owned());
323            }
324        }
325        Ok(Installation {
326            context: context_names,
327            skills,
328        })
329    }
330}
331
332fn run_cargo_metadata(root: &Path, cargo: &OsStr) -> Result<std::process::Output> {
333    let manifest = root.join("Cargo.toml");
334    let mut command = Command::new(cargo);
335    command
336        .args([
337            "metadata",
338            "--format-version",
339            "1",
340            "--locked",
341            "--manifest-path",
342        ])
343        .arg(&manifest)
344        .current_dir(root);
345
346    command.output().map_err(|error| {
347        Error::new(format!(
348            "cannot run cargo metadata for {}: {error}",
349            manifest.display()
350        ))
351    })
352}
353
354fn cargo_executable(cargo: Option<OsString>) -> OsString {
355    cargo.unwrap_or_else(|| "cargo".into())
356}
357
358fn cargo_metadata_failure(manifest: &Path, status: impl std::fmt::Display, stderr: &[u8]) -> Error {
359    let details = String::from_utf8_lossy(stderr).trim().to_owned();
360    Error::new(format!(
361        "cargo metadata failed for {} ({}): {}",
362        manifest.display(),
363        status,
364        if details.is_empty() {
365            "run cargo check to resolve and lock the project's dependencies".to_owned()
366        } else {
367            details
368        }
369    ))
370}
371
372fn parse_metadata(stdout: &[u8]) -> Result<CargoMetadata> {
373    serde_json::from_slice(stdout)
374        .map_err(|error| Error::new(format!("cannot parse cargo metadata: {error}")))
375}
376
377#[derive(Deserialize)]
378struct CargoMetadata {
379    workspace_members: Vec<String>,
380    packages: Vec<CargoPackage>,
381    resolve: Option<Resolve>,
382}
383
384#[derive(Deserialize)]
385struct Resolve {
386    nodes: Vec<ResolveNode>,
387}
388
389#[derive(Deserialize)]
390struct ResolveNode {
391    #[serde(rename = "id")]
392    package_id: String,
393}
394
395#[derive(Deserialize)]
396struct CargoPackage {
397    #[serde(rename = "id")]
398    package_id: String,
399    name: String,
400    version: String,
401    description: Option<String>,
402    manifest_path: PathBuf,
403}
404
405fn read_context_file(path: &Path) -> Result<String> {
406    fs::read_to_string(path)
407        .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))
408}
409
410pub(crate) fn remove_existing(path: &Path) -> Result<()> {
411    let metadata = match fs::symlink_metadata(path) {
412        Ok(metadata) => metadata,
413        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
414        Err(error) => {
415            return Err(Error::new(format!(
416                "cannot inspect {}: {error}",
417                path.display()
418            )));
419        }
420    };
421    let result = if metadata.file_type().is_dir() {
422        fs::remove_dir_all(path)
423    } else {
424        fs::remove_file(path)
425    };
426    result.map_err(|error| Error::new(format!("cannot remove {}: {error}", path.display())))
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use tempfile::tempdir;
433
434    fn cargo_package(id: &str, name: &str, version: &str, manifest_path: PathBuf) -> CargoPackage {
435        CargoPackage {
436            package_id: id.to_owned(),
437            name: name.to_owned(),
438            version: version.to_owned(),
439            description: Some(format!("{name} description")),
440            manifest_path,
441        }
442    }
443
444    #[test]
445    fn reports_cargo_execution_status_and_json_errors() {
446        let directory = tempdir().unwrap();
447        let error = Installer::discover(directory.path()).err().unwrap();
448        assert!(error.to_string().contains("cargo metadata failed"));
449
450        let error = Installer::discover_with_cargo(
451            directory.path().to_path_buf(),
452            directory.path().join("missing-cargo").into_os_string(),
453        )
454        .unwrap_err();
455        assert!(error.to_string().contains("cannot run cargo metadata"));
456
457        assert_eq!(cargo_executable(None), OsString::from("cargo"));
458        assert_eq!(
459            cargo_executable(Some(OsString::from("custom-cargo"))),
460            OsString::from("custom-cargo")
461        );
462
463        let error = cargo_metadata_failure(
464            &directory.path().join("Cargo.toml"),
465            "exit status: 1",
466            b"  cargo stderr \n",
467        );
468        assert!(error.to_string().ends_with("cargo stderr"));
469        let error = cargo_metadata_failure(&PathBuf::from("Cargo.toml"), "exit status: 1", b"\n");
470        assert!(error.to_string().contains("run cargo check to resolve"));
471
472        let error = parse_metadata(b"not JSON").err().unwrap();
473        assert!(error.to_string().contains("cannot parse cargo metadata"));
474    }
475
476    #[cfg(unix)]
477    #[test]
478    fn reports_metadata_parse_errors_from_cargo() {
479        let directory = tempdir().unwrap();
480        let error =
481            Installer::discover_with_cargo(directory.path().to_path_buf(), "/usr/bin/true".into())
482                .unwrap_err();
483        assert!(error.to_string().contains("cannot parse cargo metadata"));
484    }
485
486    #[test]
487    fn discovers_only_resolved_non_workspace_context_packages_and_disambiguates_versions() {
488        let directory = tempdir().unwrap();
489        let root = directory.path();
490        for path in [
491            "workspace",
492            "duplicate-v1",
493            "duplicate-v2",
494            "missing",
495            "file",
496        ] {
497            fs::create_dir_all(root.join(path)).unwrap();
498        }
499        fs::create_dir(root.join("workspace/context")).unwrap();
500        fs::create_dir(root.join("duplicate-v1/context")).unwrap();
501        fs::create_dir(root.join("duplicate-v2/context")).unwrap();
502        fs::write(root.join("file/context"), "not a directory").unwrap();
503
504        let packages = vec![
505            cargo_package(
506                "root 0.1.0",
507                "workspace",
508                "0.1.0",
509                root.join("workspace/Cargo.toml"),
510            ),
511            cargo_package(
512                "dup 1.0.0",
513                "duplicate",
514                "1.0.0",
515                root.join("duplicate-v1/Cargo.toml"),
516            ),
517            cargo_package(
518                "dup 2.0.0",
519                "duplicate",
520                "2.0.0",
521                root.join("duplicate-v2/Cargo.toml"),
522            ),
523            cargo_package(
524                "unresolved 1.0.0",
525                "unresolved",
526                "1.0.0",
527                root.join("missing/Cargo.toml"),
528            ),
529            cargo_package("file 1.0.0", "file", "1.0.0", root.join("file/Cargo.toml")),
530        ];
531        let metadata = CargoMetadata {
532            workspace_members: vec!["root 0.1.0".to_owned()],
533            packages,
534            resolve: Some(Resolve {
535                nodes: vec![
536                    ResolveNode {
537                        package_id: "root 0.1.0".to_owned(),
538                    },
539                    ResolveNode {
540                        package_id: "dup 1.0.0".to_owned(),
541                    },
542                    ResolveNode {
543                        package_id: "dup 2.0.0".to_owned(),
544                    },
545                    ResolveNode {
546                        package_id: "file 1.0.0".to_owned(),
547                    },
548                ],
549            }),
550        };
551        let installer = Installer::discover_from_metadata(root.to_path_buf(), metadata).unwrap();
552
553        assert_eq!(installer.root(), root);
554        assert_eq!(installer.context_path(), root.join(".agents/context"));
555        assert_eq!(
556            installer
557                .specifications()
558                .iter()
559                .map(Specification::selector)
560                .collect::<Vec<_>>(),
561            ["duplicate@1.0.0", "duplicate@2.0.0"]
562        );
563        assert!(installer.find_specification("missing").unwrap().is_none());
564        assert!(
565            installer
566                .find_specification("duplicate")
567                .unwrap_err()
568                .to_string()
569                .contains("duplicate@1.0.0, duplicate@2.0.0")
570        );
571        assert_eq!(
572            installer
573                .find_specification("duplicate@2.0.0")
574                .unwrap()
575                .unwrap()
576                .version,
577            "2.0.0"
578        );
579    }
580
581    #[test]
582    fn supports_metadata_without_a_resolve_graph_and_skips_missing_or_malformed_paths() {
583        let directory = tempdir().unwrap();
584        let root = directory.path();
585        fs::create_dir_all(root.join("valid/context")).unwrap();
586        fs::create_dir(root.join("path-is-file")).unwrap();
587        fs::write(root.join("path-is-file/context"), "not a directory").unwrap();
588
589        let metadata = CargoMetadata {
590            workspace_members: Vec::new(),
591            packages: vec![
592                cargo_package("valid", "valid", "1.0.0", root.join("valid/Cargo.toml")),
593                cargo_package(
594                    "missing",
595                    "missing",
596                    "1.0.0",
597                    root.join("absent/Cargo.toml"),
598                ),
599                cargo_package(
600                    "file",
601                    "file",
602                    "1.0.0",
603                    root.join("path-is-file/Cargo.toml"),
604                ),
605                cargo_package("empty", "empty", "1.0.0", PathBuf::new()),
606            ],
607            resolve: None,
608        };
609        let installer = Installer::discover_from_metadata(root.to_path_buf(), metadata).unwrap();
610        assert_eq!(installer.specifications().len(), 1);
611        assert_eq!(installer.specifications()[0].selector(), "valid");
612    }
613
614    #[cfg(unix)]
615    #[test]
616    fn reports_context_provider_path_inspection_errors() {
617        let directory = tempdir().unwrap();
618        let root = directory.path();
619        let blocker = root.join("blocker");
620        fs::write(&blocker, "file").unwrap();
621        let metadata = CargoMetadata {
622            workspace_members: Vec::new(),
623            packages: vec![cargo_package(
624                "broken",
625                "broken",
626                "1.0.0",
627                blocker.join("Cargo.toml"),
628            )],
629            resolve: None,
630        };
631        assert!(Installer::discover_from_metadata(root.to_path_buf(), metadata).is_err());
632    }
633
634    #[test]
635    fn finds_loaded_source_paths_before_skill_names() {
636        let directory = tempdir().unwrap();
637        let context = directory.path().join("context");
638        fs::create_dir_all(context.join("nested")).unwrap();
639        fs::write(context.join("guide.md"), "guide").unwrap();
640        let source =
641            "---\nname: guide\nmetadata: {type: skill}\ndescription: Workflow.\n---\n# Workflow\n";
642        fs::write(context.join("workflow.md"), source).unwrap();
643        fs::write(context.join("nested/readme.MD"), "nested").unwrap();
644        let specification = Specification::for_test("provider", "1.0.0", context.clone());
645        let installer = Installer::for_test(directory.path(), vec![specification]);
646        let package = installer.find_provider("provider").unwrap().unwrap();
647        assert_eq!(
648            package
649                .find_definition("guide")
650                .unwrap()
651                .unwrap()
652                .content()
653                .unwrap(),
654            "guide"
655        );
656        assert_eq!(
657            package
658                .find_definition("guide.md")
659                .unwrap()
660                .unwrap()
661                .content()
662                .unwrap(),
663            "guide"
664        );
665        assert_eq!(
666            package
667                .find_definition("workflow")
668                .unwrap()
669                .unwrap()
670                .content()
671                .unwrap(),
672            source
673        );
674        assert_eq!(
675            package
676                .find_definition("nested/readme.MD")
677                .unwrap()
678                .unwrap()
679                .content()
680                .unwrap(),
681            "nested"
682        );
683        assert!(package.find_definition("not-found").unwrap().is_none());
684        for path in ["", "/etc/passwd", "./guide.md", "nested/../guide.md"] {
685            assert!(package.find_definition(path).is_err());
686        }
687
688        // Source lookup uses the loaded snapshot, with installed names as a fallback:
689        fs::remove_file(context.join("guide.md")).unwrap();
690        assert_eq!(
691            package
692                .find_definition("guide")
693                .unwrap()
694                .unwrap()
695                .content()
696                .unwrap(),
697            "guide"
698        );
699        assert_eq!(
700            installer
701                .show_context_file("provider", "guide")
702                .unwrap()
703                .as_deref(),
704            Some(source)
705        );
706        fs::write(context.join("guide"), "exact source").unwrap();
707        fs::write(context.join("guide.md"), "markdown source").unwrap();
708        assert_eq!(
709            installer
710                .show_context_file("provider", "guide")
711                .unwrap()
712                .as_deref(),
713            Some("exact source")
714        );
715        assert!(installer.find_provider("missing").unwrap().is_none());
716        assert!(read_context_file(&context).is_err());
717    }
718
719    #[cfg(unix)]
720    #[test]
721    fn reports_context_removal_errors() {
722        use std::os::unix::fs::PermissionsExt;
723
724        let directory = tempdir().unwrap();
725        let parent = directory.path().join("context");
726        fs::create_dir(&parent).unwrap();
727        let destination = parent.join("installed");
728        fs::write(&destination, "previous context").unwrap();
729        fs::set_permissions(&parent, fs::Permissions::from_mode(0o555)).unwrap();
730
731        let result = remove_existing(&destination);
732        fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap();
733
734        assert!(result.unwrap_err().to_string().contains("cannot remove"));
735    }
736
737    #[cfg(unix)]
738    #[test]
739    fn rejects_context_file_symlinks_that_escape_the_provider() {
740        use std::os::unix::fs::symlink;
741
742        let directory = tempdir().unwrap();
743        let context = directory.path().join("context");
744        fs::create_dir_all(&context).unwrap();
745        let outside = directory.path().join("private.md");
746        fs::write(&outside, "secret").unwrap();
747        symlink(&outside, context.join("external.md")).unwrap();
748
749        let package = Package::load(Specification::for_test("provider", "1.0.0", context)).unwrap();
750        assert!(package.find_definition("external.md").unwrap().is_none());
751    }
752
753    #[cfg(unix)]
754    #[test]
755    fn reports_removal_errors_for_paths_beneath_files() {
756        let directory = tempdir().unwrap();
757        let blocker = directory.path().join("blocker");
758        fs::write(&blocker, "file").unwrap();
759        assert!(remove_existing(&blocker.join("child")).is_err());
760    }
761    #[test]
762    fn coordinates_ordinary_files_skills_and_empty_providers() {
763        let directory = tempdir().unwrap();
764        let root = directory.path();
765        let context = root.join("provider/context");
766        fs::create_dir_all(&context).unwrap();
767        fs::write(context.join("guide.md"), "# Guide\n").unwrap();
768        fs::write(context.join("data.txt"), "Opaque resource").unwrap();
769        fs::write(
770            context.join("workflow.md"),
771            "---\nmetadata: {type: skill}\ndescription: Workflow.\n---\n",
772        )
773        .unwrap();
774        let package = Specification::for_test("provider", "1.0.0", context.clone());
775        let installer = Installer::for_test(root, vec![package.clone()]);
776        assert_eq!(
777            installer
778                .show_context_file("provider", "data.txt")
779                .unwrap()
780                .as_deref(),
781            Some("Opaque resource")
782        );
783        assert!(
784            installer
785                .show_context_file("provider", "provider-workflow")
786                .unwrap()
787                .unwrap()
788                .contains("Workflow.")
789        );
790        assert!(
791            installer
792                .install(Some("missing"))
793                .unwrap_err()
794                .to_string()
795                .contains("no context found")
796        );
797        let ambiguous = Installer::for_test(root, vec![package.clone(), package]);
798        assert!(ambiguous.install(Some("provider")).is_err());
799        let loaded = installer.install(None).unwrap();
800        assert_eq!(loaded.context, ["provider@1.0.0"]);
801        assert_eq!(loaded.skills, ["provider-workflow"]);
802        fs::remove_file(context.join("guide.md")).unwrap();
803        fs::remove_file(context.join("data.txt")).unwrap();
804        assert!(installer.install(None).unwrap().context.is_empty());
805        assert!(installer.install(None).unwrap().context.is_empty());
806        fs::write(context.join("broken.md"), "---\n[invalid\n---\n").unwrap();
807        assert!(
808            installer
809                .show_context_file("provider", "provider-workflow")
810                .is_err()
811        );
812        assert!(installer.install(Some("provider")).is_err());
813    }
814
815    #[test]
816    fn reports_ordinary_context_write_failures_from_the_main_installation() {
817        let directory = tempdir().unwrap();
818        let root = directory.path();
819        let context = root.join("provider/context");
820        fs::create_dir_all(&context).unwrap();
821        fs::write(context.join("guide.md"), "# Guide\n").unwrap();
822        let package = Specification::for_test("provider", "1.0.0", context);
823        let installer = Installer::for_test(root, vec![package]);
824        let destination = root.join(".agents/context/provider@1.0.0/guide.md");
825        let _failure = fs::fail_once(fs::Operation::Write, move |path| path == destination);
826        assert!(
827            installer
828                .install(None)
829                .unwrap_err()
830                .to_string()
831                .contains("cannot write")
832        );
833    }
834}