use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::core::paths::FrameworkPaths;
use crate::core::skill_deploy_tiers::skill_deploy_tiers;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AssetCounts {
pub deployed: usize,
pub repaired: usize,
pub preserved: usize,
pub unchanged: usize,
pub failed: usize,
pub shadowed: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TierReport {
pub label: String,
pub agents_dir: Option<PathBuf>,
pub skills_dir: PathBuf,
pub agents: AssetCounts,
pub skills: AssetCounts,
pub notes: Vec<String>,
pub errored: bool,
pub optional: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReinstallTarget {
pub label: String,
pub agents_dir: Option<PathBuf>,
pub skills_dir: PathBuf,
pub optional: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReinstallReport {
pub agent_source_refreshed: bool,
pub skill_source_refreshed: bool,
pub tiers: Vec<TierReport>,
pub notes: Vec<String>,
}
impl ReinstallReport {
pub fn has_failures(&self) -> bool {
self.tiers
.iter()
.filter(|t| !t.optional)
.any(|t| t.errored || t.agents.failed > 0 || t.skills.failed > 0)
}
}
pub fn reinstall_targets(
paths: &FrameworkPaths,
standalone_config_dir: &Path,
project_dir: Option<&Path>,
) -> Vec<ReinstallTarget> {
let agent_deploy = paths.agent_deploy_dir();
let project_dir = project_dir.filter(|dir| dir.join(".claude").join("skills").is_dir());
let mut targets: Vec<ReinstallTarget> = skill_deploy_tiers(paths, project_dir)
.into_iter()
.map(|tier| ReinstallTarget {
agents_dir: (tier.label == "managed config").then(|| agent_deploy.clone()),
optional: tier.label == "project",
label: tier.label.to_string(),
skills_dir: tier.dir,
})
.collect();
let standalone = ReinstallTarget {
label: "standalone".to_string(),
agents_dir: Some(standalone_config_dir.join("agents")),
skills_dir: standalone_config_dir.join("skills"),
optional: false,
};
if !targets
.iter()
.any(|t| t.skills_dir == standalone.skills_dir)
{
targets.push(standalone);
}
targets
}
pub fn reinstall_assets(
paths: &FrameworkPaths,
standalone_config_dir: &Path,
project_dir: Option<&Path>,
force: bool,
backup_root: &Path,
) -> ReinstallReport {
let mut report = ReinstallReport::default();
match crate::core::skill_source::ensure_skill_source_fresh(paths) {
Ok(refreshed) => report.skill_source_refreshed = refreshed,
Err(err) => report.notes.push(format!(
"warning: could not refresh the bundled skill source at {} ({err}) — \
continuing with whatever is already on disk",
paths.skills.display()
)),
}
let agent_source = paths.agent_source_dir();
if agent_source == paths.agents || !crate::core::agent_source::has_agent_markdown(&agent_source)
{
match crate::core::agent_source::ensure_agent_source_fresh(&paths.agents) {
Ok(refreshed) => report.agent_source_refreshed = refreshed,
Err(err) => report.notes.push(format!(
"warning: could not refresh the bundled agent source at {} ({err}) — \
continuing with whatever is already on disk",
paths.agents.display()
)),
}
}
let agent_source = paths.agent_source_dir();
let bundled_stems = bundled_skill_stems(paths);
for target in reinstall_targets(paths, standalone_config_dir, project_dir) {
let mut tier = TierReport {
label: target.label,
agents_dir: target.agents_dir.clone(),
skills_dir: target.skills_dir.clone(),
optional: target.optional,
..TierReport::default()
};
if let Some(dir) = &target.agents_dir {
deploy_agents_into(&agent_source, dir, force, &mut tier);
}
deploy_skills_into(
paths,
&target.skills_dir,
&bundled_stems,
force,
backup_root,
&mut tier,
);
report.tiers.push(tier);
}
report
}
fn bundled_skill_stems(paths: &FrameworkPaths) -> BTreeSet<String> {
crate::core::skill_tiers::list_source_stems(&paths.skill_source_dir()).unwrap_or_default()
}
fn deploy_agents_into(source: &Path, dest: &Path, force: bool, tier: &mut TierReport) {
if force {
match crate::core::agent_reset::reset_agents(source, dest, None) {
Ok(reset) if !reset.recomposed.is_empty() => tier.notes.push(format!(
"forced {} agent file(s) back to the bundled composition ({} backed up in place)",
reset.recomposed.len(),
reset.backed_up.len()
)),
Ok(_) => {}
Err(err) => {
tier.errored = true;
tier.notes.push(format!(
"agent force-reset failed at {}: {err}",
dest.display()
));
}
}
}
match crate::core::agent_deployer::deploy_agents(source, dest) {
Ok(result) => {
tier.agents = AssetCounts {
deployed: result.deployed.len(),
repaired: result.repaired.len(),
preserved: result.skipped.len(),
unchanged: result.unchanged.len() + result.adopted.len(),
failed: result.failed.len(),
shadowed: 0,
};
tier.notes.extend(preserved_note(
"agent",
&result.skipped,
&crate::core::agent_source::preview(&result.skipped, 5),
));
if !result.failed.is_empty() {
tier.notes.push(format!(
"{} agent(s) failed to compose and did NOT deploy at all: {}.",
result.failed.len(),
crate::core::agent_source::preview(&result.failed, 3)
));
}
}
Err(err) => {
tier.errored = true;
tier.notes
.push(format!("agent deploy failed at {}: {err}", dest.display()));
}
}
}
fn preserved_note(kind: &str, preserved: &[String], preview: &str) -> Option<String> {
if preserved.is_empty() {
return None;
}
Some(format!(
"{} {kind} file(s) preserved — you own or edited them, so the bundled version was \
NOT written: {preview}. Run `tm reinstall --force` to overwrite them (each is \
backed up first).",
preserved.len()
))
}
fn deploy_skills_into(
paths: &FrameworkPaths,
dest: &Path,
bundled_stems: &BTreeSet<String>,
force: bool,
backup_root: &Path,
tier: &mut TierReport,
) {
if force {
match trusty_agents_common::skills::reconcile::force_adopt_bundled_skills(
dest,
bundled_stems,
backup_root,
) {
Ok(adopted) if !adopted.is_empty() => tier.notes.push(format!(
"forced {} skill(s) back under management at {} (originals backed up under {})",
adopted.len(),
dest.display(),
backup_root.display()
)),
Ok(_) => {}
Err(err) => {
tier.errored = true;
tier.notes.push(format!(
"skill force-adopt failed at {}: {err}",
dest.display()
));
}
}
}
match crate::core::skill_tiers::deploy_all_skill_tiers(
&paths.skill_source_dir(),
&paths.user_skill_source_dir(),
dest,
|_| true,
) {
Ok(deploy) => {
tier.skills = AssetCounts {
deployed: deploy.stats.deployed.len(),
repaired: 0,
preserved: deploy.stats.skipped.len(),
unchanged: deploy.stats.unchanged.len(),
failed: 0,
shadowed: distinct_shadowed(&deploy.shadowed).len(),
};
tier.notes.extend(preserved_note(
"skill",
&deploy.stats.skipped,
&crate::core::agent_source::preview(&deploy.stats.skipped, 5),
));
tier.notes
.extend(shadowed_note(&deploy.shadowed, bundled_stems));
}
Err(err) => {
tier.errored = true;
tier.notes
.push(format!("skill deploy failed at {}: {err}", dest.display()));
}
}
}
fn distinct_shadowed(
shadowed: &[trusty_agents_common::skills::tiers::Shadow],
) -> std::collections::BTreeMap<String, trusty_agents_common::skills::tiers::SkillTier> {
shadowed
.iter()
.map(|s| (s.stem.clone(), s.winner))
.collect()
}
fn shadowed_note(
shadowed: &[trusty_agents_common::skills::tiers::Shadow],
bundled_stems: &BTreeSet<String>,
) -> Option<String> {
use trusty_agents_common::skills::tiers::SkillTier;
let distinct = distinct_shadowed(shadowed);
if distinct.is_empty() {
return None;
}
let names: Vec<String> = distinct
.iter()
.map(|(stem, winner)| format!("{stem} (kept by {})", winner.label()))
.collect();
let actionable: Vec<String> = distinct
.iter()
.filter(|(stem, winner)| **winner == SkillTier::Project && bundled_stems.contains(*stem))
.map(|(stem, _)| stem.clone())
.collect();
let pointer = if actionable.is_empty() {
String::new()
} else {
format!(
" {} of them carry a BUNDLED skill's name and this destination does not track them, \
so no deploy can reach them (#4605): {}. Run `tm reinstall --force` to adopt them \
(each is backed up first).",
actionable.len(),
crate::core::agent_source::preview(&actionable, 5)
)
};
Some(format!(
"{} skill(s) were NOT deployed here — a higher-precedence tier owns the name: {}.{pointer}",
distinct.len(),
crate::core::agent_source::preview(&names, 5)
))
}
#[cfg(test)]
#[path = "reinstall_tests.rs"]
mod tests;