use std::path::Path;
use trusty_agents_common::agents::frontmatter::validate_frontmatter;
use crate::core::agent_manifest::{self, AgentManifest, ManifestLoad};
use crate::core::bundle::OUTPUT_STYLES;
use crate::core::paths::FrameworkPaths;
use crate::core::skill_manifest;
mod expected_set;
use expected_set::{expected_agent_stems, expected_skill_stems};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeploymentGap {
AgentManifestMissing,
AgentManifestCorrupt(String),
AgentMissing(String),
AgentFrontmatterInvalid(String, String),
SkillManifestMissing,
SkillMissing(String),
SettingsMissing,
SettingsMalformed(String),
OutputStyleKeyMissing,
OutputStyleUnknownId(String),
OutputStyleFileMissing(String),
HooksMissing,
}
impl DeploymentGap {
pub fn describe(&self) -> String {
match self {
Self::AgentManifestMissing => format!(
"agent ownership manifest ({}) is missing",
agent_manifest::MANIFEST_FILE
),
Self::AgentManifestCorrupt(detail) => format!("agent manifest is corrupt: {detail}"),
Self::AgentMissing(name) => format!("agent `{name}` is not deployed"),
Self::AgentFrontmatterInvalid(name, detail) => format!(
"agent `{name}` is deployed but its frontmatter is not valid YAML: {detail} \
— run `tm install --reset-agents` (or delete the file and redeploy) to refresh it"
),
Self::SkillManifestMissing => format!(
"skill ownership manifest ({}) is missing",
skill_manifest::SKILL_MANIFEST_FILE
),
Self::SkillMissing(name) => format!("skill `{name}` is not deployed"),
Self::SettingsMissing => ".claude/settings.json is missing".to_string(),
Self::SettingsMalformed(detail) => {
format!(".claude/settings.json is not valid JSON: {detail}")
}
Self::OutputStyleKeyMissing => {
"settings.json has no outputStyle key configured".to_string()
}
Self::OutputStyleUnknownId(id) => {
format!("outputStyle {id:?} is not a known trusty-mpm style")
}
Self::OutputStyleFileMissing(id) => {
format!("outputStyle {id:?} has no deployed style file")
}
Self::HooksMissing => "settings.json has no hooks configured".to_string(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ValidationReport {
pub gaps: Vec<DeploymentGap>,
}
impl ValidationReport {
pub fn is_complete(&self) -> bool {
self.gaps.is_empty()
}
}
pub fn validate_workspace(fw: &FrameworkPaths) -> ValidationReport {
let mut gaps = Vec::new();
validate_agents(fw, &mut gaps);
validate_skills(fw, &mut gaps);
validate_settings(fw, &mut gaps);
ValidationReport { gaps }
}
fn validate_agents(fw: &FrameworkPaths, gaps: &mut Vec<DeploymentGap>) {
let target = fw.claude_agents_dir();
let manifest = match AgentManifest::load_checked(&target) {
ManifestLoad::Corrupt(detail) => {
gaps.push(DeploymentGap::AgentManifestCorrupt(detail));
None
}
ManifestLoad::Ok(m) => {
if !target.join(agent_manifest::MANIFEST_FILE).is_file() {
gaps.push(DeploymentGap::AgentManifestMissing);
}
Some(m)
}
};
for name in expected_agent_stems(fw, manifest.as_ref()) {
let path = target.join(format!("{name}.md"));
if !path.is_file() {
gaps.push(DeploymentGap::AgentMissing(name));
continue;
}
if let Ok(content) = std::fs::read_to_string(&path)
&& let Err(detail) = validate_frontmatter(&content)
{
gaps.push(DeploymentGap::AgentFrontmatterInvalid(name, detail));
}
}
}
fn validate_skills(fw: &FrameworkPaths, gaps: &mut Vec<DeploymentGap>) {
let target = fw.claude_skills_dir();
let manifest_present = target.join(skill_manifest::SKILL_MANIFEST_FILE).is_file();
if !manifest_present {
gaps.push(DeploymentGap::SkillManifestMissing);
}
let manifest = manifest_present.then(|| skill_manifest::SkillManifest::load(&target));
for name in expected_skill_stems(fw, manifest.as_ref()) {
if !target.join(&name).join("SKILL.md").is_file() {
gaps.push(DeploymentGap::SkillMissing(name));
}
}
}
fn validate_settings(fw: &FrameworkPaths, gaps: &mut Vec<DeploymentGap>) {
let settings_path = fw.claude_home_dir().join(".claude").join("settings.json");
let text = match std::fs::read_to_string(&settings_path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
gaps.push(DeploymentGap::SettingsMissing);
return;
}
Err(e) => {
gaps.push(DeploymentGap::SettingsMalformed(e.to_string()));
return;
}
};
let value: serde_json::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => {
gaps.push(DeploymentGap::SettingsMalformed(e.to_string()));
return;
}
};
match value.get("outputStyle").and_then(|v| v.as_str()) {
None => gaps.push(DeploymentGap::OutputStyleKeyMissing),
Some(id) => match OUTPUT_STYLES.iter().find(|s| s.id == id) {
None => gaps.push(DeploymentGap::OutputStyleUnknownId(id.to_string())),
Some(style) => {
let style_path = fw
.claude_home_dir()
.join(".claude")
.join("output-styles")
.join(style.file_name);
let deployed = std::fs::metadata(&style_path)
.map(|m| m.len() > 0)
.unwrap_or(false);
if !deployed {
gaps.push(DeploymentGap::OutputStyleFileMissing(id.to_string()));
}
}
},
}
let hooks_present = value
.get("hooks")
.and_then(|h| h.as_object())
.is_some_and(|o| !o.is_empty());
if !hooks_present {
gaps.push(DeploymentGap::HooksMissing);
}
}
#[derive(Debug)]
pub struct RepairOutcome {
pub before: ValidationReport,
pub after: ValidationReport,
pub repaired: bool,
pub repair_error: Option<String>,
}
impl RepairOutcome {
pub fn is_complete(&self) -> bool {
self.after.is_complete()
}
}
pub fn validate_and_repair(
fw: &FrameworkPaths,
workspace: &Path,
repo_url: Option<&str>,
) -> RepairOutcome {
let before = validate_workspace(fw);
if before.is_complete() {
return RepairOutcome {
after: before.clone(),
before,
repaired: false,
repair_error: None,
};
}
let repair_error =
match crate::core::session_launch::prepare_session_with_repo_url(fw, workspace, repo_url) {
Ok(report) if !report.roster_errors.is_empty() => Some(report.roster_errors.join("; ")),
Ok(_) => None,
Err(e) => Some(e.to_string()),
};
let after = validate_workspace(fw);
RepairOutcome {
before,
after,
repaired: true,
repair_error,
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;