use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use crate::support::util::exit_code;
const REQUIRED_SKILL_DIRS: &[&str] = &[
"difflore-onboard",
"knowledge-agent",
"memory-candidate-triage",
"pre-submit-review",
"remember-rule-guide",
"rule-diff",
"rule-gap",
"rule-journey",
"rule-search",
"rule-why-fired",
"session-recap",
"smart-explore",
];
const PLUGIN_MCP_WRAPPER: &str = "${PLUGIN_ROOT}/scripts/difflore-mcp.js";
const PLUGIN_HOOK_WRAPPER_COMMAND: &str = "node \"${PLUGIN_ROOT}/scripts/difflore-hook.js\"";
const PLUGIN_HOOK_WRAPPER_COMMAND_JSON: &str =
"node \\\"${PLUGIN_ROOT}/scripts/difflore-hook.js\\\"";
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DistSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DistIssue {
pub severity: DistSeverity,
pub path: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DistCheckReport {
pub repo_root: String,
pub expected_version: Option<String>,
pub issues: Vec<DistIssue>,
}
impl DistCheckReport {
pub(crate) fn ok(&self) -> bool {
self.issues
.iter()
.all(|issue| issue.severity != DistSeverity::Error)
}
pub(crate) fn error_count(&self) -> usize {
self.issues
.iter()
.filter(|issue| issue.severity == DistSeverity::Error)
.count()
}
pub(crate) fn warning_count(&self) -> usize {
self.issues
.iter()
.filter(|issue| issue.severity == DistSeverity::Warning)
.count()
}
}
pub fn find_repo_root_from(start: &Path) -> Option<PathBuf> {
let mut cur = start.to_path_buf();
loop {
if cur.join("Cargo.toml").exists()
&& cur.join("crates").is_dir()
&& cur.join("plugin").is_dir()
{
return Some(cur);
}
if !cur.pop() {
return None;
}
}
}
pub fn verify_from_cwd() -> Result<DistCheckReport, String> {
let cwd = std::env::current_dir().map_err(|e| format!("could not resolve cwd: {e}"))?;
let root = find_repo_root_from(&cwd).ok_or_else(|| {
format!(
"`difflore dist verify` is a maintainer command — run it from a checkout \
of the difflore source tree (the one with `crates/difflore-cli/`). \
Current directory: {}",
cwd.display()
)
})?;
Ok(verify_repo(&root))
}
pub(crate) fn handle_verify(json: bool) {
let report = match verify_from_cwd() {
Ok(report) => report,
Err(message) => {
eprintln!("{message}");
exit_code(2);
}
};
if json {
match serde_json::to_string_pretty(&report) {
Ok(rendered) => println!("{rendered}"),
Err(e) => {
eprintln!("could not serialize dist report: {e}");
exit_code(2);
}
}
} else {
println!("dist verify — repo root: {}", report.repo_root);
if let Some(version) = &report.expected_version {
println!("manifest version: {version}");
}
for issue in &report.issues {
println!(" {:?}: {} — {}", issue.severity, issue.path, issue.message);
}
println!(
"{}: {} error(s), {} warning(s)",
if report.ok() { "ok" } else { "FAILED" },
report.error_count(),
report.warning_count(),
);
}
if !report.ok() {
exit_code(1);
}
}
pub fn verify_repo(root: &Path) -> DistCheckReport {
let expected_version = read_crate_version(&root.join("crates/difflore-cli/Cargo.toml"));
let mut report = DistCheckReport {
repo_root: root.display().to_string(),
expected_version,
issues: Vec::new(),
};
check_required_files(root, &mut report);
check_json_manifest(root, ".claude-plugin/plugin.json", &mut report);
check_json_manifest(root, ".codex-plugin/plugin.json", &mut report);
check_marketplace(root, &mut report);
check_mcp_bundle(root, &mut report);
check_hook_bundle(root, &mut report);
check_codex_hook_reachability(root, &mut report);
report
}
fn check_required_files(root: &Path, report: &mut DistCheckReport) {
for rel in [
".claude-plugin/marketplace.json",
".claude-plugin/plugin.json",
".codex-plugin/plugin.json",
"plugin/.mcp.json",
"plugin/hooks/hooks.json",
"plugin/scripts/difflore-runtime.js",
"plugin/scripts/difflore-mcp.js",
"plugin/scripts/difflore-hook.js",
] {
if !root.join(rel).exists() {
push(
report,
DistSeverity::Error,
rel,
"required distribution file is missing",
);
}
}
check_skill_bundle(root, report);
}
fn check_skill_bundle(root: &Path, report: &mut DistCheckReport) {
let skills_rel = "plugin/skills";
let expected: BTreeSet<&str> = REQUIRED_SKILL_DIRS.iter().copied().collect();
let entries = match fs::read_dir(root.join(skills_rel)) {
Ok(entries) => entries,
Err(e) => {
push(
report,
DistSeverity::Error,
skills_rel,
&format!("could not read skills directory: {e}"),
);
return;
}
};
let mut actual = BTreeSet::new();
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
push(
report,
DistSeverity::Error,
skills_rel,
&format!("could not read skills directory entry: {e}"),
);
continue;
}
};
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(e) => {
let path = entry.path().display().to_string();
push(
report,
DistSeverity::Error,
&path,
&format!("could not inspect skills directory entry: {e}"),
);
continue;
}
};
if !file_type.is_dir() {
continue;
}
let skill_name = entry.file_name().to_string_lossy().to_string();
let skill_rel = format!("{skills_rel}/{skill_name}");
actual.insert(skill_name);
if !entry.path().join("SKILL.md").exists() {
push(
report,
DistSeverity::Error,
&skill_rel,
"skill directory is missing SKILL.md",
);
}
}
let actual_names: BTreeSet<&str> = actual.iter().map(String::as_str).collect();
for skill in expected.difference(&actual_names) {
let rel = format!("{skills_rel}/{skill}/SKILL.md");
push(
report,
DistSeverity::Error,
&rel,
"required distribution skill is missing",
);
}
for skill in &actual {
if !expected.contains(skill.as_str()) {
let rel = format!("{skills_rel}/{skill}");
push(
report,
DistSeverity::Error,
&rel,
"skill directory is not registered in dist verify",
);
}
}
}
fn check_json_manifest(root: &Path, rel: &str, report: &mut DistCheckReport) {
let Some(value) = read_json(root, rel, report) else {
return;
};
expect_string(&value, "name", "difflore", rel, report);
expect_string(&value, "license", "Apache-2.0", rel, report);
if let Some(version) = report.expected_version.clone() {
expect_string(&value, "version", &version, rel, report);
}
let repo = value
.get("repository")
.and_then(Value::as_str)
.unwrap_or("");
let canonical = difflore_core::cloud::endpoints::GITHUB_REPO;
if !repo.contains(canonical) {
push(
report,
DistSeverity::Warning,
rel,
&format!("repository does not point at {canonical}"),
);
}
}
fn check_marketplace(root: &Path, report: &mut DistCheckReport) {
let rel = ".claude-plugin/marketplace.json";
let Some(value) = read_json(root, rel, report) else {
return;
};
expect_string(&value, "name", "difflore", rel, report);
let plugins = value.get("plugins").and_then(Value::as_array);
let difflore_plugins = plugins.map_or_else(Vec::new, |plugins| {
plugins
.iter()
.filter(|p| p.get("name") == Some(&Value::String("difflore".into())))
.collect::<Vec<_>>()
});
let Some(plugin) = difflore_plugins.first().copied() else {
push(
report,
DistSeverity::Error,
rel,
"plugins[] does not contain a difflore entry",
);
return;
};
if difflore_plugins.len() > 1 {
push(
report,
DistSeverity::Error,
rel,
"plugins[] contains duplicate difflore entries",
);
}
if let Some(version) = report.expected_version.clone() {
expect_string(plugin, "version", &version, rel, report);
}
expect_string(plugin, "source", ".", rel, report);
}
fn check_mcp_bundle(root: &Path, report: &mut DistCheckReport) {
let rel = "plugin/.mcp.json";
let Some(value) = read_json(root, rel, report) else {
return;
};
let server = value
.pointer("/mcpServers/difflore")
.or_else(|| value.pointer("/servers/difflore"));
let Some(server) = server else {
push(
report,
DistSeverity::Error,
rel,
"missing mcpServers.difflore entry",
);
return;
};
expect_string(server, "command", "node", rel, report);
let has_mcp_wrapper_arg = server
.get("args")
.and_then(Value::as_array)
.is_some_and(|args| {
args.iter()
.any(|arg| arg.as_str() == Some(PLUGIN_MCP_WRAPPER))
});
if !has_mcp_wrapper_arg {
push(
report,
DistSeverity::Error,
rel,
"difflore MCP entry must invoke the plugin runtime wrapper",
);
}
}
fn check_hook_bundle(root: &Path, report: &mut DistCheckReport) {
let rel = "plugin/hooks/hooks.json";
let raw = match fs::read_to_string(root.join(rel)) {
Ok(raw) => raw,
Err(e) => {
push(
report,
DistSeverity::Error,
rel,
&format!("could not read hooks bundle: {e}"),
);
return;
}
};
if !raw_contains_hook_wrapper(&raw) {
push(
report,
DistSeverity::Error,
rel,
&format!("hooks bundle missing `{PLUGIN_HOOK_WRAPPER_COMMAND}`"),
);
}
for needle in [
"PostToolUse",
"SessionStart",
"UserPromptSubmit",
"Stop",
"SessionEnd",
] {
if !raw.contains(needle) {
push(
report,
DistSeverity::Error,
rel,
&format!("hooks bundle missing `{needle}`"),
);
}
}
}
fn check_codex_hook_reachability(root: &Path, report: &mut DistCheckReport) {
let adapter_rel = "crates/difflore-cli/src/hook/adapters/codex.rs";
if !root.join(adapter_rel).exists() {
return;
}
let mut codex_hook_route = false;
for rel in [".codex-plugin/plugin.json", "plugin/hooks/hooks.json"] {
if fs::read_to_string(root.join(rel)).is_ok_and(|raw| raw_contains_hook_wrapper(&raw)) {
codex_hook_route = true;
break;
}
}
if root.join(".codex-plugin/hooks.json").exists()
|| root.join(".codex-plugin/hooks/hooks.json").exists()
{
codex_hook_route = true;
}
if !codex_hook_route {
push(
report,
DistSeverity::Warning,
".codex-plugin/plugin.json",
"Codex hook adapter exists but no Codex lifecycle hook distribution route was found; Codex installs currently wire MCP only",
);
}
}
fn raw_contains_hook_wrapper(raw: &str) -> bool {
raw.contains(PLUGIN_HOOK_WRAPPER_COMMAND) || raw.contains(PLUGIN_HOOK_WRAPPER_COMMAND_JSON)
}
fn read_json(root: &Path, rel: &str, report: &mut DistCheckReport) -> Option<Value> {
let path = root.join(rel);
let raw = match fs::read_to_string(&path) {
Ok(raw) => raw,
Err(e) => {
push(
report,
DistSeverity::Error,
rel,
&format!("could not read JSON: {e}"),
);
return None;
}
};
match serde_json::from_str(&raw) {
Ok(v) => Some(v),
Err(e) => {
push(
report,
DistSeverity::Error,
rel,
&format!("invalid JSON: {e}"),
);
None
}
}
}
fn expect_string(
value: &Value,
key: &str,
expected: &str,
rel: &str,
report: &mut DistCheckReport,
) {
match value.get(key).and_then(Value::as_str) {
Some(actual) if actual == expected => {}
Some(actual) => push(
report,
DistSeverity::Error,
rel,
&format!("`{key}` is `{actual}`, expected `{expected}`"),
),
None => push(
report,
DistSeverity::Error,
rel,
&format!("missing string field `{key}`"),
),
}
}
fn read_crate_version(path: &Path) -> Option<String> {
let raw = fs::read_to_string(path).ok()?;
let manifest: toml::Value = toml::from_str(&raw).ok()?;
manifest
.get("package")?
.get("version")?
.as_str()
.map(ToOwned::to_owned)
}
fn push(report: &mut DistCheckReport, severity: DistSeverity, path: &str, message: &str) {
report.issues.push(DistIssue {
severity,
path: path.to_owned(),
message: message.to_owned(),
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crate_version_parser_reads_package_version() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("Cargo.toml");
fs::write(
&path,
"[package]\nname = \"difflore-cli\"\nversion = \"0.1.0\"\n",
)
.expect("write");
assert_eq!(read_crate_version(&path).as_deref(), Some("0.1.0"));
}
#[test]
fn crate_version_parser_ignores_versions_outside_package() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("Cargo.toml");
fs::write(
&path,
"[workspace.dependencies]\nserde = { version = \"9.9.9\" }\n\n[package]\nname = \"difflore-cli\"\nversion = \"0.2.0\"\n\n[package.metadata.release]\nversion = \"1.2.3\"\n",
)
.expect("write");
assert_eq!(read_crate_version(&path).as_deref(), Some("0.2.0"));
}
#[test]
fn skill_bundle_flags_missing_and_unregistered_skill_dirs() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let root = tmp.path();
for skill in REQUIRED_SKILL_DIRS {
if *skill == "session-recap" {
continue;
}
let dir = root.join("plugin/skills").join(skill);
fs::create_dir_all(&dir).expect("mkdir");
fs::write(dir.join("SKILL.md"), "# skill\n").expect("write");
}
let extra = root.join("plugin/skills/unlisted-skill");
fs::create_dir_all(&extra).expect("mkdir");
fs::write(extra.join("SKILL.md"), "# extra\n").expect("write");
let mut report = DistCheckReport {
repo_root: root.display().to_string(),
expected_version: None,
issues: Vec::new(),
};
check_skill_bundle(root, &mut report);
assert!(
report
.issues
.iter()
.any(|issue| issue.path.contains("session-recap")),
"missing expected skill should be reported: {:?}",
report.issues
);
assert!(
report
.issues
.iter()
.any(|issue| issue.path.contains("unlisted-skill")),
"extra skill should be reported: {:?}",
report.issues
);
}
#[test]
fn report_ok_requires_no_error_issues() {
let mut report = DistCheckReport {
repo_root: ".".into(),
expected_version: Some("0.1.0".into()),
issues: Vec::new(),
};
push(&mut report, DistSeverity::Warning, "x", "warn");
assert!(report.ok());
push(&mut report, DistSeverity::Error, "x", "error");
assert!(!report.ok());
assert_eq!(report.error_count(), 1);
assert_eq!(report.warning_count(), 1);
}
#[test]
fn marketplace_reports_duplicate_difflore_plugin_entries() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let root = tmp.path();
fs::create_dir_all(root.join(".claude-plugin")).expect("mkdir");
fs::write(
root.join(".claude-plugin/marketplace.json"),
r#"{
"name": "difflore",
"plugins": [
{"name": "difflore", "version": "0.1.0", "source": "."},
{"name": "difflore", "version": "0.0.0", "source": "./stale"}
]
}"#,
)
.expect("write");
let mut report = DistCheckReport {
repo_root: root.display().to_string(),
expected_version: Some("0.1.0".to_owned()),
issues: Vec::new(),
};
check_marketplace(root, &mut report);
assert!(
report
.issues
.iter()
.any(|issue| issue.message.contains("duplicate difflore")),
"duplicate marketplace entry should be reported: {:?}",
report.issues
);
}
#[test]
fn hook_bundle_requires_stop_and_session_end_events() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let root = tmp.path();
fs::create_dir_all(root.join("plugin/hooks")).expect("mkdir");
fs::write(
root.join("plugin/hooks/hooks.json"),
r#"{
"hooks": {
"PostToolUse": [{"hooks": [{"command": "node \"${PLUGIN_ROOT}/scripts/difflore-hook.js\""}]}],
"SessionStart": [{"hooks": [{"command": "node \"${PLUGIN_ROOT}/scripts/difflore-hook.js\""}]}],
"UserPromptSubmit": [{"hooks": [{"command": "node \"${PLUGIN_ROOT}/scripts/difflore-hook.js\""}]}]
}
}"#,
)
.expect("write");
let mut report = DistCheckReport {
repo_root: root.display().to_string(),
expected_version: None,
issues: Vec::new(),
};
check_hook_bundle(root, &mut report);
assert!(
report
.issues
.iter()
.any(|issue| issue.message.contains("Stop")),
"missing Stop should be reported: {:?}",
report.issues
);
assert!(
report
.issues
.iter()
.any(|issue| issue.message.contains("SessionEnd")),
"missing SessionEnd should be reported: {:?}",
report.issues
);
}
#[test]
fn codex_adapter_without_hook_route_warns_in_dist_verify() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let root = tmp.path();
fs::create_dir_all(root.join("crates/difflore-cli/src/hook/adapters")).expect("mkdir");
fs::create_dir_all(root.join(".codex-plugin")).expect("mkdir");
fs::create_dir_all(root.join("plugin/hooks")).expect("mkdir");
fs::write(
root.join("crates/difflore-cli/src/hook/adapters/codex.rs"),
"",
)
.expect("write");
fs::write(root.join(".codex-plugin/plugin.json"), "{}").expect("write");
fs::write(
root.join("plugin/hooks/hooks.json"),
r#"{"hooks":{"Stop":[{"hooks":[{"command":"difflore-hook --client claude-code"}]}]}}"#,
)
.expect("write");
let mut report = DistCheckReport {
repo_root: root.display().to_string(),
expected_version: None,
issues: Vec::new(),
};
check_codex_hook_reachability(root, &mut report);
assert_eq!(report.warning_count(), 1);
assert!(report.issues[0].message.contains("Codex hook adapter"));
fs::write(
root.join(".codex-plugin/plugin.json"),
r#"{"hooks":[{"command":"node \"${PLUGIN_ROOT}/scripts/difflore-hook.js\""}]}"#,
)
.expect("write");
let mut routed_report = DistCheckReport {
repo_root: root.display().to_string(),
expected_version: None,
issues: Vec::new(),
};
check_codex_hook_reachability(root, &mut routed_report);
assert!(routed_report.issues.is_empty());
}
}