use std::path::{Path, PathBuf};
use anyhow::Context;
use serde_json::json;
use super::AgentAdapter;
use crate::mcp::resolve::{MEMORY_MCP_NAME, ResolvedKind, ResolvedMcp};
use crate::merge::MergedManifest;
use crate::plugins::resolve::ResolvedMarketplace;
use crate::util::{dedup, merge_json};
const ICM_MCP_NAME: &str = MEMORY_MCP_NAME;
const STALE_CHECK_COMMAND: &str = "llmenv check-stale";
#[derive(Debug, Default, Clone, Copy)]
pub struct ClaudeCodeAdapter;
impl AgentAdapter for ClaudeCodeAdapter {
fn name(&self) -> &'static str {
"claude-code"
}
fn env_vars(&self, cache_dir: &Path) -> anyhow::Result<Vec<(String, String)>> {
let dir = cache_dir.to_str().ok_or_else(|| {
anyhow::anyhow!("cache_dir is not valid UTF-8: {}", cache_dir.display())
})?;
Ok(vec![("CLAUDE_CONFIG_DIR".into(), dir.to_owned())])
}
fn materialize(&self, manifest: &MergedManifest, out: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut owned: Vec<PathBuf> = Vec::new();
std::fs::create_dir_all(out)?;
crate::paths::write_owner_only(&out.join("CLAUDE.md"), manifest.agents_md.as_bytes())?;
owned.push(PathBuf::from("CLAUDE.md"));
for r in &manifest.rules {
if crate::paths::is_unsafe_join_target(r.rel.to_string_lossy().as_ref()) {
anyhow::bail!("path traversal in rules file: {}", r.rel.display());
}
let dest = out.join(&r.rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
crate::paths::write_owner_only(&dest, r.raw.as_bytes())?;
owned.push(r.rel.clone());
}
for (rel, abs) in &manifest.files {
if crate::paths::is_unsafe_join_target(rel.to_string_lossy().as_ref()) {
anyhow::bail!("path traversal in bundle file: {}", rel.display());
}
let dest = out.join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
if is_hook_json(rel) {
let raw = std::fs::read_to_string(abs)?;
let rendered = raw.replace("{{ICM_MCP}}", ICM_MCP_NAME);
crate::paths::write_owner_only(&dest, rendered.as_bytes())?;
} else {
std::fs::copy(abs, &dest)?;
}
owned.push(rel.clone());
}
validate_skills(out)?;
generate_settings_json(out, manifest)?;
owned.push(PathBuf::from("settings.json"));
let native_mcp = manifest.capabilities.native_mcp.get("claude_code");
if !manifest.mcps.is_empty() || native_mcp.is_some() {
merge_mcp_into_claude_json(out, &manifest.mcps, native_mcp)?;
}
Ok(owned)
}
fn emit_hook_context(&self, text: &str) -> String {
if text.is_empty() {
return String::new();
}
let wrapped = format!("[ICM MEMORY CONTEXT (auto-injected)]\n{}", text);
serde_json::json!({
"hookSpecificOutput": { "additionalContext": wrapped }
})
.to_string()
}
}
fn overlay_native(
dst: &mut serde_json::Value,
fragment: Option<&serde_yaml::Value>,
) -> anyhow::Result<()> {
if let Some(frag) = fragment {
let as_json: serde_json::Value =
serde_json::to_value(frag).context("converting native fragment to JSON")?;
merge_json(dst, as_json);
}
Ok(())
}
const MODELED_SETTINGS_KEYS: [&str; 2] = ["permissions", "hooks"];
fn reject_modeled_keys_in_catch_all(fragment: &serde_yaml::Value) -> anyhow::Result<()> {
let Some(map) = fragment.as_mapping() else {
return Ok(());
};
for key in MODELED_SETTINGS_KEYS {
if map.contains_key(serde_yaml::Value::String(key.into())) {
anyhow::bail!(
"top-level `native.claude_code` carries the modeled-feature key \
`{key}`, which would silently clobber the rendered `{key}` \
(a security regression for permissions). Move it to the \
`native_{key}` sibling instead, which merges in the safe direction."
);
}
}
Ok(())
}
fn is_hook_json(rel: &Path) -> bool {
rel.starts_with("hooks") && rel.extension().is_some_and(|e| e == "json")
}
const CLAUDE_JSON_FILE: &str = ".claude.json";
fn remote_type_str(transport: crate::config::McpTransport) -> &'static str {
use crate::config::McpTransport;
match transport {
McpTransport::Sse => "sse",
McpTransport::Http | McpTransport::Stdio => "http",
}
}
fn build_mcp_servers(
mcps: &[ResolvedMcp],
) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
let mut servers = serde_json::Map::new();
let mut server_sources: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for (idx, m) in mcps.iter().enumerate() {
let entry = match &m.kind {
ResolvedKind::Stdio { command, args, env } => {
let mut obj = json!({ "command": command, "args": args });
if !env.is_empty() {
obj["env"] = json!(env);
}
obj
}
ResolvedKind::Remote { url, transport } => {
json!({ "type": remote_type_str(*transport), "url": url })
}
};
if let Some(&prev_idx) = server_sources.get(&m.name)
&& let Some(existing_entry) = servers.get(&m.name)
&& existing_entry != &entry
{
anyhow::bail!(
"true semantic conflict: MCP server '{}' defined twice with \
different content. First definition (entry #{}) differs from \
second definition (entry #{}). Resolve by removing or renaming \
one server definition.",
m.name,
prev_idx,
idx,
);
}
server_sources.insert(m.name.clone(), idx);
servers.insert(m.name.clone(), entry);
}
Ok(servers)
}
fn merge_mcp_into_claude_json(
out: &Path,
mcps: &[ResolvedMcp],
native: Option<&serde_yaml::Value>,
) -> anyhow::Result<()> {
let servers = build_mcp_servers(mcps)?;
let mut doc = json!({ "mcpServers": servers });
overlay_native(&mut doc, native)?;
let llmenv_servers = match doc.get("mcpServers").and_then(|v| v.as_object()) {
Some(s) if !s.is_empty() => s.clone(),
_ => return Ok(()),
};
let path = out.join(CLAUDE_JSON_FILE);
let mut claude = read_claude_json(&path)?;
let Some(obj) = claude.as_object_mut() else {
anyhow::bail!(
"existing {} is not a JSON object; refusing to overwrite (would \
destroy Claude state). Fix or remove the file and re-run.",
path.display()
);
};
let servers_val = obj
.entry("mcpServers")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
match servers_val.as_object_mut() {
Some(servers_obj) => {
for (name, entry) in llmenv_servers {
servers_obj.insert(name, entry);
}
}
None => {
*servers_val = serde_json::Value::Object(llmenv_servers);
}
}
crate::paths::write_owner_only_atomic(
&path,
serde_json::to_string_pretty(&claude)?.as_bytes(),
)?;
Ok(())
}
fn read_claude_json(path: &Path) -> anyhow::Result<serde_json::Value> {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).with_context(|| {
format!(
"existing {} is not valid JSON; refusing to overwrite (would \
destroy Claude state). Fix or remove the file and re-run.",
path.display()
)
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(serde_json::Value::Object(serde_json::Map::new()))
}
Err(e) => Err(anyhow::anyhow!("reading {}: {e}", path.display())),
}
}
fn validate_skills(out: &Path) -> anyhow::Result<()> {
let skills_dir = out.join("skills");
if !skills_dir.exists() {
return Ok(());
}
for entry in std::fs::read_dir(&skills_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
return Err(anyhow::anyhow!(
"Skill directory {} missing SKILL.md",
path.display()
));
}
let content = std::fs::read_to_string(&skill_md)?;
if let Some(frontmatter_end) = content.find("\n---\n").or_else(|| {
if content.ends_with("---") {
Some(content.len() - 3)
} else {
None
}
}) {
let frontmatter_str = &content[3..frontmatter_end];
match serde_yaml::from_str::<serde_yaml::Mapping>(frontmatter_str) {
Ok(mapping) => {
let has_name = mapping.get("name").is_some();
let has_description = mapping.get("description").is_some();
if !has_name || !has_description {
return Err(anyhow::anyhow!(
"Skill {} SKILL.md missing required frontmatter fields (name and description)",
path.display()
));
}
}
Err(e) => {
return Err(anyhow::anyhow!(
"Skill {} SKILL.md has invalid YAML frontmatter: {}",
path.display(),
e
));
}
}
} else {
return Err(anyhow::anyhow!(
"Skill {} SKILL.md missing YAML frontmatter (must start with --- and end with ---)",
path.display()
));
}
}
Ok(())
}
fn resolve_bundle_relative_paths(command: &str, bundle_dir: &Path) -> Option<String> {
let mut resolved = false;
let mut result = String::new();
for (i, token) in command.split_whitespace().enumerate() {
if i > 0 {
result.push(' ');
}
if token.contains('/')
&& !token.starts_with('/')
&& !token.starts_with('~')
&& !token.starts_with('$')
&& !token.starts_with('-')
&& !crate::paths::is_unsafe_join_target(token)
{
let abs_path = bundle_dir.join(token);
result.push_str(&abs_path.to_string_lossy());
resolved = true;
} else {
result.push_str(token);
}
}
if resolved { Some(result) } else { None }
}
fn generate_settings_json(out: &Path, manifest: &MergedManifest) -> anyhow::Result<()> {
let mut settings = serde_json::Map::new();
let mut hooks_by_event: std::collections::BTreeMap<String, Vec<serde_json::Value>> =
std::collections::BTreeMap::new();
for hook in &manifest.capabilities.hooks {
let resolved_command = if let Some(cmd) = &hook.handler.command {
if let Some(bundle_dir) = &hook.bundle_origin {
resolve_bundle_relative_paths(cmd, bundle_dir).or_else(|| Some(cmd.clone()))
} else {
Some(cmd.clone())
}
} else {
None
};
let handler = json!({
"command": resolved_command,
"tool": hook.handler.tool,
"type": match hook.handler.kind {
crate::config::HookHandlerKind::Command => "command",
crate::config::HookHandlerKind::McpTool => "mcp_tool",
},
});
let mut hook_entry = serde_json::Map::new();
if let Some(matcher) = &hook.matcher {
hook_entry.insert("matcher".into(), json!(matcher));
}
hook_entry.insert("hooks".into(), json!([handler]));
hooks_by_event
.entry(hook.event.clone())
.or_default()
.push(serde_json::Value::Object(hook_entry));
}
hooks_by_event
.entry("SessionStart".to_string())
.or_default()
.push(json!({
"hooks": [{ "type": "command", "command": STALE_CHECK_COMMAND }],
}));
let mut hooks_obj = serde_json::Map::new();
for (event, entries) in hooks_by_event {
hooks_obj.insert(event, json!(entries));
}
let mut hooks_value = serde_json::Value::Object(hooks_obj);
overlay_native(
&mut hooks_value,
manifest.capabilities.native_hooks.get("claude_code"),
)?;
settings.insert("hooks".into(), hooks_value);
let perms = &manifest.capabilities.permissions;
let native = manifest.capabilities.native_permissions.get("claude_code");
let native_ask: std::collections::BTreeSet<&str> = native.map_or_else(Default::default, |n| {
n.ask.iter().map(String::as_str).collect()
});
let native_deny: std::collections::BTreeSet<&str> = native.map_or_else(Default::default, |n| {
n.deny.iter().map(String::as_str).collect()
});
let suppressors = |action: PermissionAction| -> Vec<&std::collections::BTreeSet<&str>> {
match action {
PermissionAction::Allow => vec![&native_deny, &native_ask],
PermissionAction::Ask => vec![&native_deny],
PermissionAction::Deny => Vec::new(),
}
};
let render_action = |neutral: &[crate::config::PermissionRule],
native_rules: &[String],
action: PermissionAction| {
let outranking = suppressors(action);
let mut out: Vec<String> = Vec::new();
for rule in neutral {
for s in render_permission_rule(rule) {
let outranked = outranking.iter().any(|set| set.contains(s.as_str()));
if outranked && !native_rules.contains(&s) {
continue;
}
out.push(s);
}
}
out.extend(native_rules.iter().cloned());
dedup(&mut out);
out
};
let allow = render_action(
&perms.allow,
native.map_or(&[], |n| &n.allow),
PermissionAction::Allow,
);
let ask = render_action(
&perms.ask,
native.map_or(&[], |n| &n.ask),
PermissionAction::Ask,
);
let deny = render_action(
&perms.deny,
native.map_or(&[], |n| &n.deny),
PermissionAction::Deny,
);
let has_perms =
!allow.is_empty() || !ask.is_empty() || !deny.is_empty() || perms.default_mode.is_some();
if has_perms {
let mut perm_obj = serde_json::Map::new();
if let Some(mode) = perms.default_mode {
perm_obj.insert("defaultMode".into(), json!(permission_mode_str(mode)));
}
perm_obj.insert("allow".into(), json!(allow));
perm_obj.insert("ask".into(), json!(ask));
perm_obj.insert("deny".into(), json!(deny));
settings.insert("permissions".into(), serde_json::Value::Object(perm_obj));
}
let icm_active = manifest.mcps.iter().any(|m| m.name == ICM_MCP_NAME);
if icm_active {
settings.insert("autoMemoryEnabled".into(), json!(false));
}
render_plugins(&mut settings, manifest);
let mut settings_value = serde_json::Value::Object(settings);
overlay_native(
&mut settings_value,
manifest.capabilities.native_plugins.get("claude_code"),
)?;
if let Some(native) = manifest.native.get("claude_code") {
reject_modeled_keys_in_catch_all(native)?;
}
overlay_native(&mut settings_value, manifest.native.get("claude_code"))?;
let settings_path = out.join("settings.json");
let reconciled = reconcile_settings(&settings_path, settings_value)?;
let json_str = serde_json::to_string_pretty(&reconciled)?;
crate::paths::write_owner_only_atomic(&settings_path, json_str.as_bytes()).with_context(
|| {
format!(
"Failed to write settings.json at {}",
settings_path.display()
)
},
)?;
Ok(())
}
const LLMENV_OWNED_SETTINGS_KEYS: [&str; 5] = [
"permissions",
"enabledPlugins",
"extraKnownMarketplaces",
"autoMemoryEnabled",
"hooks",
];
fn reconcile_settings(path: &Path, fresh: serde_json::Value) -> anyhow::Result<serde_json::Value> {
let existing = match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes).ok(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(anyhow::anyhow!(
"reading existing settings.json {}: {e}",
path.display()
));
}
};
let Some(mut merged) = existing else {
return Ok(fresh);
};
let Some(merged_obj) = merged.as_object_mut() else {
return Ok(fresh);
};
let fresh_obj = match &fresh {
serde_json::Value::Object(o) => o,
_ => return Ok(fresh),
};
for key in LLMENV_OWNED_SETTINGS_KEYS {
match fresh_obj.get(key) {
Some(fresh_val) if key == "hooks" => {
merged_obj
.get_mut(key)
.map(|v| merge_json(v, fresh_val.clone()))
.or_else(|| {
merged_obj.insert(key.to_string(), fresh_val.clone());
Some(())
});
}
Some(fresh_val) => {
merged_obj.insert(key.to_string(), fresh_val.clone());
}
None => {
merged_obj.remove(key);
}
}
}
Ok(merged)
}
fn render_marketplace_source(mk: &ResolvedMarketplace) -> Option<serde_json::Value> {
if crate::config::is_reserved_official_marketplace(&mk.name) {
let (owner, repo) = crate::config::github_owner_repo(&mk.source)?;
return Some(json!({
"source": { "source": "github", "repo": format!("{owner}/{repo}") }
}));
}
let location = mk.install_location.as_ref()?;
Some(json!({ "source": { "source": "directory", "path": location } }))
}
fn render_plugins(
settings: &mut serde_json::Map<String, serde_json::Value>,
manifest: &MergedManifest,
) {
if manifest.marketplaces.is_empty() && manifest.plugins.is_empty() {
return;
}
let mut markets = serde_json::Map::new();
for mk in &manifest.marketplaces {
let Some(body) = render_marketplace_source(mk) else {
continue;
};
markets.insert(mk.name.clone(), body);
}
if !markets.is_empty() {
settings.insert(
"extraKnownMarketplaces".into(),
serde_json::Value::Object(markets),
);
}
let mut enabled = serde_json::Map::new();
for p in &manifest.plugins {
enabled.insert(format!("{}@{}", p.plugin, p.marketplace), json!(true));
}
if !enabled.is_empty() {
settings.insert("enabledPlugins".into(), serde_json::Value::Object(enabled));
}
}
fn render_permission_rule(rule: &crate::config::PermissionRule) -> Vec<String> {
if let Some(pattern) = &rule.pattern {
return vec![format!("{}({})", rule.tool, pattern)];
}
if !rule.paths.is_empty() {
return rule
.paths
.iter()
.map(|p| format!("{}({})", rule.tool, p))
.collect();
}
vec![rule.tool.clone()]
}
fn permission_mode_str(mode: crate::config::PermissionMode) -> &'static str {
use crate::config::PermissionMode;
match mode {
PermissionMode::AcceptEdits => "acceptEdits",
PermissionMode::Plan => "plan",
PermissionMode::Default => "default",
PermissionMode::BypassPermissions => "bypassPermissions",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionAction {
Allow,
Ask,
Deny,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::{
CLAUDE_JSON_FILE, MODELED_SETTINGS_KEYS, is_hook_json, merge_mcp_into_claude_json,
overlay_native, reconcile_settings, reject_modeled_keys_in_catch_all,
render_marketplace_source, render_permission_rule,
};
use crate::config::PermissionRule;
use crate::mcp::resolve::{ResolvedKind, ResolvedMcp};
use crate::plugins::resolve::ResolvedMarketplace;
use proptest::prelude::*;
use std::path::PathBuf;
fn marketplace(name: &str, source: &str, install: Option<&str>) -> ResolvedMarketplace {
ResolvedMarketplace {
name: name.into(),
source: source.into(),
install_location: install.map(Into::into),
head: None,
}
}
#[test]
fn reserved_marketplace_renders_github_source_not_directory() {
let mk = marketplace(
"claude-plugins-official",
"https://github.com/anthropics/claude-code",
Some("/cache/marketplaces/claude-plugins-official"),
);
let src = render_marketplace_source(&mk).expect("reserved renders a source");
assert_eq!(src["source"]["source"], serde_json::json!("github"));
assert_eq!(
src["source"]["repo"],
serde_json::json!("anthropics/claude-code")
);
assert!(
src["source"].get("path").is_none(),
"no directory path for github source"
);
}
#[test]
fn reserved_marketplace_entry_matches_claude_code_shape_exactly() {
let mk = marketplace(
"claude-plugins-official",
"https://github.com/anthropics/claude-code",
None,
);
let src = render_marketplace_source(&mk).expect("reserved renders");
assert_eq!(
src,
serde_json::json!({
"source": { "source": "github", "repo": "anthropics/claude-code" }
})
);
}
#[test]
fn non_reserved_marketplace_renders_directory_source() {
let mk = marketplace(
"superpowers",
"https://github.com/example/superpowers",
Some("/cache/marketplaces/superpowers"),
);
let src = render_marketplace_source(&mk).expect("synced marketplace renders");
assert_eq!(src["source"]["source"], serde_json::json!("directory"));
assert_eq!(
src["source"]["path"],
serde_json::json!("/cache/marketplaces/superpowers")
);
}
#[test]
fn non_reserved_marketplace_without_install_location_is_skipped() {
let mk = marketplace(
"superpowers",
"https://github.com/example/superpowers",
None,
);
assert!(render_marketplace_source(&mk).is_none());
}
#[test]
fn reserved_marketplace_renders_github_even_without_install_location() {
let mk = marketplace(
"claude-plugins-official",
"git@github.com:anthropics/claude-code.git",
None,
);
let src = render_marketplace_source(&mk).expect("reserved renders without sync");
assert_eq!(
src["source"]["repo"],
serde_json::json!("anthropics/claude-code")
);
}
proptest! {
#[test]
fn pattern_renders_single_tool_pattern_string(
tool in "[A-Za-z]{1,12}",
pattern in "[^()]{0,20}",
paths in proptest::collection::vec("[^()]{0,10}", 0..3),
) {
let rule = PermissionRule { tool: tool.clone(), pattern: Some(pattern.clone()), paths };
let out = render_permission_rule(&rule);
prop_assert_eq!(out, vec![format!("{tool}({pattern})")]);
}
#[test]
fn paths_render_one_string_each_in_order(
tool in "[A-Za-z]{1,12}",
paths in proptest::collection::vec("[^()]{1,10}", 1..5),
) {
let rule = PermissionRule { tool: tool.clone(), pattern: None, paths: paths.clone() };
let out = render_permission_rule(&rule);
let expected: Vec<String> = paths.iter().map(|p| format!("{tool}({p})")).collect();
prop_assert_eq!(out, expected);
}
#[test]
fn bare_tool_renders_tool_name(tool in "[A-Za-z]{1,12}") {
let rule = PermissionRule { tool: tool.clone(), pattern: None, paths: Vec::new() };
prop_assert_eq!(render_permission_rule(&rule), vec![tool]);
}
#[test]
fn rendering_is_deterministic(
tool in "[A-Za-z]{1,12}",
pattern in proptest::option::of("[^()]{0,20}"),
paths in proptest::collection::vec("[^()]{0,10}", 0..4),
) {
let rule = PermissionRule { tool, pattern, paths };
prop_assert_eq!(render_permission_rule(&rule), render_permission_rule(&rule));
}
#[test]
fn overlay_native_none_is_noop(seed in 0u64..1000) {
let mut dst = serde_json::json!({ "k": seed, "nested": { "a": [1, 2] } });
let before = dst.clone();
overlay_native(&mut dst, None).unwrap();
prop_assert_eq!(dst, before);
}
#[test]
fn overlay_native_is_idempotent(frag in arb_yaml_value(3)) {
let mut base = serde_json::json!({ "existing": "value", "list": ["x"] });
let mut once = base.clone();
overlay_native(&mut once, Some(&frag)).unwrap();
overlay_native(&mut base, Some(&frag)).unwrap();
overlay_native(&mut base, Some(&frag)).unwrap();
prop_assert_eq!(base, once);
}
#[test]
fn overlay_native_never_panics(frag in arb_yaml_value(4)) {
let mut dst = serde_json::json!({});
let _ = overlay_native(&mut dst, Some(&frag));
}
#[test]
fn reject_modeled_keys_accepts_non_mappings(frag in arb_non_mapping_yaml()) {
prop_assert!(reject_modeled_keys_in_catch_all(&frag).is_ok());
}
#[test]
fn reject_modeled_keys_accepts_unmodeled_mappings(
keys in proptest::collection::vec("[a-z]{1,10}", 0..6),
) {
let mut map = serde_yaml::Mapping::new();
for k in keys {
if MODELED_SETTINGS_KEYS.contains(&k.as_str()) {
continue; }
map.insert(serde_yaml::Value::String(k), serde_yaml::Value::Bool(true));
}
let frag = serde_yaml::Value::Mapping(map);
prop_assert!(reject_modeled_keys_in_catch_all(&frag).is_ok());
}
#[test]
fn reject_modeled_keys_rejects_any_modeled_key(
modeled_idx in 0usize..MODELED_SETTINGS_KEYS.len(),
extra_keys in proptest::collection::vec("[a-z]{1,8}", 0..4),
) {
let mut map = serde_yaml::Mapping::new();
for k in extra_keys {
map.insert(serde_yaml::Value::String(k), serde_yaml::Value::Null);
}
let modeled = MODELED_SETTINGS_KEYS[modeled_idx];
map.insert(
serde_yaml::Value::String(modeled.to_owned()),
serde_yaml::Value::Null,
);
let frag = serde_yaml::Value::Mapping(map);
let err = reject_modeled_keys_in_catch_all(&frag);
prop_assert!(err.is_err());
prop_assert!(err.unwrap_err().to_string().contains(modeled));
}
#[test]
fn is_hook_json_matches_spec(
first in "[a-z]{1,8}",
mid in proptest::collection::vec("[a-z]{1,6}", 0..3),
stem in "[a-z]{1,8}",
ext in proptest::option::of("[a-z]{1,5}"),
) {
let mut p = PathBuf::from(&first);
for c in &mid {
p.push(c);
}
let file = match &ext {
Some(e) => format!("{stem}.{e}"),
None => stem.clone(),
};
p.push(&file);
let expected = first == "hooks" && ext.as_deref() == Some("json");
prop_assert_eq!(is_hook_json(&p), expected);
}
#[test]
fn is_hook_json_is_deterministic(raw in ".{0,40}") {
let p = PathBuf::from(&raw);
prop_assert_eq!(is_hook_json(&p), is_hook_json(&p));
}
#[test]
fn merge_mcp_roundtrips_distinct_servers(mcps in arb_distinct_mcps()) {
let dir = tempfile::tempdir().unwrap();
merge_mcp_into_claude_json(dir.path(), &mcps, None).unwrap();
if mcps.is_empty() {
prop_assert!(!dir.path().join(CLAUDE_JSON_FILE).exists());
return Ok(());
}
let raw = std::fs::read_to_string(dir.path().join(CLAUDE_JSON_FILE)).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let servers = doc.get("mcpServers").and_then(|v| v.as_object()).unwrap();
prop_assert_eq!(servers.len(), mcps.len());
for m in &mcps {
let entry = servers.get(&m.name).unwrap();
match &m.kind {
ResolvedKind::Stdio { command, args, env } => {
prop_assert_eq!(entry.get("command").unwrap(), command);
let got_args: Vec<&str> = entry
.get("args")
.and_then(|v| v.as_array())
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
prop_assert_eq!(got_args, args.iter().map(String::as_str).collect::<Vec<_>>());
if env.is_empty() {
prop_assert!(entry.get("env").is_none());
} else {
let got_env = entry.get("env").and_then(|v| v.as_object()).unwrap();
prop_assert_eq!(got_env.len(), env.len());
for (k, v) in env {
prop_assert_eq!(got_env.get(k).unwrap().as_str().unwrap(), v);
}
}
}
ResolvedKind::Remote { url, transport } => {
prop_assert_eq!(entry.get("url").unwrap(), url);
let want = match transport {
crate::config::McpTransport::Sse => "sse",
_ => "http",
};
prop_assert_eq!(entry.get("type").unwrap().as_str().unwrap(), want);
}
}
}
}
#[test]
fn merge_mcp_empty_overlay_is_deterministic(mcps in arb_distinct_mcps()) {
let empty = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
let dir_a = tempfile::tempdir().unwrap();
merge_mcp_into_claude_json(dir_a.path(), &mcps, Some(&empty)).unwrap();
let a = std::fs::read_to_string(dir_a.path().join(CLAUDE_JSON_FILE)).ok();
let dir_b = tempfile::tempdir().unwrap();
merge_mcp_into_claude_json(dir_b.path(), &mcps, Some(&empty)).unwrap();
let b = std::fs::read_to_string(dir_b.path().join(CLAUDE_JSON_FILE)).ok();
prop_assert_eq!(a, b);
}
#[cfg(unix)]
#[test]
fn merge_mcp_writes_owner_only_permissions(mcps in arb_distinct_mcps()) {
use std::os::unix::fs::PermissionsExt;
prop_assume!(!mcps.is_empty());
let dir = tempfile::tempdir().unwrap();
merge_mcp_into_claude_json(dir.path(), &mcps, None).unwrap();
let mode = std::fs::metadata(dir.path().join(CLAUDE_JSON_FILE))
.unwrap()
.permissions()
.mode();
prop_assert_eq!(mode & 0o077, 0, "group/other bits set: {:o}", mode);
}
#[test]
fn merge_mcp_serde_roundtrip(mcps in arb_distinct_mcps()) {
prop_assume!(!mcps.is_empty());
let dir = tempfile::tempdir().unwrap();
merge_mcp_into_claude_json(dir.path(), &mcps, None).unwrap();
let raw = std::fs::read_to_string(dir.path().join(CLAUDE_JSON_FILE)).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).expect("parse");
let reserialized = serde_json::to_string_pretty(&doc).expect("reserialize");
let doc2: serde_json::Value = serde_json::from_str(&reserialized).expect("reparse");
prop_assert_eq!(doc, doc2);
}
}
fn arb_yaml_value(depth: u32) -> impl Strategy<Value = serde_yaml::Value> {
let leaf = prop_oneof![
Just(serde_yaml::Value::Null),
any::<bool>().prop_map(serde_yaml::Value::Bool),
any::<i64>().prop_map(|n| serde_yaml::Value::Number(n.into())),
"[a-z]{0,8}".prop_map(serde_yaml::Value::String),
];
leaf.prop_recursive(depth, 16, 4, |inner| {
prop_oneof![
proptest::collection::vec(inner.clone(), 0..4)
.prop_map(serde_yaml::Value::Sequence),
proptest::collection::vec(("[a-z]{1,6}", inner), 0..4).prop_map(|pairs| {
let mut m = serde_yaml::Mapping::new();
for (k, v) in pairs {
m.insert(serde_yaml::Value::String(k), v);
}
serde_yaml::Value::Mapping(m)
}),
]
})
}
fn arb_non_mapping_yaml() -> impl Strategy<Value = serde_yaml::Value> {
prop_oneof![
Just(serde_yaml::Value::Null),
any::<bool>().prop_map(serde_yaml::Value::Bool),
any::<i64>().prop_map(|n| serde_yaml::Value::Number(n.into())),
"[a-z]{0,8}".prop_map(serde_yaml::Value::String),
proptest::collection::vec("[a-z]{0,6}".prop_map(serde_yaml::Value::String), 0..4)
.prop_map(serde_yaml::Value::Sequence),
]
}
fn arb_distinct_mcps() -> impl Strategy<Value = Vec<ResolvedMcp>> {
proptest::collection::vec(arb_mcp(), 0..5).prop_map(|mcps| {
let mut seen = std::collections::BTreeSet::new();
mcps.into_iter()
.filter(|m| seen.insert(m.name.clone()))
.collect()
})
}
fn arb_mcp() -> impl Strategy<Value = ResolvedMcp> {
let stdio = (
"[a-z][a-z0-9_-]{0,10}",
"[a-z]{1,8}",
proptest::collection::vec("[a-z]{0,6}", 0..3),
proptest::collection::btree_map("[A-Z][A-Z_]{0,5}", "[a-z0-9]{0,8}", 0..3),
)
.prop_map(|(name, command, args, env)| ResolvedMcp {
name,
kind: ResolvedKind::Stdio { command, args, env },
});
let remote =
("[a-z][a-z0-9_-]{0,10}", "https://[a-z]{1,8}\\.test").prop_map(|(name, url)| {
ResolvedMcp {
name,
kind: ResolvedKind::Remote {
url,
transport: crate::config::McpTransport::Http,
},
}
});
prop_oneof![stdio, remote]
}
fn write_json(path: &std::path::Path, v: &serde_json::Value) {
std::fs::write(path, serde_json::to_vec_pretty(v).unwrap()).unwrap();
}
#[test]
fn reconcile_absent_file_returns_fresh_verbatim() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
let fresh = serde_json::json!({ "permissions": { "deny": ["X"] } });
let out = reconcile_settings(&path, fresh.clone()).unwrap();
assert_eq!(
out, fresh,
"no prior file → llmenv's render is the whole truth"
);
}
#[test]
fn reconcile_preserves_foreign_top_level_keys() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
write_json(
&path,
&serde_json::json!({
"permissions": { "deny": ["STALE"] },
"contextModeState": { "session": "abc" }
}),
);
let fresh = serde_json::json!({ "permissions": { "deny": ["FRESH"] } });
let out = reconcile_settings(&path, fresh).unwrap();
assert_eq!(out["permissions"]["deny"], serde_json::json!(["FRESH"]));
assert_eq!(out["contextModeState"]["session"], "abc");
}
#[test]
fn reconcile_unions_hooks_so_plugin_registration_survives() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
write_json(
&path,
&serde_json::json!({
"hooks": { "SessionStart": [{ "command": "plugin-hook" }] }
}),
);
let fresh = serde_json::json!({
"hooks": { "SessionStart": [{ "command": "llmenv-hook" }] }
});
let out = reconcile_settings(&path, fresh).unwrap();
let entries = out["hooks"]["SessionStart"].as_array().unwrap();
let cmds: Vec<&str> = entries
.iter()
.filter_map(|e| e["command"].as_str())
.collect();
assert!(
cmds.contains(&"plugin-hook"),
"plugin hook survives: {cmds:?}"
);
assert!(
cmds.contains(&"llmenv-hook"),
"llmenv hook present: {cmds:?}"
);
}
#[test]
fn reconcile_hooks_union_dedups_across_renders() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
let llmenv_hook = serde_json::json!({
"hooks": { "SessionStart": [{ "command": "llmenv-hook" }] }
});
write_json(&path, &llmenv_hook);
let out = reconcile_settings(&path, llmenv_hook.clone()).unwrap();
let entries = out["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(entries.len(), 1, "identical hook deduped, not doubled");
}
#[test]
fn reconcile_drops_owned_key_llmenv_no_longer_renders() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
write_json(
&path,
&serde_json::json!({ "enabledPlugins": { "old@market": true } }),
);
let fresh = serde_json::json!({ "permissions": { "deny": [] } });
let out = reconcile_settings(&path, fresh).unwrap();
assert!(
out.get("enabledPlugins").is_none(),
"stale owned key cleared on re-render"
);
}
#[test]
fn reconcile_corrupt_file_falls_back_to_fresh() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(&path, b"{ not valid json").unwrap();
let fresh = serde_json::json!({ "permissions": { "deny": ["X"] } });
let out = reconcile_settings(&path, fresh.clone()).unwrap();
assert_eq!(out, fresh);
}
fn stdio_mcp(name: &str, command: &str) -> ResolvedMcp {
ResolvedMcp {
name: name.into(),
kind: ResolvedKind::Stdio {
command: command.into(),
args: vec![],
env: std::collections::BTreeMap::new(),
},
}
}
fn remote_mcp(name: &str, url: &str, transport: crate::config::McpTransport) -> ResolvedMcp {
ResolvedMcp {
name: name.into(),
kind: ResolvedKind::Remote {
url: url.into(),
transport,
},
}
}
#[test]
fn merge_mcp_preserves_foreign_keys_and_servers() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(CLAUDE_JSON_FILE);
write_json(
&path,
&serde_json::json!({
"oauthAccount": { "email": "x@y.z" },
"numStartups": 42,
"mcpServers": { "user-added": { "command": "foo" } }
}),
);
merge_mcp_into_claude_json(tmp.path(), &[stdio_mcp("icm", "icm-bin")], None).unwrap();
let doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(doc["oauthAccount"]["email"], "x@y.z");
assert_eq!(doc["numStartups"], 42);
assert_eq!(doc["mcpServers"]["user-added"]["command"], "foo");
assert_eq!(doc["mcpServers"]["icm"]["command"], "icm-bin");
}
#[test]
fn merge_mcp_remote_entry_carries_type() {
let tmp = tempfile::tempdir().unwrap();
merge_mcp_into_claude_json(
tmp.path(),
&[remote_mcp(
"icm",
"http://still.local:9092/mcp",
crate::config::McpTransport::Http,
)],
None,
)
.unwrap();
let doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(tmp.path().join(CLAUDE_JSON_FILE)).unwrap())
.unwrap();
assert_eq!(doc["mcpServers"]["icm"]["type"], "http");
assert_eq!(
doc["mcpServers"]["icm"]["url"],
"http://still.local:9092/mcp"
);
}
#[test]
fn merge_mcp_sse_remote_emits_sse_type() {
let tmp = tempfile::tempdir().unwrap();
merge_mcp_into_claude_json(
tmp.path(),
&[remote_mcp(
"ev",
"http://h/sse",
crate::config::McpTransport::Sse,
)],
None,
)
.unwrap();
let doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(tmp.path().join(CLAUDE_JSON_FILE)).unwrap())
.unwrap();
assert_eq!(doc["mcpServers"]["ev"]["type"], "sse");
}
#[test]
fn merge_mcp_creates_file_when_absent() {
let tmp = tempfile::tempdir().unwrap();
merge_mcp_into_claude_json(tmp.path(), &[stdio_mcp("icm", "icm-bin")], None).unwrap();
let doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(tmp.path().join(CLAUDE_JSON_FILE)).unwrap())
.unwrap();
assert_eq!(doc["mcpServers"]["icm"]["command"], "icm-bin");
assert!(doc.as_object().unwrap().len() == 1, "only mcpServers key");
}
#[test]
fn merge_mcp_refuses_to_clobber_corrupt_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(CLAUDE_JSON_FILE);
std::fs::write(&path, b"{ not valid json").unwrap();
let err = merge_mcp_into_claude_json(tmp.path(), &[stdio_mcp("icm", "icm-bin")], None)
.unwrap_err();
assert!(
err.to_string().contains("not valid JSON"),
"expected refusal, got: {err}"
);
assert_eq!(std::fs::read(&path).unwrap(), b"{ not valid json");
}
#[test]
fn merge_mcp_no_servers_no_native_leaves_no_file() {
let tmp = tempfile::tempdir().unwrap();
merge_mcp_into_claude_json(tmp.path(), &[], None).unwrap();
assert!(!tmp.path().join(CLAUDE_JSON_FILE).exists());
}
#[test]
fn merge_mcp_overlays_native_server_fragment() {
let tmp = tempfile::tempdir().unwrap();
let native: serde_yaml::Value =
serde_yaml::from_str("mcpServers:\n extra:\n command: native-bin\n").unwrap();
merge_mcp_into_claude_json(tmp.path(), &[stdio_mcp("icm", "icm-bin")], Some(&native))
.unwrap();
let doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(tmp.path().join(CLAUDE_JSON_FILE)).unwrap())
.unwrap();
assert_eq!(doc["mcpServers"]["icm"]["command"], "icm-bin");
assert_eq!(doc["mcpServers"]["extra"]["command"], "native-bin");
assert!(doc.get("enabledMcpjsonServers").is_none());
}
}