omne-cli 0.2.0

CLI for managing omne volumes: init, upgrade, and validate kernel and distro releases
Documentation
//! `.claude/skills/` symlinking for Claude Code discovery.
//!
//! After `.omne/core/` and `.omne/dist/` are extracted, this module
//! wires each skill directory into `.claude/skills/<name>` so Claude
//! Code auto-discovers kernel + distro skills without any runtime
//! indirection. Distro skills shadow kernel skills on name collision
//! (distro wins over kernel).
//!
//! Windows requires `ERROR_PRIVILEGE_NOT_HELD` unlocked via either
//! elevation or Developer Mode for directory symlinks. `preflight()`
//! fails fast with an actionable error before any download occurs.

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

use crate::error::CliError;

/// Windows error code for symlink-without-privilege.
#[cfg(windows)]
const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314;

/// Probe whether this process can create directory symlinks.
///
/// On Windows, creates a throwaway dir symlink in the system temp
/// directory. On `ERROR_PRIVILEGE_NOT_HELD`, returns
/// `SymlinkPrivilegeRequired` so `init` can bail before fetching
/// tarballs. On Unix this is a no-op — symlinks work unprivileged.
pub fn preflight() -> Result<(), CliError> {
    #[cfg(windows)]
    {
        use std::os::windows::fs::symlink_dir;
        use std::sync::atomic::{AtomicU64, Ordering};
        use std::time::{SystemTime, UNIX_EPOCH};

        static COUNTER: AtomicU64 = AtomicU64::new(0);

        let tmp = std::env::temp_dir();
        let pid = std::process::id();
        // Disambiguate parallel callers (cargo test with --test-threads>1)
        // and sequential calls within the same process.
        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        let src = tmp.join(format!("omne-preflight-src-{pid}-{nanos}-{seq}"));
        let dst = tmp.join(format!("omne-preflight-dst-{pid}-{nanos}-{seq}"));

        fs::create_dir_all(&src)?;

        let result = symlink_dir(&src, &dst);
        let _ = fs::remove_dir(&dst);
        let _ = fs::remove_dir_all(&src);

        match result {
            Ok(()) => Ok(()),
            Err(e) if e.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD) => {
                Err(CliError::SymlinkPrivilegeRequired)
            }
            Err(e) => Err(CliError::Io(format!("symlink preflight failed: {e}"))),
        }
    }

    #[cfg(not(windows))]
    {
        Ok(())
    }
}

/// Create `.claude/skills/<name>` symlinks pointing at every skill
/// directory under `.omne/core/skills/` and `.omne/dist/skills/`.
///
/// Distro skills shadow kernel skills: the distro layer is linked
/// second and overwrites any existing symlink produced by the kernel
/// pass.
pub fn link_skills(root: &Path) -> Result<(), CliError> {
    let omne = root.join(".omne");
    let claude_skills = root.join(".claude").join("skills");
    fs::create_dir_all(&claude_skills)?;

    // Kernel first, distro second — distro wins on name collision.
    for layer in ["core", "dist"] {
        let layer_skills = omne.join(layer).join("skills");
        if !layer_skills.is_dir() {
            continue;
        }
        link_layer(&layer_skills, &claude_skills)?;
    }
    Ok(())
}

fn link_layer(src_skills: &Path, dst_skills: &Path) -> Result<(), CliError> {
    for entry in fs::read_dir(src_skills)? {
        let entry = entry?;
        let src = entry.path();
        if !src.is_dir() {
            // Only directory-layout skills (skills/<name>/SKILL.md).
            continue;
        }
        let name = entry.file_name();
        let dst = dst_skills.join(&name);
        replace_symlink(&src, &dst)?;
    }
    Ok(())
}

/// Remove any existing symlink at `dst`, then create a fresh one
/// pointing at `src`. Refuses to remove a real directory so a user's
/// hand-authored `.claude/skills/<name>` is never clobbered.
fn replace_symlink(src: &Path, dst: &Path) -> Result<(), CliError> {
    if let Ok(meta) = fs::symlink_metadata(dst) {
        if meta.file_type().is_symlink() {
            remove_symlink(dst)?;
        } else {
            return Err(CliError::Io(format!(
                ".claude/skills/{} exists and is not a symlink — refusing to overwrite",
                dst.file_name().unwrap_or_default().to_string_lossy()
            )));
        }
    }
    symlink_dir(src, dst).map_err(|e| {
        #[cfg(windows)]
        if e.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD) {
            return CliError::SymlinkPrivilegeRequired;
        }
        CliError::Io(format!(
            "failed to symlink {} -> {}: {e}",
            dst.display(),
            src.display()
        ))
    })
}

