omne-cli 0.2.1

CLI for managing omne volumes: init, upgrade, and validate kernel and distro releases
Documentation
//! `omne init <distro>` — scaffold a new omne volume.
//!
//! Orchestrates: distro spec parse → `.omne/` precheck → scaffold →
//! tarball extraction → manifest stamp → bootloader write.
//!
//! Three entry points:
//! - `run()` — CLI handler, uses real GitHub API
//! - `init_with_client()` — test seam for HTTP integration tests (mockito)
//! - `init_with_tarballs()` — test seam for offline fixture tests

// Test-seam functions are called from lib.rs (integration tests) but
// not from main.rs (binary). The bin crate sees them as dead code.
#![allow(dead_code)]

use std::fs;
use std::path::Path;

use clap::Args as ClapArgs;

use crate::claude_skills;
use crate::defaults;
use crate::distro;
use crate::error::CliError;
use crate::fetch;
use crate::github::GithubClient;
use crate::manifest;
use crate::scaffold;
use crate::tarball;

/// Arguments for `omne init`.
#[derive(Debug, ClapArgs)]
pub struct Args {
    /// Distro specifier (run `omne init --help` for accepted forms).
    #[arg(long_help = "Distro specifier.\n\
\n\
Accepted forms:\n\
  bare name:  omne-nosce\n\
  org/repo:   omne-org/omne-nosce\n\
  HTTPS URL:  https://github.com/omne-org/omne-nosce.git\n\
  SSH URL:    git@github.com:omne-org/omne-nosce.git\n\
\n\
file:// URLs and non-github.com hosts are rejected.")]
    pub distro: String,
}

pub fn run(args: &Args) -> Result<(), CliError> {
    let root = std::env::current_dir()
        .map_err(|e| CliError::Io(format!("cannot determine current directory: {e}")))?;
    let github = GithubClient::from_env("https://api.github.com", "omne-cli");
    init_with_client(&args.distro, &root, &github)
}

/// Init with a pre-configured `GithubClient` — test seam for mockito tests.
pub fn init_with_client(
    distro_spec: &str,
    root: &Path,
    github: &GithubClient,
) -> Result<(), CliError> {
    let spec = distro::parse(distro_spec)?;

    let omne = root.join(".omne");
    if omne.exists() {
        return Err(CliError::VolumeAlreadyExists { path: omne });
    }

    // Fail-fast if we cannot create symlinks. On Windows this catches
    // non-elevated shells without Developer Mode before any network I/O.
    claude_skills::preflight()?;

    scaffold::create_volume_dirs(root)?;
    scaffold::write_docs_baseline(root)?;
    scaffold::write_gitignore(root)?;

    // Fetch kernel
    let (kernel_org, kernel_repo) = parse_source(defaults::DEFAULT_KERNEL_SOURCE);
    let kernel_tag = github.latest_release_tag(kernel_org, kernel_repo)?;
    fetch::download_and_extract(github, kernel_org, kernel_repo, &kernel_tag, &omne, "core")?;

    // Fetch distro
    let distro_tag = github.latest_release_tag(&spec.org, &spec.repo)?;
    fetch::download_and_extract(github, &spec.org, &spec.repo, &distro_tag, &omne, "dist")?;

    // Stamp and finalize
    stamp_and_finalize(root, &omne, &spec)
}

/// Init with pre-built tarballs — test seam for offline fixture tests.
pub fn init_with_tarballs(
    distro_spec: &str,
    root: &Path,
    kernel_tarball: &Path,
    distro_tarball: &Path,
) -> Result<(), CliError> {
    let spec = distro::parse(distro_spec)?;

    let omne = root.join(".omne");
    if omne.exists() {
        return Err(CliError::VolumeAlreadyExists { path: omne });
    }

    claude_skills::preflight()?;

    scaffold::create_volume_dirs(root)?;
    scaffold::write_docs_baseline(root)?;
    scaffold::write_gitignore(root)?;

    let kernel_file = fs::File::open(kernel_tarball)?;
    tarball::extract_safe(kernel_file, &omne)?;
    verify_top_level(&omne, "core")?;

    let distro_file = fs::File::open(distro_tarball)?;
    tarball::extract_safe(distro_file, &omne)?;
    verify_top_level(&omne, "dist")?;

    stamp_and_finalize(root, &omne, &spec)
}

/// Shared post-extraction logic: read distro metadata, stamp manifest,
/// write bootloader, print success.
fn stamp_and_finalize(root: &Path, omne: &Path, spec: &distro::DistroSpec) -> Result<(), CliError> {
    let (distro_name, distro_version) = read_distro_metadata(&omne.join("dist"));
    let volume_name = root
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "unknown".to_string());

    let today = chrono_today();
    let vars = manifest::Vars {
        volume: volume_name,
        distro: distro_name.clone(),
        distro_version,
        created: today,
        kernel_source: defaults::DEFAULT_KERNEL_SOURCE.to_string(),
        distro_source: format!("{}/{}", spec.org, spec.repo),
    };
    let stamped = manifest::stamp(&vars);
    scaffold::write_omne_readme(root, &stamped)?;
    scaffold::write_bootloader(root)?;

    // Link kernel + distro skills into `.claude/skills/` and file-based
    // commands into `.claude/commands/` for Claude Code discovery.
    // Preflight ran before any extraction so Windows privilege failures
    // surface before network I/O.
    claude_skills::link_layers(root)?;

    eprintln!(
        "\x1b[32m✓\x1b[0m Initialized omne volume '{}' with distro '{}'",
        vars.volume, distro_name
    );

    Ok(())
}

/// Split a `"org/repo"` source string into `(&str, &str)`.
fn parse_source(source: &str) -> (&str, &str) {
    let (org, repo) = source.split_once('/').expect("source must be org/repo");
    (org, repo)
}

/// Read distro name and version from `dist/manifest.json`.
/// Returns `("unknown", "0.0.0")` when the file is missing or malformed.
fn read_distro_metadata(dist_dir: &Path) -> (String, String) {
    let manifest_path = dist_dir.join("manifest.json");
    if manifest_path.is_file() {
        if let Ok(content) = fs::read_to_string(&manifest_path) {
            if let Ok(data) = serde_json::from_str::<serde_json::Value>(&content) {
                let name = data
                    .get("name")
                    .and_then(|v| v.as_str())
                    .unwrap_or("unknown")
                    .to_string();
                let version = data
                    .get("version")
                    .and_then(|v| v.as_str())
                    .unwrap_or("0.0.0")
                    .to_string();
                return (name, version);
            }
        }
    }
    ("unknown".to_string(), "0.0.0".to_string())
}

/// Verify that extraction produced the expected top-level directory.
/// Mirrors the check in `fetch::download_and_extract` so the offline
/// test seam (`init_with_tarballs`) has the same invariant.
fn verify_top_level(target: &Path, expected: &str) -> Result<(), CliError> {
    if !target.join(expected).is_dir() {
        let found: Vec<String> = fs::read_dir(target)
            .ok()
            .map(|entries| {
                entries
                    .filter_map(|e| e.ok())
                    .map(|e| e.file_name().to_string_lossy().into_owned())
                    .collect()
            })
            .unwrap_or_default();
        return Err(CliError::TarballLayoutMismatch {
            expected: expected.to_string(),
            found,
        });
    }
    Ok(())
}

/// Today's date as `YYYY-MM-DD`. Thin wrapper over [`crate::clock`].
fn chrono_today() -> String {
    crate::clock::now_utc().format_date()
}