netsky 0.1.7

netsky CLI: the viable system launcher and subcommand dispatcher
Documentation
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};

fn main() {
    let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir"));
    let repo_root = manifest_dir
        .ancestors()
        .nth(3)
        .expect("repo root")
        .to_path_buf();
    let in_source_tree = repo_root.join(".mcp.json").is_file();
    let source_root = if in_source_tree {
        repo_root.clone()
    } else {
        manifest_dir.join("assets/root")
    };

    // H2: netsky-core/prompts is the single authoritative source for the
    // prompts that prompt.rs reads via `include_str!`. In dev builds we
    // mirror them into assets/root/prompts/ so the committed fresh-install
    // bundle stays in sync. prompt_drift.rs in netsky-core gates the
    // post-commit state: if anyone edits netsky-core/prompts/ without
    // rebuilding, the test fails. Non-netsky-core files in
    // assets/root/prompts/ (first-run.md, agentinit.md, crash-handoff.md,
    // tick-request.md) are untouched.
    let assets_prompts = manifest_dir.join("assets/root/prompts");
    if in_source_tree {
        let core_prompts = repo_root.join("src/crates/netsky-core/prompts");
        mirror_netsky_core_prompts(&core_prompts, &assets_prompts);
        rerun_if_changed(&core_prompts);
    }

    let mut entries = Vec::new();

    rerun_if_changed(&source_root.join(".mcp.json"));
    rerun_if_changed(&source_root.join("AGENTS.md"));
    rerun_if_changed(&source_root.join("CLAUDE.md"));
    rerun_if_changed(&source_root.join(".agents/settings.json"));
    rerun_if_changed(&assets_prompts);
    rerun_if_changed(&source_root.join(".agents/skills"));
    rerun_if_changed(&source_root.join("scripts"));

    entries.push((
        ".mcp.json".to_string(),
        fs::read_to_string(source_root.join(".mcp.json")).expect("read .mcp.json"),
    ));
    entries.push((
        "AGENTS.md".to_string(),
        fs::read_to_string(source_root.join("AGENTS.md")).expect("read AGENTS.md"),
    ));
    entries.push((
        "CLAUDE.md".to_string(),
        fs::read_to_string(source_root.join("CLAUDE.md")).expect("read CLAUDE.md"),
    ));
    entries.push((
        ".agents/settings.json".to_string(),
        fs::read_to_string(source_root.join(".agents/settings.json"))
            .expect("read .agents/settings.json"),
    ));

    entries.extend(collect_prompts_from(&assets_prompts));
    entries.extend(collect_skills(&source_root));
    entries.extend(collect_scripts(&source_root));

    entries.sort_by(|a, b| a.0.cmp(&b.0));

    let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"));
    let out_file = out_dir.join("embedded_files.rs");
    let mut rendered = String::new();
    rendered.push_str("pub const EMBEDDED_FILES: &[(&str, &str)] = &[\n");
    for (path, content) in entries {
        rendered.push_str("    (");
        rendered.push_str(&rust_string(&path));
        rendered.push_str(", ");
        rendered.push_str(&rust_string(&content));
        rendered.push_str("),\n");
    }
    rendered.push_str("];\n");
    fs::write(out_file, rendered).expect("write embedded registry");
}

fn collect_prompts_from(dir: &Path) -> Vec<(String, String)> {
    let mut entries = Vec::new();
    if let Ok(read_dir) = fs::read_dir(dir) {
        for entry in read_dir.flatten() {
            let path = entry.path();
            if path.extension() != Some(OsStr::new("md")) {
                continue;
            }
            if !path.is_file() {
                continue;
            }
            let name = path
                .file_name()
                .expect("prompt file has a name")
                .to_string_lossy()
                .into_owned();
            entries.push((
                format!("prompts/{name}"),
                fs::read_to_string(&path).expect("read prompt"),
            ));
        }
    }
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    entries
}

/// Mirror netsky-core/prompts/*.md -> netsky-cli/assets/root/prompts/*.md.
/// Writes only when bytes differ so clean dev builds do not churn mtimes
/// and git diffs. Panics on I/O error: a broken mirror leaves committed
/// assets drifted from the live source, which is the exact condition H2
/// exists to prevent.
fn mirror_netsky_core_prompts(src: &Path, dst: &Path) {
    let Ok(read_dir) = fs::read_dir(src) else {
        return;
    };
    fs::create_dir_all(dst).expect("create assets/root/prompts");
    for entry in read_dir.flatten() {
        let path = entry.path();
        if path.extension() != Some(OsStr::new("md")) || !path.is_file() {
            continue;
        }
        let name = path.file_name().expect("prompt file has a name");
        let dest = dst.join(name);
        let src_bytes = fs::read(&path).expect("read netsky-core prompt");
        let needs_write = match fs::read(&dest) {
            Ok(existing) => existing != src_bytes,
            Err(_) => true,
        };
        if needs_write {
            fs::write(&dest, &src_bytes).expect("mirror netsky-core prompt");
        }
    }
}

fn collect_skills(repo_root: &Path) -> Vec<(String, String)> {
    let dir = repo_root.join(".agents/skills");
    let mut entries = Vec::new();
    walk_skills(&dir, repo_root, &mut entries);
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    entries
}

fn collect_scripts(repo_root: &Path) -> Vec<(String, String)> {
    let dir = repo_root.join("scripts");
    let mut entries = Vec::new();
    let Ok(read_dir) = fs::read_dir(&dir) else {
        return entries;
    };
    for entry in read_dir.flatten() {
        let path = entry.path();
        if !path.is_file() {
            continue;
        }
        let rel = path
            .strip_prefix(repo_root)
            .expect("script under repo root")
            .to_string_lossy()
            .replace('\\', "/");
        entries.push((rel, fs::read_to_string(&path).expect("read script")));
    }
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    entries
}

fn walk_skills(dir: &Path, repo_root: &Path, entries: &mut Vec<(String, String)>) {
    let Ok(read_dir) = fs::read_dir(dir) else {
        return;
    };
    for entry in read_dir.flatten() {
        let path = entry.path();
        if path.is_dir() {
            walk_skills(&path, repo_root, entries);
            continue;
        }
        if path.file_name() != Some(OsStr::new("SKILL.md")) {
            continue;
        }
        let rel = path
            .strip_prefix(repo_root)
            .expect("skill under repo root")
            .to_string_lossy()
            .replace('\\', "/");
        entries.push((rel, fs::read_to_string(&path).expect("read skill")));
    }
}

fn rerun_if_changed(path: &Path) {
    println!("cargo:rerun-if-changed={}", path.display());
}

fn rust_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for ch in s.chars() {
        for esc in ch.escape_default() {
            out.push(esc);
        }
    }
    out.push('"');
    out
}