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(())
}
}
}