use std::path::{Component, Path};
use clap::Args as ClapArgs;
use path_clean::PathClean;
use walkdir::WalkDir;
use crate::error::CliError;
use crate::manifest;
use crate::python;
use crate::volume;
const MAX_DEPTH: usize = 3;
const REQUIRED_DIRS: &[&str] = &["core", "dist", "lib", "var"];
const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
"volume",
"distro",
"distro-version",
"created",
"kernel-source",
"distro-source",
];
#[derive(Debug, ClapArgs)]
pub struct Args {}
pub fn run(_args: &Args) -> Result<(), CliError> {
let cwd = std::env::current_dir()
.map_err(|e| CliError::Io(format!("cannot determine current directory: {e}")))?;
validate_at_root(&cwd)
}
pub fn validate_at_root(start: &Path) -> Result<(), CliError> {
let root = volume::find_omne_root(start).ok_or(CliError::NotAVolume)?;
let omne = root.join(".omne");
let mut issues = Vec::new();
check_required_dirs(&omne, &mut issues);
check_core(&omne, &mut issues);
check_dist(&omne, &mut issues);
check_boot_chain(&root, &mut issues);
check_docs_baseline(&omne, &mut issues);
check_manifest(&omne, &mut issues);
check_depth(&omne, &mut issues);
check_gate_runner(&omne, &mut issues);
check_pipes(&root, &mut issues);
for warning in collect_legacy_skill_warnings(&omne) {
eprintln!("\x1b[33mwarning:\x1b[0m {warning}");
}
if issues.is_empty() {
eprintln!("\x1b[32mVolume is valid.\x1b[0m");
Ok(())
} else {
Err(CliError::ValidationFailed { issues })
}
}
fn collect_legacy_skill_warnings(omne: &Path) -> Vec<String> {
let mut warnings = Vec::new();
for layer in ["core", "dist"] {
let skills_dir = omne.join(layer).join("skills");
let entries = match std::fs::read_dir(&skills_dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if !file_type.is_file() {
continue;
}
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "md") {
continue;
}
let name = match path.file_stem().and_then(|s| s.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
warnings.push(format!(
"single-file skill at {layer}/skills/{name}.md is no longer linked by v0.2.1+; move to {layer}/cmds/{name}.md"
));
}
}
warnings.sort();
warnings
}
fn check_pipes(root: &Path, issues: &mut Vec<String>) {
let pipes_dir = volume::dist_dir(root).join("pipes");
if !pipes_dir.is_dir() {
return;
}
let entries = match std::fs::read_dir(&pipes_dir) {
Ok(e) => e,
Err(e) => {
issues.push(format!("cannot read {}: {e}", pipes_dir.display()));
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "md") {
continue;
}
match crate::pipe::load(&path, root) {
Ok(pipe) => {
for warning in crate::pipe::collect_warnings(&pipe) {
eprintln!("\x1b[33mwarning:\x1b[0m pipe {}: {warning}", path.display());
}
}
Err(crate::pipe::LoadError::Parse(e)) => {
issues.push(format!("pipe {}: {e}", path.display()));
}
Err(crate::pipe::LoadError::Invalid(errs)) => {
for err in errs {
issues.push(format!("pipe {}: {err}", path.display()));
}
}
}
}
}
fn check_required_dirs(omne: &Path, issues: &mut Vec<String>) {
for &dir in REQUIRED_DIRS {
if !omne.join(dir).is_dir() {
issues.push(format!("missing required directory: .omne/{dir}/"));
}
}
}
fn check_core(omne: &Path, issues: &mut Vec<String>) {
let core = omne.join("core");
if !core.is_dir() {
return;
}
if !core.join("manifest.json").is_file() {
issues.push("missing kernel manifest: core/manifest.json".to_string());
}
}
fn check_dist(omne: &Path, issues: &mut Vec<String>) {
let dist = omne.join("dist");
if !dist.is_dir() {
return;
}
if !dist.join("AGENTS.md").is_file() {
issues.push("missing distro entry point: dist/AGENTS.md".to_string());
}
}
fn check_boot_chain(root: &Path, issues: &mut Vec<String>) {
let bootloader = root.join("CLAUDE.md");
if !bootloader.is_file() {
issues.push("missing CLAUDE.md at volume root".to_string());
return;
}
let content = match std::fs::read_to_string(&bootloader) {
Ok(c) => c,
Err(e) => {
issues.push(format!("cannot read CLAUDE.md: {e}"));
return;
}
};
let imports: Vec<&str> = content
.lines()
.map(str::trim)
.filter(|l| l.starts_with('@'))
.collect();
if imports.is_empty() {
issues.push("CLAUDE.md has no @import — expected @.omne/dist/AGENTS.md".to_string());
return;
}
let has_v2_import = imports.contains(&"@.omne/dist/AGENTS.md");
if !has_v2_import {
let is_legacy = imports.iter().any(|&l| {
l.contains("MANIFEST.md") || l.contains("SYSTEM.md") || l.contains(".omne/image/")
});
if is_legacy {
issues.push(
"legacy boot chain detected — run `omne init` to migrate to 1-hop @.omne/dist/AGENTS.md"
.to_string(),
);
} else {
issues.push(format!(
"CLAUDE.md @import does not reference .omne/dist/AGENTS.md — found: {}",
imports.join(", ")
));
}
}
}
fn check_docs_baseline(omne: &Path, issues: &mut Vec<String>) {
let docs = omne.join("lib").join("docs");
if !docs.is_dir() {
return;
}
if !docs.join("index.md").is_file() {
eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/index.md");
}
for subdir in ["raw", "inter", "wiki"] {
if !docs.join(subdir).is_dir() {
eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/{subdir}/");
}
}
let _ = issues; }
fn check_manifest(omne: &Path, issues: &mut Vec<String>) {
let readme = omne.join("omne.md");
if !readme.is_file() {
issues.push("missing .omne/omne.md".to_string());
return;
}
let content = match std::fs::read_to_string(&readme) {
Ok(c) => c,
Err(e) => {
issues.push(format!("cannot read .omne/omne.md: {e}"));
return;
}
};
let yaml_body = match manifest::extract_frontmatter_block(&content) {
Ok(body) => body,
Err(_) => {
issues.push(".omne/omne.md has no YAML frontmatter (---...---)".to_string());
return;
}
};
for &field in REQUIRED_MANIFEST_FIELDS {
let has_field = yaml_body
.lines()
.any(|line| line.starts_with(field) && line[field.len()..].starts_with(':'));
if !has_field {
issues.push(format!(".omne/omne.md missing required field: {field}"));
}
}
}
fn check_depth(omne: &Path, issues: &mut Vec<String>) {
let base = omne.join("lib").join("cfg");
if !base.is_dir() {
return;
}
for entry in WalkDir::new(&base).min_depth(1) {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_dir() {
continue;
}
let rel = match entry.path().strip_prefix(omne) {
Ok(r) => r,
Err(_) => continue,
};
let depth = rel.components().count();
if depth > MAX_DEPTH {
issues.push(format!(
"depth violation ({depth} > {MAX_DEPTH}): .omne/{}",
rel.display()
));
}
}
}
fn check_gate_runner(omne: &Path, issues: &mut Vec<String>) {
let core_manifest = omne.join("core/manifest.json");
if !core_manifest.is_file() {
return;
}
let content = match std::fs::read_to_string(&core_manifest) {
Ok(c) => c,
Err(_) => return,
};
let data: serde_json::Value = match serde_json::from_str(&content) {
Ok(d) => d,
Err(_) => {
issues.push("core/manifest.json is invalid JSON".to_string());
return;
}
};
let gate_runner = match data.get("gate_runner").and_then(|v| v.as_str()) {
Some(gr) => gr,
None => return,
};
let dist_dir = omne.join("dist");
if !is_safe_gate_runner_path(gate_runner, &dist_dir) {
issues.push(format!("gate runner path escapes dist/: {gate_runner}"));
return;
}
let runner_path = dist_dir.join(gate_runner);
if !runner_path.is_file() {
eprintln!("\x1b[33mwarning:\x1b[0m gate runner not found: dist/{gate_runner} (skipping)");
return;
}
let interpreter = match python::find_interpreter() {
Some(interp) => interp,
None => {
eprintln!("\x1b[33m{}\x1b[0m", python::missing_python_warning());
return;
}
};
if let Err(e) = python::run_gate_runner(&interpreter, &runner_path, &dist_dir) {
match e {
python::Error::GateRunnerFailed {
exit_code,
stdout,
stderr,
} => {
let mut msg = format!("gate runner failed (exit {exit_code}):");
for line in stdout.trim().lines() {
msg.push_str(&format!("\n {line}"));
}
for line in stderr.trim().lines() {
msg.push_str(&format!("\n {line}"));
}
issues.push(msg);
}
python::Error::GateRunnerTimedOut { elapsed_seconds } => {
issues.push(format!(
"gate runner timed out after {elapsed_seconds} seconds"
));
}
python::Error::InterpreterInvocation(io_err) => {
issues.push(format!("failed to invoke gate runner: {io_err}"));
}
}
}
}
fn is_safe_gate_runner_path(gate_runner: &str, base_dir: &Path) -> bool {
let path = Path::new(gate_runner);
if path.is_absolute() {
return false;
}
for component in path.components() {
match component {
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return false;
}
_ => {}
}
}
let resolved = base_dir.join(path).clean();
let base_clean = base_dir.clean();
resolved.starts_with(&base_clean)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_volume(tmp: &Path) {
let omne = tmp.join(".omne");
fs::create_dir_all(omne.join("core").join("skills")).unwrap();
fs::create_dir_all(omne.join("dist").join("skills")).unwrap();
fs::create_dir_all(omne.join("core").join("cmds")).unwrap();
fs::create_dir_all(omne.join("dist").join("cmds")).unwrap();
}
fn make_dir_skill(tmp: &Path, layer: &str, name: &str) {
let dir = tmp.join(".omne").join(layer).join("skills").join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: test\n---\n"),
)
.unwrap();
}
fn make_cmd(tmp: &Path, layer: &str, name: &str) {
let path = tmp
.join(".omne")
.join(layer)
.join("cmds")
.join(format!("{name}.md"));
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, format!("# {name}\n")).unwrap();
}
fn make_legacy_file_skill(tmp: &Path, layer: &str, name: &str) {
let path = tmp
.join(".omne")
.join(layer)
.join("skills")
.join(format!("{name}.md"));
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, format!("# {name}\n")).unwrap();
}
#[test]
fn no_warnings_for_valid_cmds_and_dir_skills() {
let tmp = TempDir::new().unwrap();
make_volume(tmp.path());
make_cmd(tmp.path(), "dist", "foo");
make_dir_skill(tmp.path(), "dist", "bar");
let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
assert!(
warnings.is_empty(),
"expected no warnings, got {warnings:?}"
);
}
#[test]
fn warns_on_legacy_dist_file_skill() {
let tmp = TempDir::new().unwrap();
make_volume(tmp.path());
make_legacy_file_skill(tmp.path(), "dist", "plan");
let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
assert_eq!(warnings.len(), 1, "got {warnings:?}");
assert!(
warnings[0].contains("dist/skills/plan.md")
&& warnings[0].contains("dist/cmds/plan.md"),
"unexpected warning text: {}",
warnings[0]
);
}
#[test]
fn warns_on_legacy_core_file_skill() {
let tmp = TempDir::new().unwrap();
make_volume(tmp.path());
make_legacy_file_skill(tmp.path(), "core", "plan");
let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
assert_eq!(warnings.len(), 1, "got {warnings:?}");
assert!(
warnings[0].contains("core/skills/plan.md")
&& warnings[0].contains("core/cmds/plan.md"),
"unexpected warning text: {}",
warnings[0]
);
}
#[test]
fn warns_on_legacy_even_when_cmds_present() {
let tmp = TempDir::new().unwrap();
make_volume(tmp.path());
make_legacy_file_skill(tmp.path(), "dist", "plan");
make_cmd(tmp.path(), "dist", "plan");
let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
assert_eq!(warnings.len(), 1, "got {warnings:?}");
assert!(
warnings[0].contains("dist/skills/plan.md"),
"unexpected warning text: {}",
warnings[0]
);
}
#[test]
fn ignores_dir_skills_and_non_md_files() {
let tmp = TempDir::new().unwrap();
make_volume(tmp.path());
make_dir_skill(tmp.path(), "dist", "legit");
fs::write(
tmp.path().join(".omne/dist/skills/README.txt"),
"not a skill",
)
.unwrap();
let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
assert!(
warnings.is_empty(),
"expected no warnings, got {warnings:?}"
);
}
#[test]
fn missing_skills_dirs_produce_no_warnings() {
let tmp = TempDir::new().unwrap();
let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
assert!(warnings.is_empty());
}
}