nd / nd-cli/src/main.rs

raw file 11545 bytes
use clap::{Parser, Subcommand};
mod commands;
mod config;
mod guess;
mod guide;
mod resume;
use std::path::PathBuf;

#[derive(Parser)]
#[command(
    name = "nd",
    version,
    about = "push/pull vibecoded projects with rich, searchable metadata to a notdoublehub server",
    long_about = guide::LONG_ABOUT
)]
struct Cli {
    /// hub url; overrides ND_SERVER/config. default: the public hub
    #[arg(long, global = true)]
    server: Option<String>,
    /// bearer token; overrides ND_TOKEN/config
    #[arg(long, global = true)]
    token: Option<String>,
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// create a notdouble.toml manifest (and .ndignore) in a project
    Init {
        /// project dir (default: current dir)
        path: Option<PathBuf>,
        /// overwrite an existing manifest
        #[arg(long)]
        force: bool,
        /// guess manifest fields by inspecting the repo (Cargo.toml,
        /// package.json, pyproject, go.mod, README, git config)
        #[arg(long)]
        guess: bool,
    },
    /// validate a manifest and print searchability tips
    Validate {
        path: Option<PathBuf>,
        #[arg(long)]
        json: bool,
    },
    /// preview what would be packed (default), or write the bundle with -o
    Pack {
        path: Option<PathBuf>,
        /// write the tar.gz bundle here instead of just listing
        #[arg(long)]
        out: Option<PathBuf>,
        /// also show which ignore rule excluded each file
        #[arg(long)]
        explain: bool,
        /// compact rollup: per top-level dir + 10 largest files
        #[arg(long)]
        summary: bool,
    },
    /// show resolved config: which hub, identity, token, reachability
    Status,
    /// compare a local project dir with the published bundle
    Diff {
        slug: String,
        /// hash-level three-way preview (exactly what nd pull would do)
        #[arg(long)]
        content: bool,
        /// line-level unified diff (local dir vs the hub copy)
        #[arg(long)]
        patch: bool,
        /// project dir (default: current dir)
        path: Option<PathBuf>,
    },
    /// pack + push a project (auto-creates or updates by slug)
    Upload {
        path: Option<PathBuf>,
        /// force through the big-upload guard
        #[arg(long)]
        yes: bool,
        /// include machine specs (os, cpu, ram, gpu) without asking
        #[arg(long)]
        specs: bool,
        /// never include machine specs (skip the prompt)
        #[arg(long)]
        no_specs: bool,
        /// show what would be packed and where it would go, then stop
        #[arg(long)]
        dry_run: bool,
        #[arg(long)]
        json: bool,
    },
    /// download a project's full source
    Download {
        slug: String,
        /// destination dir (default: ./<slug>)
        dest: Option<PathBuf>,
        #[arg(long)]
        overwrite: bool,
    },
    /// fork a project (from a hub ref, or the dir you're standing in)
    Fork {
        /// hub ref (owner/slug) — or a project dir to fork in place
        source: Option<String>,
        /// name for the fork (default: <origin>-fork)
        #[arg(long)]
        name: Option<String>,
        /// publish the fork immediately (you become its maintainer)
        #[arg(long)]
        upload: bool,
    },
    /// run a project: inside its dir (`nd run`), or fetch+cache a ref
    /// (`nd run owner/slug`)
    Run {
        /// project ref (omit to run the project in the current dir)
        r#ref: Option<String>,
        /// re-download the bundle even if cached
        #[arg(long)]
        refresh: bool,
        /// require a bundled bin/<slug>-<os>-<arch> binary (error if absent)
        #[arg(long)]
        bin: bool,
        /// force the manifest's `run` command (ignore any bundled binary)
        #[arg(long)]
        shell: bool,
        /// extra args passed to the binary/run command (no `--` needed)
        #[arg(trailing_var_arg = true)]
        args: Vec<String>,
    },
    /// pull the hub copy into a project dir (three-way merge; never
    /// silently overwrites local edits)
    Pull {
        /// ref to pull (default: the dir's tracked nd_ref, else its name)
        #[arg(long, name = "ref")]
        ref_: Option<String>,
        /// project dir (default: current dir)
        path: Option<PathBuf>,
        /// on conflicts, take the hub copy
        #[arg(long)]
        force: bool,
    },
    /// search projects (names, descriptions, keywords, code keywords, ...)
    Search {
        query: String,
        #[arg(long, default_value_t = 20)]
        limit: usize,
        /// include forked projects in the results
        #[arg(long)]
        with_forks: bool,
        #[arg(long)]
        json: bool,
    },
    /// show full metadata for one project (use --json for machines)
    Info {
        slug: String,
        #[arg(long)]
        json: bool,
    },
    /// post a review of a project you downloaded and tried
    Review {
        slug: String,
        /// works | issues | broken | notes (free text allowed)
        #[arg(long, default_value = "notes")]
        verdict: String,
        /// review text; reads stdin if omitted and stdin is piped
        #[arg(long, short = 'm')]
        message: Option<String>,
        /// name shown on the review (your name or your model's)
        #[arg(long, default_value = "anonymous")]
        author: String,
    },
    /// read the reviews on a project
    Reviews {
        slug: String,
        #[arg(long)]
        json: bool,
    },
    /// post a maintainer update on a project page (signed)
    Update {
        slug: String,
        /// headline (e.g. "v1.1.0 — faster parser")
        #[arg(long)]
        title: String,
        /// body text; reads stdin if omitted and stdin is piped
        #[arg(long, short = 'm')]
        message: Option<String>,
    },
    /// read a project's updates
    Updates {
        slug: String,
        #[arg(long)]
        json: bool,
    },
    /// projects that look like this one ("doubles")
    Doubles {
        slug: String,
        #[arg(long)]
        json: bool,
    },
    /// list the forks of a project (with each fork's maintainer)
    Forks {
        slug: String,
        #[arg(long)]
        json: bool,
    },
    /// (maintainers) pull a fork's changes into your project dir,
    /// three-way merged — resolve, review, then nd upload
    Merge {
        /// the fork to merge (owner/slug)
        fork_ref: String,
        /// project dir (default: current dir)
        path: Option<PathBuf>,
        /// resolve conflicts toward the fork
        #[arg(long)]
        force: bool,
    },
    /// delete a project from the hub (needs --yes when non-interactive)
    Delete {
        slug: String,
        /// skip the confirmation prompt
        #[arg(long)]
        yes: bool,
    },
    /// list projects you own on the hub (or all with --all)
    Ls {
        /// show every project, not just yours
        #[arg(long)]
        all: bool,
        #[arg(long)]
        json: bool,
    },
    /// build a maintainership resume from all of an owner's projects
    Resume {
        /// owner id (8-char key; default: you) — works for any maintainer
        owner: Option<String>,
        /// markdown, ready to paste into a resume
        #[arg(long)]
        markdown: bool,
        #[arg(long)]
        json: bool,
    },
    /// bump the manifest's version for a release (patch by default)
    Bump {
        path: Option<PathBuf>,
        #[arg(long)]
        patch: bool,
        #[arg(long)]
        minor: bool,
        #[arg(long)]
        major: bool,
    },
    /// print your nd identity (public key used for ownership)
    Whoami {
        /// also print the SECRET seed so you can back it up somewhere safe
        #[arg(long)]
        export: bool,
    },
    /// hand a project to another maintainer's public key (signed)
    Transfer {
        slug: String,
        /// base64 ed25519 public key (`nd whoami` prints yours)
        new_owner: String,
    },
    /// replace this nd binary with the latest one published on the hub
    UpdateNd {
        /// replace even if versions match
        #[arg(long)]
        force: bool,
    },
    /// print a compact usage guide written for llm agents
    Llmhelp,
}

