1use std::path::{Component, Path};
9
10use clap::Args as ClapArgs;
11use path_clean::PathClean;
12use walkdir::WalkDir;
13
14use crate::error::CliError;
15use crate::manifest;
16use crate::python;
17use crate::volume;
18
19const MAX_DEPTH: usize = 3;
23
24const REQUIRED_DIRS: &[&str] = &["core", "dist", "lib", "var"];
26
27const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
29 "volume",
30 "distro",
31 "distro-version",
32 "created",
33 "kernel-source",
34 "distro-source",
35];
36
37#[derive(Debug, ClapArgs)]
39pub struct Args {}
40
41pub fn run(_args: &Args) -> Result<(), CliError> {
42 let cwd = std::env::current_dir()
43 .map_err(|e| CliError::Io(format!("cannot determine current directory: {e}")))?;
44 validate_at_root(&cwd)
45}
46
47pub fn validate_at_root(start: &Path) -> Result<(), CliError> {
49 let root = volume::find_omne_root(start).ok_or(CliError::NotAVolume)?;
50 let omne = root.join(".omne");
51
52 let mut issues = Vec::new();
53 check_required_dirs(&omne, &mut issues);
54 check_core(&omne, &mut issues);
55 check_dist(&omne, &mut issues);
56 check_boot_chain(&root, &mut issues);
57 check_docs_baseline(&omne, &mut issues);
58 check_manifest(&omne, &mut issues);
59 check_depth(&omne, &mut issues);
60 check_gate_runner(&omne, &mut issues);
61 check_pipes(&root, &mut issues);
62
63 for warning in collect_legacy_skill_warnings(&omne) {
64 eprintln!("\x1b[33mwarning:\x1b[0m {warning}");
65 }
66
67 if issues.is_empty() {
68 eprintln!("\x1b[32mVolume is valid.\x1b[0m");
69 Ok(())
70 } else {
71 Err(CliError::ValidationFailed { issues })
72 }
73}
74
75fn collect_legacy_skill_warnings(omne: &Path) -> Vec<String> {
81 let mut warnings = Vec::new();
82 for layer in ["core", "dist"] {
83 let skills_dir = omne.join(layer).join("skills");
84 let entries = match std::fs::read_dir(&skills_dir) {
85 Ok(e) => e,
86 Err(_) => continue,
87 };
88 for entry in entries.flatten() {
89 let file_type = match entry.file_type() {
90 Ok(ft) => ft,
91 Err(_) => continue,
92 };
93 if !file_type.is_file() {
94 continue;
95 }
96 let path = entry.path();
97 if path.extension().is_none_or(|ext| ext != "md") {
98 continue;
99 }
100 let name = match path.file_stem().and_then(|s| s.to_str()) {
101 Some(n) => n.to_string(),
102 None => continue,
103 };
104 warnings.push(format!(
105 "single-file skill at {layer}/skills/{name}.md is no longer linked by v0.2.1+; move to {layer}/cmds/{name}.md"
106 ));
107 }
108 }
109 warnings.sort();
110 warnings
111}
112
113fn check_pipes(root: &Path, issues: &mut Vec<String>) {
118 let pipes_dir = volume::dist_dir(root).join("pipes");
119 if !pipes_dir.is_dir() {
120 return;
121 }
122 let entries = match std::fs::read_dir(&pipes_dir) {
123 Ok(e) => e,
124 Err(e) => {
125 issues.push(format!("cannot read {}: {e}", pipes_dir.display()));
126 return;
127 }
128 };
129 for entry in entries.flatten() {
130 let path = entry.path();
131 if path.extension().is_none_or(|ext| ext != "md") {
132 continue;
133 }
134 match crate::pipe::load(&path, root) {
135 Ok(pipe) => {
136 for warning in crate::pipe::collect_warnings(&pipe) {
137 eprintln!("\x1b[33mwarning:\x1b[0m pipe {}: {warning}", path.display());
138 }
139 }
140 Err(crate::pipe::LoadError::Parse(e)) => {
141 issues.push(format!("pipe {}: {e}", path.display()));
142 }
143 Err(crate::pipe::LoadError::Invalid(errs)) => {
144 for err in errs {
145 issues.push(format!("pipe {}: {err}", path.display()));
146 }
147 }
148 }
149 }
150}
151
152fn check_required_dirs(omne: &Path, issues: &mut Vec<String>) {
154 for &dir in REQUIRED_DIRS {
155 if !omne.join(dir).is_dir() {
156 issues.push(format!("missing required directory: .omne/{dir}/"));
157 }
158 }
159}
160
161fn check_core(omne: &Path, issues: &mut Vec<String>) {
163 let core = omne.join("core");
164 if !core.is_dir() {
165 return;
166 }
167 if !core.join("manifest.json").is_file() {
168 issues.push("missing kernel manifest: core/manifest.json".to_string());
169 }
170}
171
172fn check_dist(omne: &Path, issues: &mut Vec<String>) {
174 let dist = omne.join("dist");
175 if !dist.is_dir() {
176 return;
177 }
178 if !dist.join("AGENTS.md").is_file() {
179 issues.push("missing distro entry point: dist/AGENTS.md".to_string());
180 }
181}
182
183fn check_boot_chain(root: &Path, issues: &mut Vec<String>) {
187 let bootloader = root.join("CLAUDE.md");
188 if !bootloader.is_file() {
189 issues.push("missing CLAUDE.md at volume root".to_string());
190 return;
191 }
192 let content = match std::fs::read_to_string(&bootloader) {
193 Ok(c) => c,
194 Err(e) => {
195 issues.push(format!("cannot read CLAUDE.md: {e}"));
196 return;
197 }
198 };
199
200 let imports: Vec<&str> = content
201 .lines()
202 .map(str::trim)
203 .filter(|l| l.starts_with('@'))
204 .collect();
205
206 if imports.is_empty() {
207 issues.push("CLAUDE.md has no @import — expected @.omne/dist/AGENTS.md".to_string());
208 return;
209 }
210
211 let has_v2_import = imports.contains(&"@.omne/dist/AGENTS.md");
212
213 if !has_v2_import {
214 let is_legacy = imports.iter().any(|&l| {
215 l.contains("MANIFEST.md") || l.contains("SYSTEM.md") || l.contains(".omne/image/")
216 });
217 if is_legacy {
218 issues.push(
219 "legacy boot chain detected — run `omne init` to migrate to 1-hop @.omne/dist/AGENTS.md"
220 .to_string(),
221 );
222 } else {
223 issues.push(format!(
224 "CLAUDE.md @import does not reference .omne/dist/AGENTS.md — found: {}",
225 imports.join(", ")
226 ));
227 }
228 }
229}
230
231fn check_docs_baseline(omne: &Path, issues: &mut Vec<String>) {
233 let docs = omne.join("lib").join("docs");
234 if !docs.is_dir() {
235 return;
236 }
237 if !docs.join("index.md").is_file() {
238 eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/index.md");
239 }
240 for subdir in ["raw", "inter", "wiki"] {
241 if !docs.join(subdir).is_dir() {
242 eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/{subdir}/");
243 }
244 }
245 let _ = issues; }
247
248fn check_manifest(omne: &Path, issues: &mut Vec<String>) {
251 let readme = omne.join("omne.md");
252 if !readme.is_file() {
253 issues.push("missing .omne/omne.md".to_string());
254 return;
255 }
256
257 let content = match std::fs::read_to_string(&readme) {
258 Ok(c) => c,
259 Err(e) => {
260 issues.push(format!("cannot read .omne/omne.md: {e}"));
261 return;
262 }
263 };
264
265 let yaml_body = match manifest::extract_frontmatter_block(&content) {
266 Ok(body) => body,
267 Err(_) => {
268 issues.push(".omne/omne.md has no YAML frontmatter (---...---)".to_string());
269 return;
270 }
271 };
272
273 for &field in REQUIRED_MANIFEST_FIELDS {
274 let has_field = yaml_body
275 .lines()
276 .any(|line| line.starts_with(field) && line[field.len()..].starts_with(':'));
277 if !has_field {
278 issues.push(format!(".omne/omne.md missing required field: {field}"));
279 }
280 }
281}
282
283fn check_depth(omne: &Path, issues: &mut Vec<String>) {
287 let base = omne.join("lib").join("cfg");
288 if !base.is_dir() {
289 return;
290 }
291 for entry in WalkDir::new(&base).min_depth(1) {
292 let entry = match entry {
293 Ok(e) => e,
294 Err(_) => continue,
295 };
296 if !entry.file_type().is_dir() {
297 continue;
298 }
299 let rel = match entry.path().strip_prefix(omne) {
300 Ok(r) => r,
301 Err(_) => continue,
302 };
303 let depth = rel.components().count();
304 if depth > MAX_DEPTH {
305 issues.push(format!(
306 "depth violation ({depth} > {MAX_DEPTH}): .omne/{}",
307 rel.display()
308 ));
309 }
310 }
311}
312
313fn check_gate_runner(omne: &Path, issues: &mut Vec<String>) {
316 let core_manifest = omne.join("core/manifest.json");
317 if !core_manifest.is_file() {
318 return;
319 }
320
321 let content = match std::fs::read_to_string(&core_manifest) {
322 Ok(c) => c,
323 Err(_) => return,
324 };
325
326 let data: serde_json::Value = match serde_json::from_str(&content) {
327 Ok(d) => d,
328 Err(_) => {
329 issues.push("core/manifest.json is invalid JSON".to_string());
330 return;
331 }
332 };
333
334 let gate_runner = match data.get("gate_runner").and_then(|v| v.as_str()) {
335 Some(gr) => gr,
336 None => return,
337 };
338
339 let dist_dir = omne.join("dist");
340 if !is_safe_gate_runner_path(gate_runner, &dist_dir) {
341 issues.push(format!("gate runner path escapes dist/: {gate_runner}"));
342 return;
343 }
344
345 let runner_path = dist_dir.join(gate_runner);
346 if !runner_path.is_file() {
347 eprintln!("\x1b[33mwarning:\x1b[0m gate runner not found: dist/{gate_runner} (skipping)");
348 return;
349 }
350
351 let interpreter = match python::find_interpreter() {
352 Some(interp) => interp,
353 None => {
354 eprintln!("\x1b[33m{}\x1b[0m", python::missing_python_warning());
355 return;
356 }
357 };
358
359 if let Err(e) = python::run_gate_runner(&interpreter, &runner_path, &dist_dir) {
360 match e {
361 python::Error::GateRunnerFailed {
362 exit_code,
363 stdout,
364 stderr,
365 } => {
366 let mut msg = format!("gate runner failed (exit {exit_code}):");
367 for line in stdout.trim().lines() {
368 msg.push_str(&format!("\n {line}"));
369 }
370 for line in stderr.trim().lines() {
371 msg.push_str(&format!("\n {line}"));
372 }
373 issues.push(msg);
374 }
375 python::Error::GateRunnerTimedOut { elapsed_seconds } => {
376 issues.push(format!(
377 "gate runner timed out after {elapsed_seconds} seconds"
378 ));
379 }
380 python::Error::InterpreterInvocation(io_err) => {
381 issues.push(format!("failed to invoke gate runner: {io_err}"));
382 }
383 }
384 }
385}
386
387fn is_safe_gate_runner_path(gate_runner: &str, base_dir: &Path) -> bool {
389 let path = Path::new(gate_runner);
390
391 if path.is_absolute() {
392 return false;
393 }
394
395 for component in path.components() {
396 match component {
397 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
398 return false;
399 }
400 _ => {}
401 }
402 }
403
404 let resolved = base_dir.join(path).clean();
405 let base_clean = base_dir.clean();
406 resolved.starts_with(&base_clean)
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use std::fs;
413 use tempfile::TempDir;
414
415 fn make_volume(tmp: &Path) {
420 let omne = tmp.join(".omne");
421 fs::create_dir_all(omne.join("core").join("skills")).unwrap();
422 fs::create_dir_all(omne.join("dist").join("skills")).unwrap();
423 fs::create_dir_all(omne.join("core").join("cmds")).unwrap();
424 fs::create_dir_all(omne.join("dist").join("cmds")).unwrap();
425 }
426
427 fn make_dir_skill(tmp: &Path, layer: &str, name: &str) {
428 let dir = tmp.join(".omne").join(layer).join("skills").join(name);
429 fs::create_dir_all(&dir).unwrap();
430 fs::write(
431 dir.join("SKILL.md"),
432 format!("---\nname: {name}\ndescription: test\n---\n"),
433 )
434 .unwrap();
435 }
436
437 fn make_cmd(tmp: &Path, layer: &str, name: &str) {
438 let path = tmp
439 .join(".omne")
440 .join(layer)
441 .join("cmds")
442 .join(format!("{name}.md"));
443 fs::create_dir_all(path.parent().unwrap()).unwrap();
444 fs::write(&path, format!("# {name}\n")).unwrap();
445 }
446
447 fn make_legacy_file_skill(tmp: &Path, layer: &str, name: &str) {
448 let path = tmp
449 .join(".omne")
450 .join(layer)
451 .join("skills")
452 .join(format!("{name}.md"));
453 fs::create_dir_all(path.parent().unwrap()).unwrap();
454 fs::write(&path, format!("# {name}\n")).unwrap();
455 }
456
457 #[test]
458 fn no_warnings_for_valid_cmds_and_dir_skills() {
459 let tmp = TempDir::new().unwrap();
460 make_volume(tmp.path());
461 make_cmd(tmp.path(), "dist", "foo");
462 make_dir_skill(tmp.path(), "dist", "bar");
463
464 let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
465 assert!(
466 warnings.is_empty(),
467 "expected no warnings, got {warnings:?}"
468 );
469 }
470
471 #[test]
472 fn warns_on_legacy_dist_file_skill() {
473 let tmp = TempDir::new().unwrap();
474 make_volume(tmp.path());
475 make_legacy_file_skill(tmp.path(), "dist", "plan");
476
477 let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
478 assert_eq!(warnings.len(), 1, "got {warnings:?}");
479 assert!(
480 warnings[0].contains("dist/skills/plan.md")
481 && warnings[0].contains("dist/cmds/plan.md"),
482 "unexpected warning text: {}",
483 warnings[0]
484 );
485 }
486
487 #[test]
488 fn warns_on_legacy_core_file_skill() {
489 let tmp = TempDir::new().unwrap();
490 make_volume(tmp.path());
491 make_legacy_file_skill(tmp.path(), "core", "plan");
492
493 let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
494 assert_eq!(warnings.len(), 1, "got {warnings:?}");
495 assert!(
496 warnings[0].contains("core/skills/plan.md")
497 && warnings[0].contains("core/cmds/plan.md"),
498 "unexpected warning text: {}",
499 warnings[0]
500 );
501 }
502
503 #[test]
504 fn warns_on_legacy_even_when_cmds_present() {
505 let tmp = TempDir::new().unwrap();
506 make_volume(tmp.path());
507 make_legacy_file_skill(tmp.path(), "dist", "plan");
508 make_cmd(tmp.path(), "dist", "plan");
509
510 let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
511 assert_eq!(warnings.len(), 1, "got {warnings:?}");
512 assert!(
513 warnings[0].contains("dist/skills/plan.md"),
514 "unexpected warning text: {}",
515 warnings[0]
516 );
517 }
518
519 #[test]
520 fn ignores_dir_skills_and_non_md_files() {
521 let tmp = TempDir::new().unwrap();
522 make_volume(tmp.path());
523 make_dir_skill(tmp.path(), "dist", "legit");
524 fs::write(
526 tmp.path().join(".omne/dist/skills/README.txt"),
527 "not a skill",
528 )
529 .unwrap();
530
531 let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
532 assert!(
533 warnings.is_empty(),
534 "expected no warnings, got {warnings:?}"
535 );
536 }
537
538 #[test]
539 fn missing_skills_dirs_produce_no_warnings() {
540 let tmp = TempDir::new().unwrap();
541 let warnings = collect_legacy_skill_warnings(&tmp.path().join(".omne"));
543 assert!(warnings.is_empty());
544 }
545}