netsky 0.2.0

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-prompts/prompts is the single authoritative source for the
    // shipped prompt bundle. We embed those bytes directly from the
    // netsky-prompts crate and read the remaining install-only prompt
    // assets from netsky-cli/assets/root/prompts/.
    let assets_prompts = manifest_dir.join("assets/root/prompts");
    if in_source_tree {
        let prompts = repo_root.join("src/crates/netsky-prompts/prompts");
        rerun_if_changed(&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(&source_root.join(".agents/hooks"));
    rerun_if_changed(&manifest_dir.join("assets/root/.claude/settings.json"));
    rerun_if_changed(&manifest_dir.join("assets/root/.claude/hooks"));
    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_cli_prompt_assets(&assets_prompts));
    entries.extend(collect_bundled_prompt_assets());
    entries.extend(collect_skills(&source_root));
    entries.extend(collect_hooks(
        &source_root,
        &manifest_dir.join("assets/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_cli_prompt_assets(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();
            if netsky_prompts::BUNDLED_PROMPT_FILES
                .iter()
                .any(|(bundled, _)| bundled == &name)
            {
                continue;
            }
            entries.push((
                format!("prompts/{name}"),
                fs::read_to_string(&path).expect("read prompt"),
            ));
        }
    }
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    entries
}

fn collect_bundled_prompt_assets() -> Vec<(String, String)> {
    let mut entries = netsky_prompts::BUNDLED_PROMPT_FILES
        .iter()
        .map(|(name, body)| (format!("prompts/{name}"), (*body).to_string()))
        .collect::<Vec<_>>();
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    entries
}

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 collect_hooks(source_root: &Path, assets_root: &Path) -> Vec<(String, String)> {
    let mut entries = Vec::new();
    walk_rel_files(
        &source_root.join(".agents/hooks"),
        source_root,
        &mut entries,
    );

    let mut asset_entries = Vec::new();
    walk_rel_files(
        &assets_root.join(".claude/hooks"),
        assets_root,
        &mut asset_entries,
    );
    for (rel, content) in asset_entries {
        if entries.iter().any(|(existing, _)| existing == &rel) {
            continue;
        }
        entries.push((rel, content));
    }

    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 walk_rel_files(dir: &Path, 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_rel_files(&path, root, entries);
            continue;
        }
        if !path.is_file() {
            continue;
        }
        let rel = path
            .strip_prefix(root)
            .expect("file under root")
            .to_string_lossy()
            .replace('\\', "/");
        entries.push((rel, fs::read_to_string(&path).expect("read file")));
    }
}

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
}