use std::cmp::Ordering;
use crate::doctor::check::{CheckResult, FixAction};
use crate::skill;
use super::Ctx;
pub fn check(_ctx: &Ctx) -> Vec<CheckResult> {
let binary = skill::binary_cli_version();
let mut out = Vec::new();
for name in skill::bundled_skill_names() {
let id = format!("skill.sync.{name}");
let suggest_install = format!("orchestratectl skill install {name} --force");
let Some(path) = skill::claude_default_path(name) else {
out.push(CheckResult::warn(
"skill.sync",
"cannot locate skill installs (HOME unset)",
"set HOME so the default skill path resolves",
));
break;
};
if !path.exists() {
out.push(CheckResult::warn(
id,
format!("skill '{name}' is not installed at {}", path.display()),
suggest_install,
));
continue;
}
let on_disk = skill::read_on_disk_cli_version(&path);
match on_disk
.as_deref()
.and_then(|v| compare(v, binary).map(|o| (v, o)))
{
Some((_, Ordering::Equal)) => {
out.push(CheckResult::ok(
id,
format!("skill '{name}' in sync at cli_version {binary}"),
));
}
Some((v, Ordering::Less)) => {
out.push(
CheckResult::warn(
id,
format!("skill '{name}' is cli_version {v}, binary is {binary}"),
suggest_install,
)
.with_safe_fix(FixAction::InstallSkill(name.to_string())),
);
}
Some((v, Ordering::Greater)) => {
out.push(CheckResult::warn(
id,
format!(
"skill '{name}' on disk is cli_version {v}, newer than binary {binary}"
),
"upgrade the orchestratectl binary to match the installed skill",
));
}
None => {
out.push(CheckResult::warn(
id,
format!(
"skill '{name}' has an unreadable/unparseable cli_version at {}",
path.display()
),
suggest_install,
));
}
}
if let Some(skill_dir) = path.parent() {
for companion in skill::companion_sources(name) {
let companion_path = skill_dir.join(companion.filename);
out.push(check_companion(name, &companion, &companion_path, binary));
}
for filename in skill::orphan_companions(name, skill_dir) {
let orphan_path = skill_dir.join(&filename);
out.push(CheckResult::warn(
format!("skill.orphan.{name}.{filename}"),
format!(
"companion '{filename}' for skill '{name}' at {} is orchestratectl-managed but the current binary no longer bundles it (de-registered)",
orphan_path.display()
),
format!("orchestratectl skill install {name} --force"),
));
}
}
}
for (name, dir) in skill::managed_orphans() {
out.push(CheckResult::warn(
format!("skill.orphan.{name}"),
format!(
"skill '{name}' at {} is orchestratectl-managed but no longer in the catalog (de-registered)",
dir.display()
),
"orchestratectl skill install --force",
));
}
check_codex(binary, &mut out);
out
}
fn check_codex(binary: &str, out: &mut Vec<CheckResult>) {
let managed_prompts = skill::codex_managed_prompts();
let managed_companions = skill::codex_managed_companions();
if managed_prompts.is_empty() && managed_companions.is_empty() {
return;
}
let catalog: std::collections::HashSet<&str> =
skill::bundled_skill_names().into_iter().collect();
for name in &managed_prompts {
let Some(path) = skill::codex_default_path(name) else {
continue;
};
if catalog.contains(name.as_str()) {
let id = format!("skill.sync.codex.{name}");
let suggest = format!("orchestratectl skill install {name} --agent codex --force");
if !path.exists() {
out.push(CheckResult::warn(
id,
format!(
"codex skill '{name}' is not installed at {}",
path.display()
),
suggest,
));
continue;
}
match skill::read_on_disk_cli_version(&path)
.as_deref()
.and_then(|v| compare(v, binary).map(|o| (v, o)))
{
Some((_, Ordering::Equal)) => out.push(CheckResult::ok(
id,
format!("codex skill '{name}' in sync at cli_version {binary}"),
)),
Some((v, Ordering::Less)) => out.push(CheckResult::warn(
id,
format!("codex skill '{name}' is cli_version {v}, binary is {binary}"),
suggest,
)),
Some((v, Ordering::Greater)) => out.push(CheckResult::warn(
id,
format!(
"codex skill '{name}' on disk is cli_version {v}, newer than binary {binary}"
),
"upgrade the orchestratectl binary to match the installed skill",
)),
None => out.push(CheckResult::warn(
id,
format!(
"codex skill '{name}' has an unreadable/unparseable cli_version at {}",
path.display()
),
suggest,
)),
}
} else {
if std::fs::symlink_metadata(&path).is_ok() {
out.push(CheckResult::warn(
format!("skill.orphan.codex.{name}"),
format!(
"codex skill '{name}' at {} is orchestratectl-managed but no longer in the catalog (de-registered)",
path.display()
),
"orchestratectl skill install --agent codex --force",
));
}
}
}
let Some(shared_root) = skill::codex_shared_root() else {
return;
};
let bundled: std::collections::HashSet<&str> = skill::all_companion_sources()
.iter()
.map(|c| c.filename)
.collect();
for companion in skill::all_companion_sources() {
if !managed_companions.iter().any(|c| c == companion.filename) {
continue; }
let path = shared_root.join(companion.filename);
out.push(check_codex_companion(&companion, &path, binary));
}
for filename in &managed_companions {
if bundled.contains(filename.as_str()) {
continue; }
let path = shared_root.join(filename);
if std::fs::symlink_metadata(&path).is_ok() {
out.push(CheckResult::warn(
format!("skill.orphan.codex._shared.{filename}"),
format!(
"codex companion '_shared/{filename}' at {} is orchestratectl-managed but no bundled skill references it any more (de-registered)",
path.display()
),
"orchestratectl skill install --agent codex --force",
));
}
}
}
fn check_codex_companion(
companion: &skill::CompanionSource,
path: &std::path::Path,
binary: &str,
) -> CheckResult {
let filename = companion.filename;
let id = format!("skill.sync.codex._shared.{filename}");
let suggest = "orchestratectl skill install --agent codex --force".to_string();
let on_disk = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return CheckResult::warn(
id,
format!(
"codex companion '_shared/{filename}' is not installed at {}",
path.display()
),
suggest,
);
}
Err(e) => {
return CheckResult::warn(
id,
format!(
"codex companion '_shared/{filename}' is unreadable at {}: {e}",
path.display()
),
suggest,
);
}
};
if on_disk == companion.bundled_body {
return CheckResult::ok(
id,
format!(
"codex companion '_shared/{filename}' matches the bundled content for binary {binary}"
),
);
}
match skill::cli_version_of(&on_disk)
.as_deref()
.and_then(|v| compare(v, binary).map(|o| (v, o)))
{
Some((v, Ordering::Less)) => CheckResult::warn(
id,
format!(
"codex companion '_shared/{filename}' is cli_version {v}, binary is {binary}"
),
suggest,
),
Some((v, Ordering::Greater)) => CheckResult::warn(
id,
format!(
"codex companion '_shared/{filename}' differs from the bundled copy and declares cli_version {v}, newer than binary {binary}"
),
"upgrade the orchestratectl binary, or reinstall with --agent codex --force to restore the bundled companion",
),
Some((_, Ordering::Equal)) => CheckResult::warn(
id,
format!(
"codex companion '_shared/{filename}' differs from the bundled copy while its cli_version matches binary {binary} (possible local edits)"
),
suggest,
),
None => CheckResult::warn(
id,
format!(
"codex companion '_shared/{filename}' differs from the bundled copy and declares no parseable cli_version at {}",
path.display()
),
suggest,
),
}
}
fn check_companion(
skill_name: &str,
companion: &skill::CompanionSource,
path: &std::path::Path,
binary: &str,
) -> CheckResult {
let filename = companion.filename;
let id = format!("skill.sync.{skill_name}.{filename}");
let suggest_install = format!("orchestratectl skill install {skill_name} --force");
let on_disk = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return CheckResult::warn(
id,
format!(
"companion '{filename}' for skill '{skill_name}' is not installed at {}",
path.display()
),
suggest_install,
);
}
Err(e) => {
return CheckResult::warn(
id,
format!(
"companion '{filename}' for skill '{skill_name}' is unreadable at {}: {e}",
path.display()
),
suggest_install,
);
}
};
if on_disk == companion.bundled_body {
return CheckResult::ok(
id,
format!(
"companion '{filename}' for skill '{skill_name}' matches the bundled content for binary {binary}"
),
);
}
let disk_version = skill::cli_version_of(&on_disk);
match disk_version
.as_deref()
.and_then(|v| compare(v, binary).map(|o| (v, o)))
{
Some((v, Ordering::Less)) => CheckResult::warn(
id,
format!(
"companion '{filename}' for skill '{skill_name}' is cli_version {v}, binary is {binary}"
),
suggest_install,
)
.with_safe_fix(FixAction::InstallSkill(skill_name.to_string())),
Some((v, Ordering::Greater)) => CheckResult::warn(
id,
format!(
"companion '{filename}' for skill '{skill_name}' differs from the bundled copy and declares cli_version {v}, newer than binary {binary}"
),
"upgrade the orchestratectl binary, or reinstall with --force to restore the bundled companion",
),
Some((_, Ordering::Equal)) => CheckResult::warn(
id,
format!(
"companion '{filename}' for skill '{skill_name}' differs from the bundled copy while its cli_version matches binary {binary} (possible local edits)"
),
suggest_install,
),
None => CheckResult::warn(
id,
format!(
"companion '{filename}' for skill '{skill_name}' differs from the bundled copy and declares no parseable cli_version at {}",
path.display()
),
suggest_install,
),
}
}
fn compare(a: &str, b: &str) -> Option<Ordering> {
let av = semver::Version::parse(a).ok()?;
let bv = semver::Version::parse(b).ok()?;
Some(av.cmp(&bv))
}