pub mod agents_md;
pub mod capabilities;
pub mod rules;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::config::Capabilities;
use crate::mcp::resolve::ResolvedMcp;
use crate::plugins::resolve::{ResolvedMarketplace, ResolvedPlugin};
use capabilities::{CapabilityContributor, merge_capabilities};
use rules::RuleFile;
#[derive(Debug, Clone)]
pub struct BundleRef {
pub name: String,
pub path: PathBuf,
pub precedence: u8,
}
#[derive(Debug, Clone, Default)]
pub struct MergedManifest {
pub agents_md: String,
pub files: BTreeMap<PathBuf, PathBuf>,
pub rules: Vec<RuleFile>,
pub mcps: Vec<ResolvedMcp>,
pub plugins: Vec<ResolvedPlugin>,
pub marketplaces: Vec<ResolvedMarketplace>,
pub capabilities: Capabilities,
pub native: std::collections::BTreeMap<String, serde_yaml::Value>,
}
const COPIED_SUBDIRS: &[&str] = &["skills", "plugins", "hooks"];
const TOP_LEVEL_PRECEDENCE: u8 = u8::MAX;
pub fn merge(
top_level: &Capabilities,
native: &BTreeMap<String, serde_yaml::Value>,
bundles: &[BundleRef],
) -> anyhow::Result<MergedManifest> {
let mut agents_parts = Vec::new();
let mut files = BTreeMap::new();
let mut rule_files: Vec<RuleFile> = Vec::new();
let mut contributors: Vec<CapabilityContributor> = Vec::new();
if !top_level.is_empty() {
contributors.push(CapabilityContributor {
name: "config.yaml".to_string(),
precedence: TOP_LEVEL_PRECEDENCE,
capabilities: top_level.clone(),
});
}
for b in bundles {
let am = b.path.join("AGENTS.md");
if am.exists() {
agents_parts.push((b.name.clone(), std::fs::read_to_string(&am)?));
}
for sub in COPIED_SUBDIRS {
let dir = b.path.join(sub);
if !dir.exists() {
continue;
}
walk(&b.path, &dir, &mut files)?;
}
rule_files.extend(rules::collect_from_bundle(&b.path, &b.name)?);
if let Some(caps) = read_bundle_yaml(&b.path, &b.name)? {
contributors.push(CapabilityContributor {
name: format!("bundle '{}'", b.name),
precedence: b.precedence,
capabilities: caps,
});
}
}
Ok(MergedManifest {
agents_md: agents_md::concat(&agents_parts),
files,
rules: rule_files,
native: native.clone(),
capabilities: merge_capabilities(&contributors)?,
..MergedManifest::default()
})
}
fn read_bundle_yaml(bundle_root: &Path, name: &str) -> anyhow::Result<Option<Capabilities>> {
let path = bundle_root.join("bundle.yaml");
if !path.exists() {
return Ok(None);
}
let s = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("bundle '{name}': reading {}: {e}", path.display()))?;
let mut caps: Capabilities = serde_yaml::from_str(&s)
.map_err(|e| anyhow::anyhow!("bundle '{name}': parsing {}: {e}", path.display()))?;
for hook in &mut caps.hooks {
hook.bundle_origin = Some(bundle_root.to_path_buf());
}
Ok(Some(caps))
}
fn walk(
bundle_root: &Path,
dir: &Path,
out: &mut BTreeMap<PathBuf, PathBuf>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
let p = entry.path();
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
walk(bundle_root, &p, out)?;
} else if file_type.is_file() {
let rel = p
.strip_prefix(bundle_root)
.map_err(|e| anyhow::anyhow!("path {} not under bundle root: {e}", p.display()))?
.to_path_buf();
out.insert(rel, p);
}
}
Ok(())
}