fn main() {
    let cli = Cli::parse();
    if let Err(e) = dispatch(cli.cmd, cli.server, cli.token) {
        eprintln!("error: {e}");
        for cause in e.chain().skip(1) {
            eprintln!("  caused by: {cause}");
        }
        std::process::exit(1);
    }
}

fn dispatch(cmd: Cmd, server: Option<String>, token: Option<String>) -> anyhow::Result<()> {
    match cmd {
        Cmd::Init { path, force, guess } => commands::init(path, force, guess),
        Cmd::Validate { path, json } => commands::validate(path, json),
        Cmd::Pack { path, out, explain, summary } => commands::pack(path, out, explain, summary),
        Cmd::Status => commands::status(server),
        Cmd::Diff { slug, path, content, patch } => {
            commands::diff(&slug, path, content, patch, server, token)
        }
        Cmd::Upload { path, yes, specs, no_specs, dry_run, json } => {
            commands::upload(path, yes, server, token, specs, no_specs, dry_run, json)
        }
        Cmd::Download { slug, dest, overwrite } => {
            commands::download(&slug, dest, server, token, overwrite)
        }
        Cmd::Fork { source, name, upload } => commands::fork(source, name, upload, server, token),
        Cmd::Run { r#ref, refresh, bin, shell, args } => {
            commands::run(r#ref, refresh, &args, bin, shell, server, token)
        }
        Cmd::Pull { ref_, path, force } => {
            commands::pull(ref_, path, force, false, true, server, token)
        }
        Cmd::Search { query, limit, with_forks, json } => {
            commands::search(&query, limit, with_forks, server, json)
        }
        Cmd::Info { slug, json } => commands::info(&slug, server, token, json),
        Cmd::Review { slug, verdict, message, author } => {
            commands::review(&slug, &verdict, message, &author, server, token)
        }
        Cmd::Reviews { slug, json } => commands::reviews(&slug, server, json),
        Cmd::Update { slug, title, message } => {
            commands::post_update(&slug, &title, message, server, token)
        }
        Cmd::Updates { slug, json } => commands::updates_list(&slug, server, json),
        Cmd::Doubles { slug, json } => commands::doubles(&slug, server, json),
        Cmd::Forks { slug, json } => commands::forks(&slug, server, json),
        Cmd::Merge { fork_ref, path, force } => {
            commands::merge(&fork_ref, path, force, server, token)
        }
        Cmd::Delete { slug, yes } => commands::delete(&slug, yes, server, token),
        Cmd::Ls { all, json } => commands::ls(all, server, json),
        Cmd::Resume { owner, markdown, json } => commands::resume(owner, markdown, json, server),
        Cmd::Bump { path, minor, major, .. } => {
            let kind = if major { "major" } else if minor { "minor" } else { "patch" };
            commands::bump(path, kind)
        }
        Cmd::Whoami { export } => commands::whoami(export),
        Cmd::Transfer { slug, new_owner } => commands::transfer(&slug, &new_owner, server, token),
        Cmd::UpdateNd { force } => commands::update_nd(force, server, token),
        Cmd::Llmhelp => {
            print!("{}", guide::LLM_GUIDE);
            Ok(())
        }
    }

}