bake-agent-context 0.2.0

Install context files from Cargo dependencies for coding agents
Documentation
// Released under the MIT License.
// Copyright, 2026, by Samuel Williams.

use bake::{Error, Result};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const BEGIN_MARKER: &str = "# BEGIN bake-agent-context";
const END_MARKER: &str = "# END bake-agent-context";

pub(crate) struct Update {
    path: PathBuf,
    original: String,
    updated: String,
}

impl Update {
    pub(crate) fn apply(self) -> Result<()> {
        if self.original == self.updated {
            return Ok(());
        }

        if let Some(parent) = self.path.parent() {
            fs::create_dir_all(parent).map_err(|error| {
                Error::new(format!("cannot create {}: {error}", parent.display()))
            })?;
        }

        fs::write(&self.path, self.updated)
            .map_err(|error| Error::new(format!("cannot update {}: {error}", self.path.display())))
    }
}

/// Prepare local Git excludes for generated context and installed skills.
///
/// These rules live in `.git/info/exclude`, so projects do not need to commit
/// ignore entries for generated local files.
pub(crate) fn prepare(
    root: &Path,
    skill_names: impl IntoIterator<Item = String>,
) -> Result<Option<Update>> {
    let Some(path) = local_exclude_path(root)? else {
        return Ok(None);
    };

    let skill_names: BTreeSet<_> = skill_names.into_iter().collect();
    Ok(Some(prepare_local_exclude(path, &skill_names)?))
}

fn local_exclude_path(root: &Path) -> Result<Option<PathBuf>> {
    let output = match Command::new("git")
        .args(["rev-parse", "--git-path", "info/exclude"])
        .current_dir(root)
        .output()
    {
        Ok(output) => output,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => {
            return Err(Error::new(format!(
                "cannot locate Git exclude file: {error}"
            )));
        }
    };

    if !output.status.success() {
        // Context can still be installed from a source archive or other
        // directory which is not a Git checkout.
        return Ok(None);
    }

    let path = String::from_utf8(output.stdout)
        .map_err(|error| Error::new(format!("Git returned an invalid exclude path: {error}")))?;
    let path = PathBuf::from(path.trim());
    Ok(Some(if path.is_absolute() {
        path
    } else {
        root.join(path)
    }))
}

fn prepare_local_exclude(path: PathBuf, skill_names: &BTreeSet<String>) -> Result<Update> {
    let original = match fs::read_to_string(&path) {
        Ok(contents) => contents,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(error) => {
            return Err(Error::new(format!(
                "cannot read {}: {error}",
                path.display()
            )));
        }
    };

    let mut lines = remove_managed_block(&original, &path)?
        .lines()
        .map(str::to_owned)
        .collect::<Vec<_>>();
    while lines.last().is_some_and(|line| line.is_empty()) {
        lines.pop();
    }
    if !lines.is_empty() {
        lines.push(String::new());
    }

    let mut block = vec![
        BEGIN_MARKER.to_owned(),
        "# Generated by Bake Agent Context; do not edit this block manually.".to_owned(),
        "/.agents/context/".to_owned(),
        "/.agents/skills/.agent-context-skills.json".to_owned(),
    ];
    block.extend(
        skill_names
            .iter()
            .map(|name| format!("/.agents/skills/{name}/")),
    );
    block.push(END_MARKER.to_owned());

    lines.extend(block);
    let mut updated = lines.join("\n");
    updated.push('\n');

    Ok(Update {
        path,
        original,
        updated,
    })
}

fn remove_managed_block(contents: &str, path: &Path) -> Result<String> {
    let mut lines: Vec<String> = contents.lines().map(str::to_owned).collect();
    let begin_positions: Vec<_> = lines
        .iter()
        .enumerate()
        .filter(|(_, line)| line.trim() == BEGIN_MARKER)
        .map(|(index, _)| index)
        .collect();
    let end_positions: Vec<_> = lines
        .iter()
        .enumerate()
        .filter(|(_, line)| line.trim() == END_MARKER)
        .map(|(index, _)| index)
        .collect();

    match (begin_positions.as_slice(), end_positions.as_slice()) {
        ([], []) => {}
        ([begin], [end]) if begin < end => {
            lines.drain(*begin..=*end);
        }
        _ => {
            return Err(Error::new(format!(
                "{} has an incomplete or duplicated bake-agent-context ignore block",
                path.display()
            )));
        }
    }

    while lines.last().is_some_and(|line| line.is_empty()) {
        lines.pop();
    }

    if lines.is_empty() {
        Ok(String::new())
    } else {
        let mut output = lines.join("\n");
        output.push('\n');
        Ok(output)
    }
}