Skip to main content

bake_agent_context/
lib.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4//! Bake tasks and APIs for consuming context directories shipped by Cargo packages.
5mod exclude;
6mod index;
7mod installer;
8mod skill;
9#[cfg(test)]
10mod test_filesystem;
11
12pub use index::ContextIndex;
13pub use installer::{ContextFile, ContextPackage, Installer};
14pub use skill::{Skill, install_skills, list_skills};
15
16use bake::{Context, Error, Result};
17
18fn installer(context: &mut Context) -> Result<Installer> {
19    if let Some(installer) = context.get::<Installer>() {
20        return Ok(installer.clone());
21    }
22
23    let installer = Installer::new(context.root())?;
24    context.insert(installer.clone());
25    Ok(installer)
26}
27
28/// List dependency crates that ship context, or list one crate's context files.
29#[bake::task(name = "agent:context:list")]
30pub fn list(context: &mut Context, package: Option<String>) -> Result<String> {
31    let installer = installer(context)?;
32
33    if let Some(package) = package {
34        let Some(package) = installer.find_package(&package)? else {
35            return Ok(format!("No context found for crate '{package}'"));
36        };
37
38        let files = installer.list_context_files(&package)?;
39        if files.is_empty() {
40            return Ok(format!(
41                "No context files found for crate '{}'.",
42                package.selector()
43            ));
44        }
45        let mut output = format!("Context files for crate '{}':", package.selector());
46        for file in files {
47            output.push_str(&format!("\n  {}", file.path.display()));
48        }
49        return Ok(output);
50    }
51
52    let mut packages = Vec::new();
53    for package in installer.packages() {
54        if !installer.list_context_files(package)?.is_empty() {
55            packages.push(package);
56        }
57    }
58    if packages.is_empty() {
59        return Ok("No dependency context files found".to_owned());
60    }
61
62    let mut output = String::from("Crates with context available:");
63    for package in packages {
64        output.push_str(&format!("\n  {} ({})", package.selector(), package.version));
65    }
66    Ok(output)
67}
68
69/// Show a context file from one resolved dependency crate.
70#[bake::task(name = "agent:context:show")]
71pub fn show(
72    context: &mut Context,
73    #[bake(named)] package: String,
74    #[bake(named)] file: String,
75) -> Result<String> {
76    let installer = installer(context)?;
77    let Some(content) = installer.show_context_file(&package, &file)? else {
78        return Err(Error::new(format!(
79            "context file {file:?} was not found in crate {package:?}"
80        )));
81    };
82    Ok(content)
83}
84
85/// Install context and skills from one crate or all dependencies, then update the context index.
86#[bake::task(name = "agent:context:install")]
87pub fn install(context: &mut Context, package: Option<String>) -> Result<String> {
88    let installer = installer(context)?;
89    let installed_context = if let Some(package) = package.as_deref() {
90        if installer.install_package(package)? {
91            vec![package.to_owned()]
92        } else {
93            Vec::new()
94        }
95    } else {
96        installer.install_all()?
97    };
98    let installed_skills = install_skills(&installer, package.as_deref(), None)?;
99
100    ContextIndex::new(context.root())
101        .with_packages(installer.packages())
102        .update_index()?;
103
104    let mut output = Vec::new();
105    if !installed_context.is_empty() {
106        output.push(format!(
107            "Installed context from: {}",
108            installed_context.join(", ")
109        ));
110    }
111    if !installed_skills.is_empty() {
112        output.push(format!("Installed skills: {}", installed_skills.join(", ")));
113    }
114
115    if output.is_empty() {
116        Ok("No dependency context or skills were installed".to_owned())
117    } else {
118        Ok(output.join("\n"))
119    }
120}
121
122/// List skills declared by dependency context documents.
123#[bake::task(name = "agent:context:skill:list")]
124pub fn skill_list(context: &mut Context, package: Option<String>) -> Result<String> {
125    let installer = installer(context)?;
126    let skills = list_skills(&installer, package.as_deref())?;
127    if skills.is_empty() {
128        return Ok("No dependency skills found".to_owned());
129    }
130
131    let mut output = String::from("Available dependency skills:");
132    for skill in skills {
133        output.push_str(&format!(
134            "\n  {} ({}) — {}",
135            skill.name,
136            skill.package_selector(),
137            skill.description
138        ));
139    }
140    Ok(output)
141}
142
143/// Install skills from all dependencies or select a crate and/or package-prefixed skill name.
144#[bake::task(name = "agent:context:skill:install")]
145pub fn skill_install(
146    context: &mut Context,
147    package: Option<String>,
148    skill: Option<String>,
149) -> Result<String> {
150    let installer = installer(context)?;
151    let installed = install_skills(&installer, package.as_deref(), skill.as_deref())?;
152    if installed.is_empty() {
153        Ok("No dependency skills were installed".to_owned())
154    } else {
155        Ok(format!("Installed skills: {}", installed.join(", ")))
156    }
157}
158
159/// Create or update `.agents/context/index.md` from installed context files.
160#[bake::task(name = "agent:context:index")]
161pub fn index(context: &mut Context) -> Result<String> {
162    let installer = installer(context)?;
163    ContextIndex::new(context.root())
164        .with_packages(installer.packages())
165        .update_index()?;
166    Ok("Updated .agents/context/index.md".to_owned())
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::fs;
173    use std::path::Path;
174    use tempfile::tempdir;
175
176    fn make_context(root: &Path, packages: Vec<ContextPackage>) -> Context {
177        let mut context = bake::Registry::new().context(root);
178        context.insert(Installer::for_test(root, packages));
179        context
180    }
181
182    fn package(root: &Path, name: &str, version: &str) -> ContextPackage {
183        ContextPackage::for_test(name, version, root.join(name).join("context"))
184    }
185
186    fn write(path: &Path, contents: &str) {
187        fs::create_dir_all(path.parent().unwrap()).unwrap();
188        fs::write(path, contents).unwrap();
189    }
190
191    #[test]
192    fn task_wrappers_return_outputs_and_propagate_errors() {
193        let directory = tempdir().unwrap();
194        let root = directory.path();
195        let guide = package(root, "provider", "1.0.0");
196        write(&guide.context_path.join("guide.md"), "# Guide\n");
197        write(
198            &guide.context_path.join("broken.md"),
199            "---\ntype: guide\n---\n# Unsupported\n",
200        );
201        let mut context = make_context(root, vec![guide.clone()]);
202
203        assert!(
204            list(&mut context, Some("unknown".to_owned()))
205                .unwrap()
206                .contains("No context found")
207        );
208        assert!(list(&mut context, None).is_err());
209        assert!(list(&mut context, Some("provider".to_owned())).is_err());
210        assert!(install(&mut context, None).is_err());
211        assert!(show(&mut context, "provider".to_owned(), "missing.md".to_owned()).is_err());
212        assert!(install(&mut context, Some("provider".to_owned())).is_err());
213        assert!(skill_list(&mut context, Some("provider".to_owned())).is_err());
214        assert!(skill_install(&mut context, Some("provider".to_owned()), None).is_err());
215
216        let mut duplicates = make_context(
217            root,
218            vec![
219                package(root, "duplicate", "1.0.0"),
220                package(root, "duplicate", "2.0.0"),
221            ],
222        );
223        assert!(list(&mut duplicates, Some("duplicate".to_owned())).is_err());
224        assert!(
225            show(
226                &mut duplicates,
227                "duplicate".to_owned(),
228                "guide.md".to_owned()
229            )
230            .is_err()
231        );
232        assert!(
233            duplicates
234                .get::<Installer>()
235                .unwrap()
236                .install_package("duplicate")
237                .is_err()
238        );
239
240        let listing_package = package(root, "listing", "1.0.0");
241        write(
242            &listing_package.context_path.join("guide.md"),
243            "# Listing\n",
244        );
245        let mut listing_context = make_context(root, vec![listing_package]);
246        assert!(
247            list(&mut listing_context, None)
248                .unwrap()
249                .contains("listing@1.0.0 (1.0.0)")
250        );
251
252        let empty_package = package(root, "empty", "1.0.0");
253        fs::create_dir_all(&empty_package.context_path).unwrap();
254        let mut empty_context = make_context(root, vec![empty_package]);
255        assert!(
256            list(&mut empty_context, None)
257                .unwrap()
258                .contains("No dependency context files")
259        );
260    }
261
262    #[test]
263    fn task_wrappers_propagate_install_and_index_failures() {
264        let directory = tempdir().unwrap();
265        let root = directory.path();
266        let provider = package(root, "provider", "1.0.0");
267        write(&provider.context_path.join("guide.md"), "# Guide\n");
268
269        let registry = root.join(".agents/skills/.agent-context-skills.json");
270        write(&registry, r#"{"version":1,"skills":{}}"#);
271        let failure_path = registry.clone();
272        let _failure = test_filesystem::fail_once(test_filesystem::Operation::Read, move |path| {
273            path == failure_path
274        });
275        let mut context = make_context(root, vec![provider.clone()]);
276        assert!(install(&mut context, Some("provider".to_owned())).is_err());
277        drop(_failure);
278
279        let index_file = root.join(".agents/context/index.md");
280        fs::create_dir_all(&index_file).unwrap();
281        let mut context = make_context(root, Vec::new());
282        assert!(install(&mut context, None).is_err());
283        assert!(index(&mut context).is_err());
284
285        let mut empty = make_context(root, Vec::new());
286        assert!(
287            list(&mut empty, None)
288                .unwrap()
289                .contains("No dependency context files")
290        );
291        assert_eq!(
292            skill_list(&mut empty, None).unwrap(),
293            "No dependency skills found"
294        );
295        assert_eq!(
296            skill_install(&mut empty, None, None).unwrap(),
297            "No dependency skills were installed"
298        );
299    }
300
301    #[test]
302    fn installer_creation_errors_are_returned_by_tasks() {
303        let directory = tempdir().unwrap();
304        let mut context = bake::Registry::new().context(directory.path());
305        assert!(list(&mut context, None).is_err());
306        assert!(show(&mut context, "provider".to_owned(), "guide.md".to_owned()).is_err());
307        assert!(install(&mut context, None).is_err());
308        assert!(skill_list(&mut context, None).is_err());
309        assert!(skill_install(&mut context, None, None).is_err());
310        assert!(index(&mut context).is_err());
311    }
312}