pub(crate) async fn install(
force: bool,
reset_agents: Option<Vec<String>>,
reset_agents_workspaces: bool,
reconcile_skills: bool,
) -> anyhow::Result<()> {
let paths = trusty_mpm::core::paths::FrameworkPaths::default();
let report = install_to(&paths, force)?;
println!(
"Installing trusty-mpm framework artifacts to {}",
paths.framework.display()
);
for line in &report {
println!(" {line}");
}
match trusty_mpm::core::instruction_pipeline::remove_stale_bundled_instructions(
&paths.framework_instructions_path(),
) {
Ok(true) => println!(" \u{2717} instructions/INSTRUCTIONS.md (stale, removed)"),
Ok(false) => {}
Err(e) => eprintln!("warning: failed to remove stale instructions/INSTRUCTIONS.md: {e:#}"),
}
println!(
"Composing agents into {}",
paths.agent_deploy_dir().display()
);
let deploy = trusty_mpm::core::agent_deployer::deploy_agents(
&paths.agent_source_dir(),
&paths.agent_deploy_dir(),
)?;
for line in deploy_report_lines(&deploy, &paths.agent_source_dir()) {
println!(" {line}");
}
if let Some(names) = reset_agents {
let filter = if names.is_empty() { None } else { Some(names) };
println!(
"Resetting agents ({}) into {}",
filter
.as_ref()
.map(|n| n.join(", "))
.unwrap_or_else(|| "all".to_string()),
paths.agent_deploy_dir().display()
);
let reset = trusty_mpm::core::agent_reset::reset_agents(
&paths.agent_source_dir(),
&paths.agent_deploy_dir(),
filter.as_deref(),
)?;
for line in reset_report_lines(&reset) {
println!(" {line}");
}
if reset_agents_workspaces {
println!("Resetting agents in every intact session workspace…");
let outcomes = trusty_mpm::core::agent_reset_workspace::reset_active_workspace_agents(
&paths.root,
filter.as_deref(),
)
.await?;
if outcomes.is_empty() {
println!(" (no intact session workspaces found)");
}
for outcome in &outcomes {
println!(
" {} ({})",
outcome.tmux_name,
outcome.workspace_path.display()
);
if let Some(reason) = &outcome.skipped_reason {
println!(" {reason}");
continue;
}
for line in reset_report_lines(&outcome.result) {
println!(" {line}");
}
}
}
}
println!(
"Deploying skills into {}",
paths.claude_skills_dir().display()
);
let skill_deploy = trusty_mpm::core::skill_tiers::deploy_all_skill_tiers(
&paths.skill_source_dir(),
&paths.user_skill_source_dir(),
&paths.claude_skills_dir(),
|_| true,
)?
.stats;
for line in skill_report_lines(&skill_deploy) {
println!(" {line}");
}
for retired in trusty_mpm::core::skill_retire::retire_orphaned_skills(&paths, None) {
match &retired.reason {
None => println!(
" - {} removed from {} (no longer shipped)",
retired.stem, retired.tier
),
Some(why) => println!(
" ! {} is no longer shipped but its files were kept at {} ({why}) — \
trusty-mpm no longer tracks them",
retired.stem, retired.tier
),
}
}
if reconcile_skills {
crate::commands::install_skills::reconcile_skills(&paths, None)?;
} else {
for line in crate::commands::install_skills::unmanaged_report_lines(&paths, None) {
println!(" {line}");
}
}
let bundled_stems = trusty_mpm::core::skill_tiers::list_source_stems(&paths.skill_source_dir())
.unwrap_or_default();
let user_stems =
trusty_mpm::core::skill_tiers::list_source_stems(&paths.user_skill_source_dir())
.unwrap_or_default();
let project_stems =
trusty_mpm::core::skill_tiers::list_project_custom_stems(&paths.claude_skills_dir())
.unwrap_or_default();
let dangling = trusty_mpm::core::agent_skill_codeploy::log_declared_skills(
&deploy.declared_skills,
&project_stems,
&user_stems,
&bundled_stems,
);
for (agent, skill) in &dangling {
println!(
" warning: agent `{agent}` declares skill `{skill}`, but it was not found in any tier"
);
}
if let Err(e) = install_claude_hooks() {
eprintln!("warning: failed to install Claude Code hooks: {e:#}");
}
println!("Framework installed. Run `trusty-mpm daemon` to start.");
Ok(())
}
pub(crate) fn install_claude_hooks() -> anyhow::Result<usize> {
let config_dir = trusty_mpm::core::trusty_tools_config::managed_claude_config_dir()
.ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?;
install_claude_hooks_at(&config_dir)
}
fn install_claude_hooks_at(config_dir: &std::path::Path) -> anyhow::Result<usize> {
use colored::Colorize;
let settings_path = config_dir.join("settings.json");
println!(
"Wiring MPM hooks into the tm-owned settings at {}…",
settings_path.display()
);
let exe = trusty_mpm::core::standalone::hooks::resolve_current_exe();
match trusty_mpm::core::standalone::hooks::write_project_hooks(&settings_path, exe.as_deref()) {
Ok(true) => {
println!(" {} {}", "✓".green(), settings_path.display());
Ok(1)
}
Ok(false) => {
println!(
" {} {} {}",
"↻".cyan(),
settings_path.display().to_string().dimmed(),
"(already configured)".dimmed()
);
Ok(0)
}
Err(e) => {
eprintln!(
" {} {} {}",
"✗".red(),
settings_path.display(),
format!("({e})").red()
);
Err(e)
}
}
}
pub(crate) fn remove_global_trusty_mpm_hooks() -> anyhow::Result<usize> {
trusty_mpm::core::standalone::hooks::remove_global_trusty_mpm_hooks()
}
pub(crate) fn write_project_hooks_for_dir(project_dir: &std::path::Path) -> anyhow::Result<bool> {
let settings_path = project_dir.join(".claude").join("settings.json");
let exe = trusty_mpm::core::standalone::hooks::resolve_current_exe();
trusty_mpm::core::standalone::hooks::write_project_hooks(&settings_path, exe.as_deref())
}
#[cfg(test)]
pub(crate) fn mpm_hook_additions() -> serde_json::Value {
trusty_mpm::core::standalone::hooks::mpm_hook_additions()
}
pub(crate) fn deploy_report_lines(
deploy: &trusty_mpm::core::agent_deployer::DeployResult,
source_dir: &std::path::Path,
) -> Vec<String> {
let mut lines = Vec::new();
for file in &deploy.deployed {
let name = file.trim_end_matches(".md");
let chain = trusty_mpm::core::agent_builder::source_chain(name, source_dir)
.map(|c| c.join(" \u{2192} "))
.unwrap_or_else(|_| name.to_string());
lines.push(format!("\u{2713} {file} (composed: {chain})"));
}
for file in &deploy.adopted {
lines.push(format!(
"\u{25c6} {file} (adopted \u{2014} untracked, now managed)"
));
}
for file in &deploy.skipped {
if deploy.untracked_modified.contains(file) {
lines.push(format!(
"~ {file} (skipped \u{2014} untracked, differs from bundle)"
));
} else {
lines.push(format!("~ {file} (skipped \u{2014} user-modified)"));
}
}
for file in &deploy.unchanged {
lines.push(format!("= {file} (unchanged)"));
}
if !deploy.untracked_modified.is_empty() {
lines.push(format!(
"\u{26a0} {} untracked agent file(s) differ from the bundle and were skipped; \
run `tm install --reset-agents` to review and reconcile them.",
deploy.untracked_modified.len()
));
}
for failure in &deploy.failed {
lines.push(format!(
"\u{2717} {failure} (agent skipped, compose failed)"
));
}
lines
}
pub(crate) fn reset_report_lines(
reset: &trusty_mpm::core::agent_reset::ResetResult,
) -> Vec<String> {
let mut lines = Vec::new();
for file in &reset.recomposed {
if reset.backed_up.contains(file) {
lines.push(format!("\u{27f3} {file} (recomposed, backed up)"));
} else {
lines.push(format!("\u{27f3} {file} (recomposed)"));
}
}
for file in &reset.adopted {
lines.push(format!("\u{25c6} {file} (adopted)"));
}
for name in &reset.not_found {
lines.push(format!("? {name} (no matching source agent)"));
}
for name in &reset.deselected {
lines.push(format!(
"\u{2298} {name} (excluded by this workspace's manifest)"
));
}
for file in &reset.retracted {
lines.push(format!(
"\u{2717} {file} (retracted — bundled agents live in the tm-managed config dir)"
));
}
lines.push(format!(
"reset summary: {} recomposed, {} adopted, {} backed up, {} not found, \
{} deselected, {} retracted",
reset.recomposed.len(),
reset.adopted.len(),
reset.backed_up.len(),
reset.not_found.len(),
reset.deselected.len(),
reset.retracted.len()
));
lines
}
pub(crate) fn skill_report_lines(
deploy: &trusty_mpm::core::skill_deployer::DeployStats,
) -> Vec<String> {
let mut lines = Vec::new();
for file in &deploy.deployed {
lines.push(format!("\u{2713} {file}"));
}
for file in &deploy.skipped {
lines.push(format!("~ {file} (skipped \u{2014} user-modified)"));
}
for file in &deploy.unchanged {
lines.push(format!("= {file} (unchanged)"));
}
lines
}
pub(crate) fn install_one(
dest: &std::path::Path,
artifact: &trusty_mpm::core::bundle::BundledArtifact,
force: bool,
) -> anyhow::Result<String> {
use trusty_mpm::core::bundle::InstallPolicy;
let exists = dest.exists();
match artifact.install {
InstallPolicy::Overwrite => {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(dest, artifact.contents)?;
Ok(if exists {
format!("\u{2713} {} (refreshed)", artifact.rel_path)
} else {
format!("\u{2713} {}", artifact.rel_path)
})
}
InstallPolicy::SeedOnce => {
if exists && !force {
return Ok(format!(
"- {} (exists, preserved \u{2014} user-owned)",
artifact.rel_path
));
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(dest, artifact.contents)?;
Ok(if exists {
format!("\u{2713} {} (reset to shipped default)", artifact.rel_path)
} else {
format!("\u{2713} {}", artifact.rel_path)
})
}
}
}
pub(crate) fn install_to(
paths: &trusty_mpm::core::paths::FrameworkPaths,
force: bool,
) -> anyhow::Result<Vec<String>> {
let mut report = Vec::new();
for artifact in trusty_mpm::core::bundle::ALL {
let dest = paths.framework.join(artifact.rel_path);
report.push(install_one(&dest, artifact, force)?);
}
Ok(report)
}
#[cfg(test)]
#[path = "install_tests.rs"]
mod tests;