use std::path::{Path, PathBuf};
pub fn ensure_global_config_dir(
managed_root: &Path,
claude_config_dir: &Path,
) -> anyhow::Result<PathBuf> {
std::fs::create_dir_all(claude_config_dir)?;
super::settings_defaults::ensure_settings_defaults(claude_config_dir)?;
super::hooks::ensure_managed_hooks(claude_config_dir)?;
seed_credentials(claude_config_dir);
ensure_mcp_config(claude_config_dir)?;
deploy_agents_and_skills(managed_root, claude_config_dir)?;
Ok(claude_config_dir.to_path_buf())
}
fn deploy_agents_and_skills(managed_root: &Path, claude_config_dir: &Path) -> anyhow::Result<()> {
let agents_src = managed_root.join("framework").join("agents");
let skills_src = managed_root.join("framework").join("skills");
let agents_dest = claude_config_dir.join("agents");
let skills_dest = claude_config_dir.join("skills");
let agents_missing = !agents_src.exists();
let skills_missing = !skills_src.exists();
if agents_missing && skills_missing {
eprintln!(
"note: no bundled agents or skills found under {}/framework; \
run `tm install` to populate them",
managed_root.display()
);
} else {
if agents_missing {
eprintln!(
"note: no bundled agents found at {}; run `tm install` to populate them",
agents_src.display()
);
}
if skills_missing {
eprintln!(
"note: no bundled skills found at {}; run `tm install` to populate them",
skills_src.display()
);
}
}
if !agents_missing {
crate::core::agent_deployer::deploy_agents(&agents_src, &agents_dest)
.map_err(|e| anyhow::anyhow!("failed to deploy agents into managed config dir: {e}"))?;
}
crate::core::skill_tiers::deploy_all_skill_tiers(
&skills_src,
&managed_root.join("skills"),
&skills_dest,
|_| true,
)
.map_err(|e| anyhow::anyhow!("failed to deploy skills into managed config dir: {e}"))?;
crate::core::output_style_deployer::deploy_output_styles(claude_config_dir).map_err(|e| {
anyhow::anyhow!("failed to deploy output styles into managed config dir: {e}")
})?;
Ok(())
}
pub(crate) fn seed_credentials_from(
src: &std::path::Path,
dst: &std::path::Path,
) -> anyhow::Result<()> {
if !src.exists() {
eprintln!(
"note: {} not found; MCP OAuth tokens will not be pre-seeded \
(primary auth is via `tm login` or ANTHROPIC_API_KEY, not this file)",
src.display()
);
return Ok(());
}
if !dst.exists() {
std::fs::copy(src, dst).map_err(|e| anyhow::anyhow!("failed to copy credentials: {e}"))?;
}
Ok(())
}
fn seed_credentials(claude_config_dir: &Path) {
let Some(home) = dirs::home_dir() else {
eprintln!("warning: cannot resolve home directory; skipping credential seed");
return;
};
let src = home.join(".claude").join(".credentials.json");
let dst = claude_config_dir.join(".credentials.json");
if let Err(e) = seed_credentials_from(&src, &dst) {
eprintln!("warning: {e}");
}
}
fn ensure_mcp_config(claude_config_dir: &Path) -> anyhow::Result<()> {
let mcp_path = claude_config_dir.join(".mcp.json");
let existing_value: Option<serde_json::Value> = std::fs::read_to_string(&mcp_path)
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.filter(|v| v.is_object());
let mut config = existing_value
.clone()
.unwrap_or_else(|| serde_json::json!({}));
let servers = config
.as_object_mut()
.expect("config is an object")
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
if !servers.is_object() {
*servers = serde_json::json!({});
}
let servers = servers.as_object_mut().expect("mcpServers is an object");
for name in crate::core::mcp_config::BUILTIN_MANAGED_MCP_SERVERS {
if let Some(entry) = crate::core::mcp_config::builtin_server_entry(name) {
servers.entry(*name).or_insert(entry);
}
}
let needs_write = existing_value.as_ref() != Some(&config);
if needs_write {
let serialized = serde_json::to_string_pretty(&config)?;
std::fs::write(&mcp_path, &serialized)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_global_config_dir_ensure_idempotent() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let claude_config = managed_root.join("claude-config");
ensure_global_config_dir(&managed_root, &claude_config).unwrap();
assert!(claude_config.join("settings.json").exists());
assert!(claude_config.join(".mcp.json").exists());
ensure_global_config_dir(&managed_root, &claude_config).unwrap();
assert!(claude_config.join("settings.json").exists());
}
#[test]
fn test_global_config_dir_seeds_output_style_and_status_line() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let claude_config = managed_root.join("claude-config");
ensure_global_config_dir(&managed_root, &claude_config).unwrap();
let text = std::fs::read_to_string(claude_config.join("settings.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val["outputStyle"].as_str(),
Some(crate::core::session_launch::OUTPUT_STYLE),
"fresh tm-owned settings.json must have outputStyle seeded"
);
assert!(
val["statusLine"]["command"]
.as_str()
.is_some_and(|c| c.ends_with(" statusline")),
"fresh tm-owned settings.json must have statusLine seeded"
);
let mut with_theme = val.clone();
with_theme["theme"] = serde_json::json!("dark");
std::fs::write(
claude_config.join("settings.json"),
serde_json::to_string_pretty(&with_theme).unwrap(),
)
.unwrap();
ensure_global_config_dir(&managed_root, &claude_config).unwrap();
let text_after = std::fs::read_to_string(claude_config.join("settings.json")).unwrap();
let val_after: serde_json::Value = serde_json::from_str(&text_after).unwrap();
assert_eq!(
val_after["theme"].as_str(),
Some("dark"),
"client-persisted keys must survive re-provisioning"
);
assert_eq!(
val_after["outputStyle"].as_str(),
Some(crate::core::session_launch::OUTPUT_STYLE)
);
}
#[test]
fn test_mcp_config_contains_all_three_servers() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
ensure_mcp_config(&cfg).unwrap();
let text = std::fs::read_to_string(cfg.join(".mcp.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let servers = val["mcpServers"].as_object().unwrap();
assert!(
servers.contains_key("trusty-memory"),
"trusty-memory must be defined in .mcp.json"
);
assert!(
servers.contains_key("trusty-review"),
"trusty-review must be defined in .mcp.json (WI-8)"
);
assert!(
servers.contains_key("trusty-search"),
"trusty-search must be defined in .mcp.json"
);
}
#[test]
fn test_mcp_config_review_server_entry() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
ensure_mcp_config(&cfg).unwrap();
let text = std::fs::read_to_string(cfg.join(".mcp.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let review = &val["mcpServers"]["trusty-review"];
assert_eq!(
review["type"].as_str(),
Some("stdio"),
"trusty-review transport must be stdio"
);
assert_eq!(
review["command"].as_str(),
Some("trusty-review"),
"trusty-review command must be 'trusty-review'"
);
let args: Vec<&str> = review["args"]
.as_array()
.expect("args must be an array")
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(
args,
vec!["serve", "--stdio"],
"trusty-review args must be [\"serve\", \"--stdio\"]"
);
}
#[serial_test::serial]
#[test]
fn test_mcp_config_review_no_home_write() {
struct HomeGuard(Option<String>);
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0 {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
let root = TempDir::new().unwrap();
let cfg = root.path().join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let fake_home = TempDir::new().unwrap();
let _home_guard = {
let prev = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", fake_home.path()) };
HomeGuard(prev)
};
ensure_mcp_config(&cfg).unwrap();
assert!(
cfg.join(".mcp.json").exists(),
"ensure_mcp_config must write .mcp.json inside the given dir"
);
assert!(
!root.path().join(".mcp.json").exists(),
"ensure_mcp_config must NOT write .mcp.json outside the given dir (isolation)"
);
assert!(
!fake_home.path().join(".mcp.json").exists(),
"ensure_mcp_config must NOT write .mcp.json to $HOME (isolation)"
);
assert!(
!fake_home.path().join(".claude").exists(),
"ensure_mcp_config must NOT create $HOME/.claude/ (isolation)"
);
}
#[test]
fn test_mcp_config_no_spurious_write_when_unchanged() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
let mcp_path = cfg.join(".mcp.json");
ensure_mcp_config(&cfg).unwrap();
let bytes_after_first = std::fs::read(&mcp_path).unwrap();
ensure_mcp_config(&cfg).unwrap();
let bytes_after_second = std::fs::read(&mcp_path).unwrap();
assert_eq!(
bytes_after_first, bytes_after_second,
"ensure_mcp_config must not rewrite .mcp.json when content is unchanged (#1550 item 3)"
);
}
#[test]
fn test_mcp_config_trailing_newline_does_not_trigger_rewrite() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
let mcp_path = cfg.join(".mcp.json");
ensure_mcp_config(&cfg).unwrap();
let canonical = std::fs::read_to_string(&mcp_path).unwrap();
let with_trailing_newline = format!("{canonical}\n");
std::fs::write(&mcp_path, with_trailing_newline.as_bytes()).unwrap();
let bytes_before = std::fs::read(&mcp_path).unwrap();
ensure_mcp_config(&cfg).unwrap();
let bytes_after = std::fs::read(&mcp_path).unwrap();
assert_eq!(
bytes_before, bytes_after,
"trailing newline in .mcp.json must not trigger a spurious rewrite \
(structural comparison must tolerate formatting differences, refs #1550 Finding-1)"
);
}
#[test]
fn test_mcp_config_idempotent_content() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
ensure_mcp_config(&cfg).unwrap();
let text_first = std::fs::read_to_string(cfg.join(".mcp.json")).unwrap();
ensure_mcp_config(&cfg).unwrap();
let text_second = std::fs::read_to_string(cfg.join(".mcp.json")).unwrap();
assert_eq!(
text_first, text_second,
"ensure_mcp_config must produce byte-identical output on repeated calls (#1550 item 3)"
);
}
#[test]
fn test_seed_credentials_from_missing_src_is_ok() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("nonexistent.json");
let dst = tmp.path().join("dst.json");
let result = seed_credentials_from(&src, &dst);
assert!(result.is_ok(), "missing src must return Ok, not an error");
assert!(!dst.exists(), "dst must not be created when src is missing");
}
#[test]
fn test_seed_credentials_from_copies_when_dst_missing() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src.json");
let dst = tmp.path().join("dst.json");
std::fs::write(&src, r#"{"token":"initial"}"#).unwrap();
seed_credentials_from(&src, &dst).unwrap();
assert!(dst.exists(), "dst must be created when it was missing");
let content = std::fs::read_to_string(&dst).unwrap();
assert_eq!(
content, r#"{"token":"initial"}"#,
"dst content must match src content after first-time copy"
);
}
#[test]
fn test_seed_credentials_from_skips_when_dst_already_exists() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src.json");
let dst = tmp.path().join("dst.json");
std::fs::write(&src, r#"{"token":"real-home"}"#).unwrap();
std::fs::write(&dst, r#"{"token":"managed-custom"}"#).unwrap();
seed_credentials_from(&src, &dst).unwrap();
let content = std::fs::read_to_string(&dst).unwrap();
assert_eq!(
content, r#"{"token":"managed-custom"}"#,
"existing managed credential must NOT be overwritten (#1550 item 2)"
);
}
#[test]
fn test_seed_credentials_skips_when_source_missing() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let claude_config = managed_root.join("claude-config");
ensure_global_config_dir(&managed_root, &claude_config).unwrap();
assert!(claude_config.join("settings.json").exists());
}
#[test]
fn test_deploy_agents_and_skills_populates_config_dir() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let claude_config = managed_root.join("claude-config");
let agents_src = managed_root.join("framework").join("agents");
std::fs::create_dir_all(&agents_src).unwrap();
std::fs::write(
agents_src.join("my-agent.md"),
"---\nname: my-agent\nrole: engineer\n---\n\n# My Agent\n\nAgent content.\n",
)
.unwrap();
let skills_src = managed_root.join("framework").join("skills");
std::fs::create_dir_all(&skills_src).unwrap();
std::fs::write(
skills_src.join("my-skill.md"),
"---\nname: my-skill\n---\n\n# My Skill\n\nSkill content.\n",
)
.unwrap();
ensure_global_config_dir(&managed_root, &claude_config).unwrap();
let agent_path = claude_config.join("agents").join("my-agent.md");
assert!(
agent_path.exists(),
"agent must be deployed to config_dir/agents/my-agent.md"
);
let agent_content = std::fs::read_to_string(&agent_path).unwrap();
assert!(
agent_content.contains("Agent content."),
"deployed agent must contain the source content"
);
let skill_path = claude_config
.join("skills")
.join("my-skill")
.join("SKILL.md");
assert!(
skill_path.exists(),
"skill must be deployed to config_dir/skills/my-skill/SKILL.md"
);
let skill_content = std::fs::read_to_string(&skill_path).unwrap();
assert!(
skill_content.contains("Skill content."),
"deployed skill must contain the source content"
);
assert!(
claude_config
.join("agents")
.join(".trusty-mpm-manifest.json")
.exists(),
"agent manifest must exist after deploy"
);
}
#[test]
fn test_deploy_agents_and_skills_includes_user_tier_skill() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let claude_config = managed_root.join("claude-config");
let user_skills_src = managed_root.join("skills");
std::fs::create_dir_all(&user_skills_src).unwrap();
std::fs::write(
user_skills_src.join("my-custom-skill.md"),
"---\nname: my-custom-skill\n---\n\nUSER CUSTOM.\n",
)
.unwrap();
ensure_global_config_dir(&managed_root, &claude_config).unwrap();
assert!(
claude_config
.join("skills/my-custom-skill/SKILL.md")
.exists(),
"user-custom skill must be deployed to the tm-global config dir \
even with no bundled framework skill source present"
);
}
#[test]
fn test_deploy_agents_and_skills_missing_source_is_ok() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let claude_config = managed_root.join("claude-config");
let result = ensure_global_config_dir(&managed_root, &claude_config);
assert!(
result.is_ok(),
"missing framework source dirs must not cause an error: {result:?}"
);
assert!(
claude_config.join("settings.json").exists(),
"settings.json must exist even when no framework is installed"
);
assert!(
!claude_config.join("agents").exists(),
"agents dir must not be created when source is missing"
);
assert!(
!claude_config.join("skills").exists(),
"skills dir must not be created when source is missing"
);
assert!(
claude_config.join("output-styles").exists(),
"output-styles dir must be created even when no framework is installed"
);
}
#[test]
fn test_deploy_output_styles_wired_into_config_dir() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let claude_config = managed_root.join("claude-config");
ensure_global_config_dir(&managed_root, &claude_config).unwrap();
let styles_dir = claude_config.join("output-styles");
assert!(
styles_dir.exists(),
"output-styles dir must exist after ensure_global_config_dir"
);
for style in crate::core::bundle::OUTPUT_STYLES {
let target = styles_dir.join(style.file_name);
assert!(
target.exists(),
"output-styles/{} must be deployed by ensure_global_config_dir",
style.file_name
);
let content = std::fs::read_to_string(&target).unwrap();
assert_eq!(
content, style.content,
"deployed output style {} must match bundled content",
style.file_name
);
}
}
#[test]
fn test_deploy_output_styles_idempotent_via_global_config() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let claude_config = managed_root.join("claude-config");
ensure_global_config_dir(&managed_root, &claude_config).unwrap();
let result =
crate::core::output_style_deployer::deploy_output_styles(&claude_config).unwrap();
assert!(
result.deployed.is_empty(),
"second deploy must not overwrite any style file (idempotent); \
deployed: {:?}",
result.deployed
);
assert_eq!(
result.unchanged.len(),
crate::core::bundle::OUTPUT_STYLES.len(),
"all style files must be reported unchanged on second call; \
unchanged: {:?}",
result.unchanged
);
}
}