use std::path::{Path, PathBuf};
pub fn ensure_global_config_dir(
managed_root: &Path,
claude_config_dir: &Path,
) -> anyhow::Result<PathBuf> {
ensure_global_config_dir_with_exe(managed_root, claude_config_dir, None)
}
pub fn ensure_global_config_dir_with_exe(
managed_root: &Path,
claude_config_dir: &Path,
exe_override: Option<&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_with_exe(claude_config_dir, exe_override)?;
seed_credentials(claude_config_dir);
seed_builtin_mcp_servers(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, &skills_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}"))?;
let content = match crate::core::content_source::framework_content() {
Ok(content) => content,
Err(err) => {
eprintln!("note: output styles not deployed: {err}");
return Ok(());
}
};
let styles =
crate::core::output_style_deployer::deploy_output_styles(&content, claude_config_dir)
.map_err(|e| {
anyhow::anyhow!("failed to deploy output styles into managed config dir: {e}")
})?;
if !styles.failed.is_empty() {
let detail = styles
.failed
.iter()
.map(|(file_name, why)| format!("{file_name}: {why}"))
.collect::<Vec<_>>()
.join("; ");
return Err(anyhow::anyhow!(
"failed to deploy output styles into managed config dir: {detail}"
));
}
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 seed_builtin_mcp_servers(claude_config_dir: &Path) {
match crate::core::mcp_config::seed_builtin_servers(claude_config_dir) {
Ok(seeded) if !seeded.is_empty() => tracing::info!(
config_dir = %claude_config_dir.display(),
servers = %seeded.join(", "),
"declared builtin MCP servers in user scope"
),
Ok(_) => {}
Err(e) => tracing::warn!(
config_dir = %claude_config_dir.display(),
"builtin MCP server seeding failed (non-fatal): {e}"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn ensure_global_config_dir(
managed_root: &Path,
claude_config_dir: &Path,
) -> anyhow::Result<PathBuf> {
ensure_global_config_dir_with_exe(
managed_root,
claude_config_dir,
Some(Path::new(crate::test_support::STABLE_HOOK_EXE)),
)
}
#[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(".claude.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_global_config_dir_seeds_builtin_mcp_servers() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let cfg = managed_root.join("claude-config");
ensure_global_config_dir(&managed_root, &cfg).unwrap();
let text = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let servers = val["mcpServers"].as_object().unwrap();
for name in crate::core::mcp_config::BUILTIN_MANAGED_MCP_SERVERS {
assert!(
servers.contains_key(*name),
"{name} must be declared in .claude.json's user-scope mcpServers map"
);
}
}
#[test]
fn test_seeded_review_server_entry() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
seed_builtin_mcp_servers(&cfg);
let text = std::fs::read_to_string(cfg.join(".claude.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_seed_builtin_mcp_servers_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)
};
seed_builtin_mcp_servers(&cfg);
assert!(
cfg.join(".claude.json").exists(),
"seeding must write .claude.json inside the given dir"
);
assert!(
!root.path().join(".claude.json").exists(),
"seeding must NOT write .claude.json outside the given dir (isolation)"
);
assert!(
!fake_home.path().join(".claude.json").exists(),
"seeding must NOT write .claude.json to $HOME (isolation)"
);
assert!(
!fake_home.path().join(".claude").exists(),
"seeding must NOT create $HOME/.claude/ (isolation)"
);
}
#[test]
fn test_seeding_no_spurious_write_when_unchanged() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
let claude_json = cfg.join(".claude.json");
seed_builtin_mcp_servers(&cfg);
let bytes_after_first = std::fs::read(&claude_json).unwrap();
seed_builtin_mcp_servers(&cfg);
let bytes_after_second = std::fs::read(&claude_json).unwrap();
assert_eq!(
bytes_after_first, bytes_after_second,
"seeding must not rewrite .claude.json when every builtin is already present"
);
}
#[test]
fn test_seeding_trailing_newline_does_not_trigger_rewrite() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
let claude_json = cfg.join(".claude.json");
seed_builtin_mcp_servers(&cfg);
let canonical = std::fs::read_to_string(&claude_json).unwrap();
std::fs::write(&claude_json, format!("{canonical}\n").as_bytes()).unwrap();
let bytes_before = std::fs::read(&claude_json).unwrap();
seed_builtin_mcp_servers(&cfg);
let bytes_after = std::fs::read(&claude_json).unwrap();
assert_eq!(
bytes_before, bytes_after,
"a trailing newline must not trigger a spurious rewrite of .claude.json \
(refs #1550 Finding-1)"
);
}
#[test]
fn test_global_config_dir_never_overwrites_an_operator_mcp_entry() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let cfg = managed_root.join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let operator = serde_json::json!({
"type": "stdio",
"command": "/opt/operator/trusty-search",
"args": ["serve", "--index", "pinned"]
});
std::fs::write(
cfg.join(".claude.json"),
serde_json::to_string_pretty(&serde_json::json!({
"mcpServers": { "trusty-search": operator.clone() }
}))
.unwrap(),
)
.unwrap();
ensure_global_config_dir(&managed_root, &cfg).unwrap();
let text = std::fs::read_to_string(cfg.join(".claude.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val["mcpServers"]["trusty-search"], operator,
"seeding must never overwrite an operator-registered entry"
);
assert!(
val["mcpServers"]["trusty-mpm"].is_object(),
"the remaining builtins must still be seeded alongside it"
);
}
#[test]
fn test_global_config_dir_survives_a_malformed_claude_json() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let cfg = managed_root.join("claude-config");
std::fs::create_dir_all(&cfg).unwrap();
let complete = serde_json::to_string(&serde_json::json!({
"oauthAccount": { "emailAddress": "op@example.com" }
}))
.unwrap();
let malformed = complete.as_bytes()[..complete.len() - 1].to_vec();
std::fs::write(cfg.join(".claude.json"), &malformed).unwrap();
ensure_global_config_dir(&managed_root, &cfg)
.expect("a malformed .claude.json must not fail provisioning");
assert_eq!(
std::fs::read(cfg.join(".claude.json")).unwrap(),
malformed,
"the seeder must leave a malformed .claude.json byte-identical — no rename, no write"
);
let quarantined = std::fs::read_dir(&cfg)
.unwrap()
.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().contains("corrupt"));
assert!(!quarantined, "the seeder must not quarantine .claude.json");
assert!(
cfg.join("output-styles").exists(),
"provisioning steps after the seed must still have run"
);
}
#[test]
fn test_global_config_dir_survives_an_unreadable_claude_json() {
let tmp = TempDir::new().unwrap();
let managed_root = tmp.path().join("managed");
let cfg = managed_root.join("claude-config");
std::fs::create_dir_all(cfg.join(".claude.json")).unwrap();
ensure_global_config_dir(&managed_root, &cfg)
.expect("an unreadable .claude.json must not fail provisioning");
assert!(
cfg.join(".claude.json").is_dir(),
"the seeder must not have disturbed what it could not read"
);
assert!(
cfg.join("output-styles").exists(),
"provisioning steps after the seed must still have run"
);
}
#[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(&crate::core::content_source::test_support::repo_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(
&crate::core::content_source::test_support::repo_content(),
&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
);
}
}