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 crate::util::{merge_yaml, normalize_yaml};
pub 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,
});
}
}
let merged_caps = merge_capabilities(&contributors)?;
let mut merged_native = merged_caps.native.clone();
for (key, value) in native {
match merged_native.get_mut(key) {
Some(existing) => merge_yaml(existing, value.clone()),
None => {
let mut normalized = value.clone();
normalize_yaml(&mut normalized);
merged_native.insert(key.clone(), normalized);
}
}
}
Ok(MergedManifest {
agents_md: agents_md::concat(&agents_parts),
files,
rules: rule_files,
native: merged_native,
capabilities: merged_caps,
..MergedManifest::default()
})
}
const BUNDLE_YAML_KNOWN_KEYS: &[&str] = &[
"permissions",
"hooks",
"plugins",
"mcp",
"env",
"auto_memory_enabled",
"effort_level",
"advisor_size",
"native_permissions",
"native_hooks",
"native_plugins",
"native_mcp",
"native",
"features",
"host",
];
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 raw: serde_yaml::Value = serde_yaml::from_str(&s)
.map_err(|e| anyhow::anyhow!("bundle '{name}': parsing {}: {e}", path.display()))?;
if let Some(mapping) = raw.as_mapping() {
for key in mapping.keys() {
if let Some(k) = key.as_str()
&& !BUNDLE_YAML_KNOWN_KEYS.contains(&k)
{
anyhow::bail!(
"bundle '{name}': unknown key '{k}' in bundle.yaml — \
known keys: {}",
BUNDLE_YAML_KNOWN_KEYS.join(", ")
);
}
}
}
let mut caps: Capabilities = serde_yaml::from_value(raw)
.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());
}
let context = format!("bundle '{name}'");
for key in caps.env.keys() {
crate::config::validate_capabilities_env_key(&context, key)?;
}
if let Some(features) = &caps.features {
for mem in &features.memory {
if mem.when.is_empty() {
anyhow::bail!(
"{context}: features.memory entry for '{}' has no 'when' tags — every memory entry must declare at least one activation tag",
mem.server_host
);
}
if mem.listen_host.parse::<std::net::IpAddr>().is_err() {
anyhow::bail!(
"{context}: features.memory entry for '{}': listen_host '{}' is not a valid \
IP address literal (hostnames not supported)",
mem.server_host,
mem.listen_host
);
}
}
}
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(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use tempfile::tempdir;
#[test]
fn bundle_mcp_block_appears_in_merged_capabilities() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("mcp-bundle");
std::fs::create_dir_all(&bundle_dir).unwrap();
std::fs::write(
bundle_dir.join("bundle.yaml"),
concat!("mcp:\n", " - name: ctx\n", " command: ctx-mcp\n",),
)
.unwrap();
let bundle = BundleRef {
name: "mcp-bundle".into(),
path: bundle_dir,
precedence: 1,
};
let manifest = merge(&Capabilities::default(), &BTreeMap::new(), &[bundle]).unwrap();
assert_eq!(
manifest.capabilities.mcp.len(),
1,
"bundle mcp: entry must appear in merged capabilities"
);
assert_eq!(manifest.capabilities.mcp[0].name, "ctx");
}
#[test]
fn bundle_native_block_appears_in_merged_output() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("my-bundle");
std::fs::create_dir_all(&bundle_dir).unwrap();
std::fs::write(
bundle_dir.join("bundle.yaml"),
"native:\n claude_code:\n statusLine: bundle-value\n",
)
.unwrap();
let bundle = BundleRef {
name: "my-bundle".into(),
path: bundle_dir,
precedence: 1,
};
let manifest = merge(&Capabilities::default(), &BTreeMap::new(), &[bundle]).unwrap();
assert!(
manifest.native.contains_key("claude_code"),
"bundle native: block must appear in MergedManifest.native"
);
}
#[test]
fn top_level_native_wins_over_bundle_native_on_collision() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("b");
std::fs::create_dir_all(&bundle_dir).unwrap();
std::fs::write(
bundle_dir.join("bundle.yaml"),
"native:\n claude_code:\n key: from-bundle\n",
)
.unwrap();
let bundle = BundleRef {
name: "b".into(),
path: bundle_dir,
precedence: 1,
};
let mut top_native: BTreeMap<String, serde_yaml::Value> = BTreeMap::new();
top_native.insert(
"claude_code".to_string(),
serde_yaml::from_str("key: from-top").unwrap(),
);
let manifest = merge(&Capabilities::default(), &top_native, &[bundle]).unwrap();
let val = manifest.native["claude_code"]
.as_mapping()
.and_then(|m| m.get(serde_yaml::Value::String("key".into())))
.and_then(serde_yaml::Value::as_str)
.expect("key must be present");
assert_eq!(val, "from-top", "top-level native: must win over bundle");
}
#[test]
fn top_level_native_insert_is_normalized() {
let mut top_native: BTreeMap<String, serde_yaml::Value> = BTreeMap::new();
top_native.insert(
"claude_code".to_string(),
serde_yaml::from_str("seq:\n - one\n - two\n").unwrap(),
);
let manifest = merge(&Capabilities::default(), &top_native, &[]).unwrap();
let val = manifest
.native
.get("claude_code")
.expect("claude_code key must be present");
let re_serialized = serde_yaml::to_string(val).expect("must serialize");
assert!(
!re_serialized.contains("!!"),
"normalized value must not contain YAML tags: {re_serialized}"
);
}
#[test]
fn bundle_features_memory_appears_in_merged_capabilities() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("mem-bundle");
std::fs::create_dir_all(&bundle_dir).unwrap();
std::fs::write(
bundle_dir.join("bundle.yaml"),
concat!(
"features:\n",
" memory:\n",
" - server_host: still\n",
" port: 9092\n",
" when: [home]\n",
),
)
.unwrap();
let bundle = BundleRef {
name: "mem-bundle".into(),
path: bundle_dir,
precedence: 1,
};
let manifest = merge(&Capabilities::default(), &BTreeMap::new(), &[bundle]).unwrap();
let features = manifest
.capabilities
.features
.as_ref()
.expect("features must be present");
assert_eq!(features.memory.len(), 1);
assert_eq!(features.memory[0].server_host, "still");
}
#[test]
fn bundle_host_block_appears_in_merged_capabilities() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("host-bundle");
std::fs::create_dir_all(&bundle_dir).unwrap();
std::fs::write(
bundle_dir.join("bundle.yaml"),
concat!("host:\n", " still:\n", " addr: still.local\n",),
)
.unwrap();
let bundle = BundleRef {
name: "host-bundle".into(),
path: bundle_dir,
precedence: 1,
};
let manifest = merge(&Capabilities::default(), &BTreeMap::new(), &[bundle]).unwrap();
assert!(
manifest.capabilities.host.contains_key("still"),
"bundle host: entry must appear in merged capabilities"
);
assert_eq!(manifest.capabilities.host["still"].addr, "still.local");
}
#[test]
fn bundle_env_reserved_key_is_rejected() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("b");
std::fs::create_dir_all(&bundle_dir).unwrap();
std::fs::write(
bundle_dir.join("bundle.yaml"),
"env:\n CLAUDE_CONFIG_DIR: /bad\n",
)
.unwrap();
let bundle = BundleRef {
name: "b".into(),
path: bundle_dir,
precedence: 1,
};
let err = merge(&Capabilities::default(), &BTreeMap::new(), &[bundle]).unwrap_err();
let ve = err
.downcast_ref::<crate::config::ValidateError>()
.expect("should be ValidateError");
assert!(
matches!(
ve,
crate::config::ValidateError::CapabilitiesReservedEnvKey { key, .. }
if key == "CLAUDE_CONFIG_DIR"
),
"unexpected variant: {ve}"
);
}
#[test]
fn bundle_env_llmenv_prefix_is_rejected() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("b");
std::fs::create_dir_all(&bundle_dir).unwrap();
std::fs::write(
bundle_dir.join("bundle.yaml"),
"env:\n LLMENV_CUSTOM: bad\n",
)
.unwrap();
let bundle = BundleRef {
name: "b".into(),
path: bundle_dir,
precedence: 1,
};
let err = merge(&Capabilities::default(), &BTreeMap::new(), &[bundle]).unwrap_err();
let ve = err
.downcast_ref::<crate::config::ValidateError>()
.expect("should be ValidateError");
assert!(
matches!(
ve,
crate::config::ValidateError::CapabilitiesLlmenvPrefixEnvKey { key, .. }
if key == "LLMENV_CUSTOM"
),
"unexpected variant: {ve}"
);
}
#[test]
fn bundle_env_invalid_var_name_is_rejected() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("b");
std::fs::create_dir_all(&bundle_dir).unwrap();
std::fs::write(bundle_dir.join("bundle.yaml"), "env:\n 1INVALID: bad\n").unwrap();
let bundle = BundleRef {
name: "b".into(),
path: bundle_dir,
precedence: 1,
};
let err = merge(&Capabilities::default(), &BTreeMap::new(), &[bundle]).unwrap_err();
let ve = err
.downcast_ref::<crate::config::ValidateError>()
.expect("should be ValidateError");
assert!(
matches!(
ve,
crate::config::ValidateError::CapabilitiesInvalidVarName { key, .. }
if key == "1INVALID"
),
"unexpected variant: {ve}"
);
}
#[test]
fn bundle_yaml_unknown_key_errors() {
let tmp = tempdir().unwrap();
let bundle_dir = tmp.path().join("bad-bundle");
std::fs::create_dir_all(&bundle_dir).unwrap();
std::fs::write(
bundle_dir.join("bundle.yaml"),
"typo_key:\n value: oops\n",
)
.unwrap();
let bundle = BundleRef {
name: "bad-bundle".into(),
path: bundle_dir,
precedence: 1,
};
let err = merge(&Capabilities::default(), &BTreeMap::new(), &[bundle]).unwrap_err();
assert!(
err.to_string().contains("unknown key"),
"must report unknown key, got: {err}"
);
assert!(
err.to_string().contains("typo_key"),
"error must name the unknown key, got: {err}"
);
}
}