omne-cli 0.2.1

CLI for managing omne volumes: init, upgrade, and validate kernel and distro releases
Documentation
//! Top-level CLI error type.
//!
//! `CliError` is a thin composition enum: it holds a small number of
//! cross-cutting variants (`NotAVolume`, `VolumeAlreadyExists`,
//! `ValidationFailed`) directly, plus `#[from]` conversions for the
//! per-module error types that are wired in Phase 1: `manifest::Error`
//! and `distro::Error`. Additional module error types (`github::Error`,
//! `tarball::Error`, `python::Error`) will be composed in as those
//! modules come online in subsequent units. The `volume` module returns
//! `Option` from `find_omne_root` rather than `Result`, so it has no
//! `Error` type yet — one will be added in Unit 9 if volume operations
//! start returning `Result`.
//!
//! The variants here are the union of cross-cutting errors the command
//! layer surfaces to `main.rs`; module-owned errors live in their own
//! modules and are wrapped at this layer. Keeping the top-level enum thin
//! avoids the god-type coupling pattern where one enum depends on every
//! crate (chrono, ureq, PathBuf, ...) through its error imports.

use std::path::PathBuf;

use thiserror::Error;

/// Error variant categories map to process exit codes at `main.rs`:
/// logical errors (cross-cutting variants and module-wrapper variants)
/// exit with code 1; clap argument-parse errors exit with code 2 via
/// clap's own machinery. Successful runs exit with code 0.
///
/// The variants below are reserved for the command handlers that land in
/// Units 8a, 9, and 10. The earlier units only wire up the dispatcher,
/// stub handlers, and leaf modules, so none of the variants are
/// constructed yet — the `#[allow(dead_code)]` is scoped to the enum and
/// will be removed automatically as each variant picks up a first call
/// site.
#[allow(dead_code)]
#[derive(Debug, Error)]
pub enum CliError {
    /// Walk-up from the current directory did not find a `.omne/` root.
    /// Produced by `upgrade` and `validate` when invoked outside a volume.
    #[error(".omne/ not found — not an omne volume")]
    NotAVolume,

    /// Produced by `init` when the current directory already contains a
    /// `.omne/` directory. The precheck deliberately does not walk up,
    /// per R13: `init` creates in the current directory only.
    #[error(".omne/ already exists at {path}")]
    VolumeAlreadyExists { path: PathBuf },

    /// Produced by `validate` after collecting one or more issues.
    /// The issue strings are carried in the variant so callers and
    /// tests can introspect them structurally; `main.rs` prints the
    /// header and each issue line.
    #[error("validation failed with {} issue(s)", issues.len())]
    ValidationFailed { issues: Vec<String> },