#[cfg(windows)]
fn symlink_dir(src: &Path, dst: &Path) -> io::Result<()> {
    std::os::windows::fs::symlink_dir(src, dst)
}

#[cfg(unix)]
fn symlink_dir(src: &Path, dst: &Path) -> io::Result<()> {
    std::os::unix::fs::symlink(src, dst)
}

#[cfg(windows)]
fn remove_symlink(dst: &Path) -> io::Result<()> {
    // Windows: dir-symlinks must be removed with remove_dir.
    fs::remove_dir(dst)
}

#[cfg(unix)]
fn remove_symlink(dst: &Path) -> io::Result<()> {
    fs::remove_file(dst)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn make_skill(root: &Path, layer: &str, name: &str) {
        let dir = root.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();
    }

    #[test]
    fn links_kernel_skill() {
        let tmp = TempDir::new().unwrap();
        make_skill(tmp.path(), "core", "query-installation");

        link_skills(tmp.path()).unwrap();

        let link = tmp.path().join(".claude/skills/query-installation");
        let meta = fs::symlink_metadata(&link).unwrap();
        assert!(meta.file_type().is_symlink());
    }

    #[test]
    fn links_distro_skill() {
        let tmp = TempDir::new().unwrap();
        make_skill(tmp.path(), "dist", "assess-domain");

        link_skills(tmp.path()).unwrap();

        let link = tmp.path().join(".claude/skills/assess-domain");
        assert!(fs::symlink_metadata(&link)
            .unwrap()
            .file_type()
            .is_symlink());
    }

    #[test]
    fn dist_shadows_kernel_on_name_collision() {
        let tmp = TempDir::new().unwrap();
        make_skill(tmp.path(), "core", "dup");
        make_skill(tmp.path(), "dist", "dup");

        link_skills(tmp.path()).unwrap();

        let link = tmp.path().join(".claude/skills/dup");
        let target = fs::read_link(&link).unwrap();
        assert!(
            target.to_string_lossy().contains("dist"),
            "expected dist/ target, got {}",
            target.display()
        );
    }

    #[test]
    fn links_multiple_skills() {
        let tmp = TempDir::new().unwrap();
        make_skill(tmp.path(), "dist", "a");
        make_skill(tmp.path(), "dist", "b");
        make_skill(tmp.path(), "dist", "c");

        link_skills(tmp.path()).unwrap();

        for name in ["a", "b", "c"] {
            let link = tmp.path().join(".claude/skills").join(name);
            assert!(fs::symlink_metadata(&link)
                .unwrap()
                .file_type()
                .is_symlink());
        }
    }

    #[test]
    fn refuses_to_overwrite_real_directory() {
        let tmp = TempDir::new().unwrap();
        make_skill(tmp.path(), "dist", "preexisting");

        // User hand-authored skill at same name.
        let real = tmp.path().join(".claude/skills/preexisting");
        fs::create_dir_all(&real).unwrap();
        fs::write(real.join("SKILL.md"), "user content").unwrap();

        let err = link_skills(tmp.path()).unwrap_err();
        assert!(
            matches!(err, CliError::Io(ref m) if m.contains("refusing to overwrite")),
            "expected Io refusal, got {err:?}"
        );
    }

    #[test]
    fn idempotent_when_symlink_already_present() {
        let tmp = TempDir::new().unwrap();
        make_skill(tmp.path(), "dist", "repeat");

        link_skills(tmp.path()).unwrap();
        // Second call should replace the prior symlink without erroring.
        link_skills(tmp.path()).unwrap();

        let link = tmp.path().join(".claude/skills/repeat");
        assert!(fs::symlink_metadata(&link)
            .unwrap()
            .file_type()
            .is_symlink());
    }

    #[test]
    fn preflight_succeeds_in_test_environment() {
        // CI / dev environments running the test suite should have
        // symlink capability. If this fails on Windows CI, Developer
        // Mode is off and needs enabling.
        preflight().unwrap();
    }
}