use std::path::Path;
use super::settings::deploy_output_style;
use crate::core::agent_deployer::{DeployResult, deploy_agents_filtered, retract_framework_agents};
use crate::core::paths::FrameworkPaths;
use crate::core::skill_deployer::DeployStats;
use crate::core::skill_tiers::deploy_all_skill_tiers;
#[derive(Debug, thiserror::Error)]
pub enum SyncAssetsError {
#[error("agent redeploy failed: {0}")]
AgentDeploy(String),
#[error("skill redeploy failed: {0}")]
SkillDeploy(String),
}
#[derive(Debug, Default)]
pub struct SyncAssetsReport {
pub agents_deployed: Vec<String>,
pub agents_skipped: Vec<String>,
pub skills_deployed: Vec<String>,
pub skills_skipped: Vec<String>,
pub output_style_synced: bool,
pub retraction_error: Option<String>,
pub quarantine_summary: Option<String>,
}
pub fn sync_session_assets(
fw: &FrameworkPaths,
project_dir: &Path,
) -> Result<SyncAssetsReport, SyncAssetsError> {
crate::core::manifest::invalidate_stack_detection(project_dir);
let catalog_root = crate::content::catalog_root_for(&fw.root);
let sources = crate::core::manifest::ManifestSources::resolve(project_dir, &catalog_root);
let manifest = crate::core::manifest::resolve_manifest(&sources);
let plan = crate::core::manifest::HarnessPlan::from_manifest(&manifest, fw, &catalog_root);
let deploy: DeployResult =
deploy_agents_filtered(
&plan.agent_source,
&fw.agent_deploy_dir(),
&fw.skill_deploy_dir(),
|name| plan.agent_selected(name),
)
.map_err(|e| SyncAssetsError::AgentDeploy(e.to_string()))?;
let retraction_error =
match retract_framework_agents(&project_dir.join(".claude").join("agents")) {
Ok(_) => None,
Err(err) => {
tracing::warn!(
project_dir = %project_dir.display(),
"workspace bundled-agent retraction failed during sync-assets: {err}"
);
Some(err.to_string())
}
};
let quarantine_summary =
match super::quarantine_shadows::quarantine_workspace_shadows(fw, project_dir) {
Ok(report) => super::quarantine_shadows::summarize(&report),
Err(super::quarantine_shadows::ShadowQuarantineError::Roster(err))
if trusty_agents_common::agent_content::report_not_installed(
&err,
"agent quarantine skipped during sync-assets",
) =>
{
Some(err.to_string())
}
Err(err) => {
tracing::warn!(
project_dir = %project_dir.display(),
"shadowing-agent quarantine refused to run during sync-assets: {err}"
);
Some(format!("agent quarantine refused: {err}"))
}
};
super::skills::sweep_project_tier_strays(fw, project_dir);
let mut skill_deploy: DeployStats = deploy_all_skill_tiers(
&plan.skill_source,
&fw.user_skill_source_dir(),
&fw.skill_deploy_dir(),
|_| true,
)
.map_err(|e| SyncAssetsError::SkillDeploy(e.to_string()))?
.stats;
let project_skills: DeployStats = deploy_all_skill_tiers(
&plan.skill_source,
&fw.user_skill_source_dir(),
&fw.claude_skills_dir(),
crate::core::project_skill_tier::bundled_excluded_from_project_tier,
)
.map_err(|e| SyncAssetsError::SkillDeploy(e.to_string()))?
.stats;
skill_deploy.deployed.extend(project_skills.deployed);
skill_deploy.skipped.extend(project_skills.skipped);
skill_deploy.unchanged.extend(project_skills.unchanged);
let output_style_synced = match crate::core::content_source::framework_content_for(project_dir)
{
Ok(content) => deploy_output_style(&content, project_dir).is_ok(),
Err(err) => {
tracing::warn!("output styles not synced: {err}");
false
}
};
Ok(SyncAssetsReport {
agents_deployed: deploy.deployed,
agents_skipped: deploy.skipped,
skills_deployed: skill_deploy.deployed,
skills_skipped: skill_deploy.skipped,
output_style_synced,
retraction_error,
quarantine_summary,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_assets_redeploys_new_agent() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
let first = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert_eq!(first.agents_deployed, vec!["rust-engineer.md".to_string()]);
assert!(!ws_fw.agent_deploy_dir().join("qa.md").exists());
std::fs::write(bundled.join("qa.md"), "---\nname: qa\n---\nv1").unwrap();
let second = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(second.agents_deployed.contains(&"qa.md".to_string()));
assert!(ws_fw.agent_deploy_dir().join("qa.md").exists());
}
#[test]
fn sync_assets_refreshes_stale_skill() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.skill_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(bundled.join("tm-doctor.md"), "v1.0.0").unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
sync_session_assets(&ws_fw, &project_dir).unwrap();
let deployed_path = ws_fw.skill_deploy_dir().join("tm-doctor").join("SKILL.md");
assert_eq!(std::fs::read_to_string(&deployed_path).unwrap(), "v1.0.0");
assert!(
!ws_fw.claude_skills_dir().join("tm-doctor").exists(),
"a bundled skill must never be deployed to the project tier"
);
std::fs::write(bundled.join("tm-doctor.md"), "v1.1.0").unwrap();
let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(report.skills_deployed.contains(&"tm-doctor".to_string()));
assert_eq!(std::fs::read_to_string(&deployed_path).unwrap(), "v1.1.0");
assert!(
!ws_fw.claude_skills_dir().join("tm-doctor").exists(),
"a bundled skill must never be deployed to the project tier"
);
}
#[test]
fn sync_assets_refreshes_project_output_style() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(report.output_style_synced);
assert!(
project_dir
.join(".claude")
.join("output-styles")
.join("trusty-mpm.md")
.exists()
);
}
#[test]
fn sync_assets_never_touches_hand_placed_agent() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
std::fs::create_dir_all(ws_fw.agent_deploy_dir()).unwrap();
let hand_placed = ws_fw.agent_deploy_dir().join("rust-engineer.md");
let content = "---\nname: rust-engineer\n---\noperator's own agent";
std::fs::write(&hand_placed, content).unwrap();
let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(
report
.agents_skipped
.contains(&"rust-engineer.md".to_string())
);
assert_eq!(std::fs::read_to_string(&hand_placed).unwrap(), content);
}
#[test]
fn sync_assets_repairs_drifted_bundled_agent() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
sync_session_assets(&ws_fw, &project_dir).unwrap();
let deployed = ws_fw.agent_deploy_dir().join("rust-engineer.md");
std::fs::write(&deployed, "---\nname: rust-engineer\n---\n\nv1\n").unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv2",
)
.unwrap();
let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(
report
.agents_deployed
.contains(&"rust-engineer.md".to_string()),
"drifted bundled agent must be repaired, got: {report:?}"
);
assert!(
!report
.agents_skipped
.contains(&"rust-engineer.md".to_string())
);
assert!(
std::fs::read_to_string(&deployed).unwrap().contains("v2"),
"the current bundled composition must be restored"
);
}
#[test]
fn sync_assets_retracts_workspace_bundled_agents() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
let legacy = ws_fw.claude_agents_dir();
deploy_agents_filtered(&bundled, &legacy, &std::env::temp_dir(), |_| true).unwrap();
assert!(legacy.join("rust-engineer.md").exists());
let hand_placed = legacy.join("my-own-agent.md");
let mine = "---\nname: my-own-agent\ndescription: mine\n---\n\nMine.\n";
std::fs::write(&hand_placed, mine).unwrap();
sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(
!legacy.join("rust-engineer.md").exists(),
"the legacy per-workspace bundled copy must be retracted"
);
assert_eq!(
std::fs::read_to_string(&hand_placed).unwrap(),
mine,
"a hand-placed agent must survive retraction byte-identical"
);
assert!(
ws_fw.agent_deploy_dir().join("rust-engineer.md").exists(),
"the canonical config-dir tier must carry the roster instead"
);
}
#[test]
fn sync_assets_reports_retraction_failure_on_corrupt_manifest() {
let tmp = tempfile::tempdir().unwrap();
let mut fw = FrameworkPaths::under(tmp.path().join("home"));
fw.trusty_mpm_root = None;
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let project_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&project_dir).unwrap();
let mut ws_fw = fw.clone();
ws_fw.claude_agents = project_dir.join(".claude").join("agents");
ws_fw.claude_skills = project_dir.join(".claude").join("skills");
let legacy = project_dir.join(".claude").join("agents");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::write(
legacy.join(crate::core::agent_manifest::MANIFEST_FILE),
b"not valid json{{{",
)
.unwrap();
let report = sync_session_assets(&ws_fw, &project_dir).unwrap();
assert!(
report
.retraction_error
.as_deref()
.is_some_and(|e| e.contains("corrupt")),
"the refusal must be reported, got: {:?}",
report.retraction_error
);
assert!(
report
.agents_deployed
.contains(&"rust-engineer.md".to_string())
);
}
}