    /// Wraps `distro::Error` (e.g. `UnsupportedSpec` for `file://` or
    /// non-github.com hosts). Produced by `init` at argument parse time.
    #[error(transparent)]
    Distro(#[from] crate::distro::Error),

    /// Wraps `manifest::Error` (missing frontmatter, missing required
    /// field, YAML parse failure). Produced by `upgrade` and `validate`
    /// when reading `.omne/omne.md`.
    #[error(transparent)]
    Manifest(#[from] crate::manifest::Error),

    /// Wraps `github::Error` (rate limit, auth failure, no release found,
    /// no tarball asset, sanitized HTTP error). Produced by `init` and
    /// `upgrade` when fetching releases.
    #[error(transparent)]
    Github(#[from] crate::github::Error),

    /// Wraps `tarball::Error` (path traversal, unsupported entry type,
    /// pre-planted symlink, I/O). Produced during tarball extraction in
    /// `init` and `upgrade`.
    #[error(transparent)]
    Tarball(#[from] crate::tarball::Error),

    /// The extracted tarball did not contain the expected top-level
    /// directory (e.g. `core/` for kernel, `dist/` for distro).
    /// Indicates the upstream release workflow changed its tarball layout.
    #[error("tarball layout mismatch: expected '{expected}/' but found {found:?}")]
    TarballLayoutMismatch {
        expected: String,
        found: Vec<String>,
    },

    /// Wraps `python::Error` (gate runner failure, timeout, interpreter
    /// invocation failure). Produced by `validate` when running the gate
    /// runner script.
    #[error(transparent)]
    Python(#[from] crate::python::Error),

    /// The target directory for upgrade contains or is reached via a
    /// symlink. Refusing to `remove_dir_all` to prevent symlink traversal.
    #[error("unsafe target: {path} contains or is a symlink — refusing to remove")]
    UnsafeTarget { path: PathBuf },

    /// Windows-only: creating a directory symlink failed with
    /// ERROR_PRIVILEGE_NOT_HELD (1314). Tells the user the two ways to
    /// unlock symlink creation so they can re-run.
    #[error(
        "symlink privilege required on Windows\n\
         \n\
         Creating .claude/skills/ requires directory symlinks. Windows\n\
         blocks this for non-elevated processes unless Developer Mode\n\
         is enabled. Re-run `omne init` one of these ways:\n\
         \n\
           • Elevated PowerShell: right-click → Run as Administrator\n\
           • Enable Developer Mode: Settings → Privacy & Security → For developers"
    )]
    SymlinkPrivilegeRequired,

    /// Advisory lock on the ULID allocator file was not released within
    /// the acquire budget (default 5s). Produced by `run` when composing
    /// the per-run identifier via `crate::ulid::allocate`.
    #[error("ULID allocator lock {path} timed out")]
    UlidLockTimeout { path: PathBuf },

    /// Wraps `ulid::Error` for ULID allocator failures that are not
    /// lock timeouts (I/O errors on the lock file, malformed persisted
    /// state). Separate variant from `UlidLockTimeout` so callers can
    /// discriminate lock contention from data corruption.
    #[error(transparent)]
    Ulid(crate::ulid::Error),

    /// Wraps `worktree::Error` for `git worktree add / remove / list`
    /// failures surfaced by the runner preflight, executor teardown,
    /// and status enumeration.
    #[error(transparent)]
    Worktree(#[from] crate::worktree::Error),

    /// Wraps `event_log::Error` for per-run `events.jsonl` open /
    /// append / read / enumerate failures, including the advisory
    /// lock timeout surfaced by `EventLog::append`.
    #[error(transparent)]
    EventLog(#[from] crate::event_log::Error),

    /// Wraps `claude_proc::Error` for `claude -p` subprocess client
    /// failures (HostMissing, Spawn, Timeout, ExitedNonZero, stream
    /// I/O, capture-file I/O). Surfaced by Unit 11's executor when a
    /// prompt node's subprocess misbehaves.
    #[error(transparent)]
    ClaudeProc(#[from] crate::claude_proc::Error),

    /// Wraps `executor::DispatchError` for infrastructure failures
    /// surfaced while dispatching a single node (event-log I/O, spawn
    /// / wait errors, malformed pipe data slipping past the
    /// validator). Distinct from `NodeOutcome::Failed`, which is a
    /// *recorded* node failure — a `Dispatch` error means the
    /// executor couldn't even decide the outcome.
    #[error(transparent)]
    Dispatch(#[from] crate::executor::DispatchError),

    /// Wraps `std::io::Error` for filesystem and cwd operations.
    #[error("{0}")]
    Io(String),

    /// `omne status <run_id>` for a run that does not exist.
    #[error("run not found: {0}")]
    RunNotFound(String),

    /// A run completed with at least one `Failed` or `Blocked` node.
    /// The event log has already recorded `pipe.aborted`; this error
    /// is the CLI-surface representation so `main.rs` exits non-zero
    /// without re-deriving the state.
    #[error("pipe {run_id} aborted: {reason}")]
    PipeAborted { run_id: String, reason: String },
}

// Manual From — intentionally not `#[from]` because LockTimeout routes
// to a dedicated variant for user-facing messaging.
impl From<crate::ulid::Error> for CliError {
    fn from(err: crate::ulid::Error) -> Self {
        match err {
            crate::ulid::Error::LockTimeout { path } => CliError::UlidLockTimeout { path },
            other => CliError::Ulid(other),
        }
    }
}

impl From<std::io::Error> for CliError {
    fn from(err: std::io::Error) -> Self {
        CliError::Io(err.to_string())
    }
}

impl CliError {
    /// Exit code mapping for the process. All logical errors map to 1;
    /// clap parser failures exit with 2 via clap itself. Matches the
    /// Python CLI's exit code contract.
    pub fn exit_code(&self) -> i32 {
        match self {
            CliError::NotAVolume
            | CliError::VolumeAlreadyExists { .. }
            | CliError::ValidationFailed { .. }
            | CliError::Distro(_)
            | CliError::Manifest(_)
            | CliError::Github(_)
            | CliError::Tarball(_)
            | CliError::TarballLayoutMismatch { .. }
            | CliError::Python(_)
            | CliError::UnsafeTarget { .. }
            | CliError::UlidLockTimeout { .. }
            | CliError::Ulid(_)
            | CliError::Worktree(_)
            | CliError::EventLog(_)
            | CliError::ClaudeProc(_)
            | CliError::Dispatch(_)
            | CliError::Io(_)
            | CliError::RunNotFound(_)
            | CliError::PipeAborted { .. } => 1,
            CliError::SymlinkPrivilegeRequired => 7,
        }
    }
}