// mcpp.cli — top-level command dispatch (and nothing else). // // The cli layer only parses arguments and routes: // mcpp.cli.cmd_build / cmd_new / cmd_registry / cmd_cache / // mcpp.cli.cmd_toolchain / cmd_publish / cmd_self (parse + route) // mcpp.pm.commands (add / remove / update) // Domain logic lives in its owning subsystem: mcpp.build.{prepare,execute}, // mcpp.pm.index_management, mcpp.toolchain.lifecycle, mcpp.scaffold.create, // mcpp.publish.pipeline, mcpp.pack.pipeline, mcpp.bmi_cache.maintenance, mcpp.doctor, // mcpp.project, mcpp.fetcher.progress. // See .agents/docs/2026-06-10-cli-modularization.md for the architecture. module; #include #include export module mcpp.cli; import std; import mcpplibs.cmdline; import mcpp.cli.cmd_build; import mcpp.cli.cmd_cache; import mcpp.cli.cmd_new; import mcpp.cli.cmd_publish; import mcpp.cli.cmd_xpkg; import mcpp.cli.cmd_registry; import mcpp.cli.cmd_self; import mcpp.cli.cmd_toolchain; import mcpp.pm.commands; import mcpp.toolchain.fingerprint; // MCPP_VERSION import mcpp.wire; import mcpp.platform.env; // --offline → MCPP_OFFLINE import mcpp.platform.runtime_search; // linker-wrapper path-injection opt-out import mcpp.ui; import mcpp.log; export namespace mcpp::cli { int run(int argc, char** argv); } // namespace mcpp::cli namespace mcpp::cli { // Custom top-level help. cmdline's auto-generated `print_help` is a fine // default but its layout (`USAGE:`, no command-specific blurbs) doesn't // match what the e2e tests assert against — they check for `Usage:` // (mixed case) plus `mcpp new` / `mcpp build` literals. We keep the // canonical printer here so the docs/CHANGELOG examples don't drift // every time cmdline tweaks its formatting. void print_usage() { std::println("mcpp v{} - modern C++23 build tool", mcpp::toolchain::MCPP_VERSION); std::println(""); std::println("Usage:"); std::println("Project commands:"); std::println(" mcpp new Create a new package skeleton"); std::println(" mcpp build [options] Build the current package"); std::println(" mcpp run [target] [-- args...] Build + run a binary target"); std::println(" mcpp test [pattern] [-- args...] Build + run tests/**/*.cpp (--list, --timeout, --build-timeout, --message-format json)"); std::println(" mcpp clean [--bmi-cache] Remove target/ (and optionally the build cache)"); std::println(" mcpp add [ns.]pkg@ver Add an exact dependency to mcpp.toml"); std::println(" mcpp remove [ns.]pkg Remove an exact dependency from mcpp.toml"); std::println(" mcpp update [pkg] Re-resolve deps and rewrite mcpp.lock"); std::println(" mcpp search Search packages in registries"); std::println(" mcpp publish [--dry-run] Publish package to default registry"); std::println(" mcpp pack [target] Build + package (program: bundle; library: interface + binaries)"); std::println(" mcpp emit xpkg [-V VER] [-o FILE] Generate xpkg Lua entry"); std::println(" mcpp xpkg parse [--json] Validate an xpkg descriptor (resolver grammar)"); std::println(""); std::println("Resource management:"); std::println(" mcpp toolchain install|list|default Manage mcpp's private toolchains"); std::println(" mcpp cache dir|list|info|gc|... Inspect/manage the global build cache"); std::println(" mcpp index list|add|remove|update Manage package registries"); std::println(""); std::println("About mcpp itself:"); std::println(" mcpp self doctor Diagnose mcpp environment health"); std::println(" mcpp self env Print mcpp paths and toolchain"); std::println(" mcpp self config [--mirror CN|GLOBAL] Show or modify mcpp's xlings config"); std::println(" mcpp self version Show mcpp version"); std::println(" mcpp self explain Show extended description for an error code"); std::println(" mcpp --help / --version Help / version"); std::println(""); std::println("Build options:"); std::println(" --verbose, -v Verbose compiler output"); std::println(" --quiet, -q Suppress status output"); std::println(" --print-fingerprint Show toolchain fingerprint and 11 inputs"); std::println(" --configure-only Generate CDB without compiling or linking"); std::println(" --cache Dependency cache: global (default) | local | off"); std::println(" --no-cache Deprecated alias for --cache=off (clears the build dir)"); std::println(" --no-color Disable colored output"); std::println(" --offline Never touch the network (also: MCPP_OFFLINE=1)"); std::println(" --jobs N|auto, -j Concurrent compiles ('auto' = cores + free RAM)"); std::println(" --toolchain SPEC Use this toolchain for one build (e.g. llvm@22.1.8)"); std::println(""); std::println("Docs: https://github.com/mcpp-community/mcpp/tree/main/docs"); } int run(int argc, char** argv) { namespace cl = mcpplibs::cmdline; // ─── --quiet / --no-color: pre-scan ───────────────────────────────── // The cmdline lib propagates global options into nested subcommand // ParsedArgs, but we set ui:: state up-front so that *every* line // emitted from the action lambdas (including the very first // "Resolving toolchain" banner) honours the user's intent. This is // a side-channel only — the global options are still declared on // the App below so they show up in --help and pass schema checks. for (int i = 1; i < argc; ++i) { std::string_view a = argv[i]; // Everything after a bare `--` belongs to the program being run or the // test binary being invoked, not to mcpp. Without this, `mcpp run -- -j 4` // reads the child's flag as mcpp's own concurrency setting — `-j` is a // common enough flag that this is a matter of when, not whether. if (a == "--") break; if (a == "--quiet" || a == "-q") mcpp::ui::set_quiet(true); else if (a == "--no-color") mcpp::ui::disable_color(); else if (a == "--verbose" || a == "-v") mcpp::log::set_verbose(true); // --offline is published as the env var rather than plumbed through // BuildOverrides: its consumers are index refresh, package install and // toolchain auto-install, which sit in three subsystems and would each // need a parameter threaded down. Same shape as MCPP_VERBOSE above, and // it makes `MCPP_OFFLINE=1` and `--offline` literally the same switch. else if (a == "--offline") mcpp::platform::env::set("MCPP_OFFLINE", "1"); // --jobs rides the same side channel as --offline, for the same reason // recorded there: its consumer is deep in mcpp.build.execute and // threading a parameter down would touch every caller in between. // Accepts `--jobs N`, `--jobs=N`, `-j N` and `-jN`. else if (a == "--jobs" || a == "-j") { if (i + 1 < argc) mcpp::platform::env::set("MCPP_JOBS", argv[++i]); } else if (a.starts_with("--jobs=")) mcpp::platform::env::set("MCPP_JOBS", std::string(a.substr(7))); // --toolchain rides the same channel, for the same reason: its consumer // is deep inside prepare's resolution and threading a parameter down // would touch every caller in between. else if (a == "--toolchain") { if (i + 1 < argc) mcpp::platform::env::set("MCPP_TOOLCHAIN", argv[++i]); } else if (a.starts_with("--toolchain=")) mcpp::platform::env::set("MCPP_TOOLCHAIN", std::string(a.substr(12))); else if (a.starts_with("-j") && a.size() > 2) mcpp::platform::env::set("MCPP_JOBS", std::string(a.substr(2))); } // Decline xlings' linker-wrapper path injection, for this process and // everything it spawns (openxlings/xlings#540). // // That wrapper appends `-rpath "$XLINGS_SUBOS_LIB"` to every link it sees. // mcpp wants the TAG half of what it does and must refuse the PATH half: // `$XLINGS_SUBOS_LIB` names the ACTIVE SHELL's SubOS, which is measurably // not the one mcpp resolved — mcpp keeps its own xlings home under // `/registry`, so on an ordinary developer machine the variable // points at a different farm backed by a DIFFERENT PHYSICAL glibc payload. // Inheriting it would put a second libc on the artifact's search path, // which is the one thing rule B exists to prevent. mcpp emits its own farm // entry, derived from the binding it actually selected. // // Set here rather than per link command: the link line has a hard 128KiB // ceiling that real workspaces already spend 43% of, and children inherit // the environment for free. Declared BEFORE the wrapper ships, because // "the exit must be declared, not inferred" is the rule that whole // negotiation established — today this is a no-op. mcpp::platform::env::set( std::string(mcpp::platform::search::kLinkerPathInjectionOptOut), std::string(mcpp::platform::search::kLinkerPathInjectionOptOutValue)); // Env override (observability, esp. CI): MCPP_VERBOSE= // turns on verbose logging for EVERY mcpp invocation — including the ones // nested inside e2e test scripts that call $MCPP without flags. Lets a // workflow flip on diagnostics globally with one env var. An explicit // --quiet still wins (it is processed above and gates the verbose sinks). if (const char* v = std::getenv("MCPP_VERBOSE"); v && *v && std::string_view(v) != "0") mcpp::log::set_verbose(true); // ─── top-level --help / -h / --version intercept ──────────────────── // cmdline auto-handles these but its formatter doesn't match the // mixed-case "Usage:" + per-command blurbs that documentation + // e2e tests pin. Print our canonical screen and `mcpp X.Y.Z` // version line up-front so the App never sees these tokens. if (argc >= 2) { std::string_view a = argv[1]; if (a == "--help" || a == "-h") { print_usage(); return 0; } if (a == "--version" || a == "-V") { std::println("mcpp {}", mcpp::toolchain::MCPP_VERSION); return 0; } } // ─── action_rc plumbing ───────────────────────────────────────────── // cmdline's action callback returns void. Capture int return codes // via a shared local; `wrap_rc` adapts an `int(ParsedArgs&)` lambda // into the void-returning shape cmdline expects. int action_rc = 0; auto wrap_rc = [&action_rc](auto&& fn) { return [fn = std::forward(fn), &action_rc] (const cl::ParsedArgs& args) { action_rc = fn(args); }; }; // ─── nested-subcommand dispatcher ─────────────────────────────────── // cmdline's run() only dispatches one level — when a parent subcommand // (e.g. `self`, `cache`, `index`, `emit`) has its own children but no // parent action, the matched leaf never gets invoked. We give every // such parent an action that switches on the parsed child name and // forwards to the right cmd_* directly. using cmd_fn = int(*)(const cl::ParsedArgs&); auto dispatch_sub = [](std::string_view parent, const cl::ParsedArgs& parsed, std::initializer_list> table) -> int { if (!parsed.has_subcommand()) { std::string usage = std::format("`mcpp {}` requires a subcommand: ", parent); bool first = true; for (auto& [n, _] : table) { if (!first) usage += " / "; usage += n; first = false; } mcpp::ui::error(usage); return 2; } auto name = parsed.subcommand_name(); auto sub_ref = parsed.subcommand(); if (!sub_ref) return 2; for (auto& [n, fn] : table) { if (n == name) return fn(sub_ref->get()); } mcpp::ui::error(std::format("unknown `mcpp {} {}` subcommand", parent, name)); return 2; }; // ─── `--` passthrough for `mcpp run` / `mcpp test` ────────────────── // cmdline natively recognises `--` and dumps everything after it // into `parsed.positionals` (with the bare `--` token dropped). For // `mcpp run [target] -- args...` we need to distinguish the // optional `target` (a real positional) from the passthrough // tokens (which must reach the executed binary verbatim, even when // they look like `-x` / `--foo`). We split ourselves at the first // `--` and present cmdline only the pre-`--` slice; post-args go // into `passthrough` and are handed to the action helper directly. std::vector passthrough; std::vector trimmed_argv(argv, argv + argc); { for (std::size_t i = 1; i < trimmed_argv.size(); ++i) { if (std::string_view(trimmed_argv[i]) != "--") continue; for (std::size_t j = i + 1; j < trimmed_argv.size(); ++j) passthrough.emplace_back(trimmed_argv[j]); trimmed_argv.resize(i); // drop `--` and everything after break; } } int trimmed_argc = static_cast(trimmed_argv.size()); char** trimmed_argp = trimmed_argv.empty() ? nullptr : trimmed_argv.data(); // ─── Build the top-level App ──────────────────────────────────────── auto app = cl::App("mcpp") .version(std::string{mcpp::toolchain::MCPP_VERSION}) .description("modern C++ build tool") .option(cl::Option("quiet").short_name('q') .help("Suppress status output").global()) .option(cl::Option("verbose").short_name('v') .help("Show detailed progress on stderr").global()) .option(cl::Option("no-color") .help("Disable colored output").global()) .option(cl::Option("offline") .help("Never touch the network (index refresh, downloads, toolchain install)") .global()) // Answers "what do you speak" without spawning a command that might // fail. An optimisation, NOT the client's detection rule: on any mcpp // predating it this is itself an unknown option, so a client must // still detect the protocol by parsing stdout for schemaVersion+kind. .option(cl::Option("protocol-version") .help("Print the machine-output protocol this build speaks (JSON)")) // ─── project commands ────────────────────────────────────────── .subcommand(cl::App("new") .description("Create a new mcpp package skeleton") // not .required(): `--list-templates` runs without a name // (cmd_new validates presence for project creation itself). .arg(cl::Arg("name").help("Package directory name")) .option(cl::Option("template").short_name('t').takes_value().value_name("SPEC") .help("bin (default) | [ns.]pkg[@ver][:template] — exact package template")) .option(cl::Option("list-templates").takes_value().value_name("PKG") .help("List templates from exact [ns.]pkg[@ver]")) .action(wrap_rc(cmd_new))) .subcommand(cl::App("build") .description("Build the current package") .option(cl::Option("configure-only") .help("Generate compile_commands.json without compiling or linking")) .option(cl::Option("print-fingerprint") .help("Show toolchain fingerprint and 11 inputs")) .option(cl::Option("cache").takes_value().value_name("MODE") .help("Global dependency cache: global (default) | local | off")) .option(cl::Option("jobs").short_name('j').takes_value().value_name("N") .help("Concurrent compiles: a number, or 'auto' to size from cores + free RAM")) .option(cl::Option("toolchain").takes_value().value_name("SPEC") .help("Build with this toolchain for one build, e.g. llvm@22.1.8")) .option(cl::Option("no-cache") .help("Deprecated alias for --cache=off (also clears the build dir)")) .option(cl::Option("target").takes_value().help( "Build for (e.g. x86_64-linux-musl); looks up [target.] in mcpp.toml")) .option(cl::Option("static").help( "Force static linking (-static). On Linux, prefer pairing with --target -linux-musl")) .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") .help("Build only the named workspace member")) .option(cl::Option("profile").takes_value().value_name("NAME") .help("Build profile: release (default) | dev | dist | <[profile.*] name>")) .option(cl::Option("release").help("Shorthand for --profile release")) .option(cl::Option("dev").help("Shorthand for --profile dev (-O0 -g)")) .option(cl::Option("features").takes_value().value_name("LIST") .help("Activate root-package features (comma-separated)")) .option(cl::Option("cap").takes_value().value_name("LIST") .help("Pin capability providers (e.g. blas=openblas,lapack=mkl)")) .option(cl::Option("strict") .help("Treat manifest schema warnings (unknown feature/platform) as errors")) .option(cl::Option("workspace") .help("Build all workspace members")) .action(wrap_rc(cmd_build))) .subcommand(cl::App("run") .description("Build + run a binary target (after `--`, args are passed to it)") // NB: this positional is a BINARY NAME from [[bin]]/src layout — // unrelated to `--target ` (the cross-target axis). .arg(cl::Arg("target").help("Binary name (optional)")) .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") .help("Run only the named workspace member (single-member; no --workspace fan-out)")) .option(cl::Option("cache").takes_value().value_name("MODE") .help("Global dependency cache: global (default) | local | off")) .option(cl::Option("no-cache") .help("Deprecated alias for --cache=off (also clears the build dir)")) .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { return cmd_run(p, std::span(passthrough)); }))) .subcommand(cl::App("test") .description("Build + run all tests/**/*.cpp (after `--`, args go to each test binary)") .arg(cl::Arg("pattern") .help("Run only tests whose name contains PATTERN (optional)")) .option(cl::Option("message-format").takes_value().value_name("FMT") .help("Output format: human (default) | json (NDJSON, one record per test)")) .option(cl::Option("list") .help("List (filtered) tests without building or running them")) .option(cl::Option("timeout").takes_value().value_name("SECS") .help("Kill a test still RUNNING after SECS seconds (default 300; 0 = no limit)")) .option(cl::Option("build-timeout").takes_value().value_name("SECS") .help("Kill a compile/link drive still running after SECS seconds (default 0 = no limit; POSIX only)")) .option(cl::Option("workspace-timeout").takes_value().value_name("SECS") .help("Stop the --workspace fan-out after SECS seconds and report what did run (default 0 = no limit)")) .option(cl::Option("profile").takes_value().value_name("NAME") .help("Build profile for the test build: release (default) | dev | dist | <[profile.*] name>")) .option(cl::Option("features").takes_value().value_name("LIST") .help("Activate root-package features for the test build (comma-separated)")) .option(cl::Option("cap").takes_value().value_name("LIST") .help("Pin capability providers (e.g. blas=openblas,lapack=mkl)")) .option(cl::Option("strict") .help("Treat manifest schema warnings (unknown feature/platform) as errors")) .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") .help("Run tests only for the named workspace member")) .option(cl::Option("cache").takes_value().value_name("MODE") .help("Global dependency cache: global (default) | local | off")) .option(cl::Option("no-cache") .help("Deprecated alias for --cache=off (also clears the build dir)")) .option(cl::Option("workspace") .help("Run tests for all workspace members")) .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { return cmd_test(p, std::span(passthrough)); }))) .subcommand(cl::App("clean") .description("Remove target/ (and optionally the global build cache)") .option(cl::Option("bmi-cache").help("Also wipe the global build cache (see `mcpp cache clean`)")) .action(wrap_rc(cmd_clean))) .subcommand(cl::App("why") .description("Explain how the toolchain / runtime / deps were resolved") .arg(cl::Arg("topic").help("toolchain | runtime | deps (default: all)")) .action(wrap_rc(cmd_why))) .subcommand(cl::App("resolve") .description("Re-resolve the build plan and explain it") .option(cl::Option("explain").help("Print resolved toolchain / runtime / deps")) .action(wrap_rc(cmd_why))) .subcommand(cl::App("add") .description("Add a dependency to mcpp.toml") .arg(cl::Arg("pkg").help( "Exact package spec, e.g. foo@1.0.0 or compat.gtest@1.15.2") .required()) .option(cl::Option("dev").help( "Add to [dev-dependencies] (test-only, e.g. compat.gtest)")) .action(wrap_rc(mcpp::pm::commands::cmd_add))) .subcommand(cl::App("remove") .description("Remove a dependency from mcpp.toml") .arg(cl::Arg("pkg").help("Exact package selector [ns.]name").required()) .action(wrap_rc(mcpp::pm::commands::cmd_remove))) .subcommand(cl::App("update") .description("Re-resolve dependencies and rewrite mcpp.lock") .arg(cl::Arg("pkg").help("If given, update only that package")) .action(wrap_rc(mcpp::pm::commands::cmd_update))) .subcommand(cl::App("search") .description("Search packages in configured registries") .arg(cl::Arg("keyword").help("Search keyword (substring match)").required()) .action(wrap_rc(cmd_search))) .subcommand(cl::App("publish") .description("Publish package to default registry") .option(cl::Option("dry-run").help("Print xpkg.lua without uploading")) .option(cl::Option("allow-dirty").help("Allow uncommitted changes")) .action(wrap_rc(cmd_publish))) .subcommand(cl::App("pack") // "archive", not "tarball": a Windows target produces a .zip, and // the help said tarball while the code had already stopped // agreeing. `--format tar` likewise selects "an archive rather // than a plain directory" — WHICH archive follows the artifact, // because a .tar.gz full of DLLs is a package most Windows users // cannot open without installing something first. // Says both shapes, because `[targets.].kind` picks between them // and the one-line help is where a reader finds that out. "Bundle // into a self-contained archive" described only the program case, // which is now half of what this command does. .description("Build + package: a program becomes a self-contained " "bundle, a library an interface + prebuilt binaries") // NB: a target NAME from [targets.*], not a triple — the same // split `mcpp run [target]` has. Its `kind` decides what is // packed, so there is no --lib and no --artifact: a program // becomes an application bundle, a library becomes a library // package. Omit it and mcpp picks the only packable target. .arg(cl::Arg("target").help("Target name from [targets.*] (optional)")) .option(cl::Option("mode").takes_value() .help("system | vendored (default) | self-contained | static")) .option(cl::Option("target").takes_value().multiple() .help("Triple, e.g. x86_64-linux-musl (repeatable: one leg per triple)")) .option(cl::Option("format").takes_value() .help("tar (default; .zip for a Windows target) | dir")) .option(cl::Option("output").short_name('o').takes_value() .help("Override output path")) .action(wrap_rc(cmd_pack))) // ─── emit (one nested subcommand: xpkg) ──────────────────────── .subcommand(cl::App("emit") .description("Generate package descriptor (xpkg)") .subcommand(cl::App("xpkg") .description("Generate xpkg Lua entry") .option(cl::Option("version").short_name('V').takes_value().value_name("VER") .help("Override package version")) .option(cl::Option("output").short_name('o').takes_value().value_name("FILE") .help("Write to file instead of stdout")) .option(cl::Option("namespace").takes_value().value_name("NS") .help("Package namespace for the emitted descriptor " "(overrides [package] namespace). Emits both " "`namespace` and the fully-qualified `name`"))) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { return dispatch_sub("emit", p, {{"xpkg", cmd_emit_xpkg}}); }))) // ─── xpkg (descriptor tooling: parse) ────────────────────────── .subcommand(cl::App("xpkg") .description("Inspect / validate xpkg descriptors") .subcommand(cl::App("parse") .description("Parse a descriptor's mcpp segment exactly as the resolver would (strict: unknown keys are errors)") .option(cl::Option("json") .help("Emit machine-readable JSON (legacy payload, kept for ever)")) .option(cl::Option("format").takes_value().value_name("json") .help("Machine-readable output (enveloped; see docs/11-machine-output.md)")) .option(cl::Option("allow-unknown") .help("Downgrade unknown mcpp-segment keys from error to warning")) .option(cl::Option("all-os") .help("Validate every per-OS section (linux/macosx/windows), " "not just the running host's")) .option(cl::Option("allow-split-name") .help("OBSOLETE (kept accepted so 0.0.105-era index CI keeps " "working): skip the package.name form check. Since " "0.0.106 the canonical form IS the short name, so " "xlings-native descriptors pass without this flag"))) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { return dispatch_sub("xpkg", p, {{"parse", cmd_xpkg_parse}}); }))) // ─── resource management ─────────────────────────────────────── .subcommand(cl::App("toolchain") .description("Install / list / select / remove C++ toolchains") .subcommand(cl::App("list").description("List installed toolchains")) .subcommand(cl::App("install") .description("Install a toolchain via mcpp's xlings") // Both `mcpp toolchain install gcc 16.1.0` and `mcpp toolchain // install gcc@16.1.0` are accepted, and the version may be // partial (`15`, `15.1`) — mcpp resolves to the highest match. // With --target the family may be omitted entirely (taken from // the target's convention pin): // mcpp toolchain install --target x86_64-windows-gnu .arg(cl::Arg("compiler").help("gcc | llvm | msvc (or gcc@16.1.0; legacy aliases accepted)")) .arg(cl::Arg("version").help("e.g. 16.1.0, 15, 15.1")) .option(cl::Option("target").takes_value().help( "Install the toolchain payload for (e.g. x86_64-windows-gnu)"))) .subcommand(cl::App("default") .description("Set the default toolchain (and optionally the default target)") // Same dual-form as `install`: `gcc@16.1.0` or `gcc 16.1.0`, // partial versions allowed. .arg(cl::Arg("spec").help("[@] (version may be partial)").required()) .arg(cl::Arg("version").help("(optional, alternative to @-form)")) .option(cl::Option("target").takes_value().help( "Default build target (omit = host)"))) .subcommand(cl::App("remove") .description("Uninstall a toolchain") .arg(cl::Arg("spec").help("@").required()) .option(cl::Option("target").takes_value().help( "Remove the payload for instead of the host one"))) .action(wrap_rc(cmd_toolchain))) .subcommand(cl::App("cache") .description("Inspect and manage the global build cache") .subcommand(cl::App("dir") .description("Print the cache root (and any pre-v1 cache)")) .subcommand(cl::App("list") .description("List cache entries with size + last-use") .option(cl::Option("json") .help("Emit machine-readable JSON (legacy payload, kept for ever)")) .option(cl::Option("format").takes_value().value_name("json") .help("Machine-readable output (enveloped; see docs/11-machine-output.md)"))) .subcommand(cl::App("info") .description("Show details (incl. key inputs) for a cached package") .arg(cl::Arg("pkg").help("@").required())) .subcommand(cl::App("prune") .description("Drop entries not used within a threshold") .option(cl::Option("older-than").takes_value().value_name("N{s|m|h|d}") .help("Age threshold (e.g. 30d)"))) .subcommand(cl::App("gc") .description("LRU-collect package entries to a size and/or age budget") .option(cl::Option("max-size").takes_value().value_name("N{MiB|GiB}") .help("Keep the package cache under this size (e.g. 5GiB)")) .option(cl::Option("older-than").takes_value().value_name("N{s|m|h|d}") .help("Also drop entries unused for longer than this"))) .subcommand(cl::App("clean") .description("Drop cache entries (default: package entries only)") .option(cl::Option("deps").help("Drop package entries (default)")) .option(cl::Option("std").help("Drop std module entries")) .option(cl::Option("all").help("Drop both")) .option(cl::Option("legacy") .help("Remove the unused pre-v1 cache at $MCPP_HOME/bmi"))) .subcommand(cl::App("verify") .description("Check every entry's manifest against the files on disk")) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { return dispatch_sub("cache", p, { {"dir", cmd_cache_dir}, {"list", cmd_cache_list}, {"info", cmd_cache_info}, {"prune", cmd_cache_prune}, {"gc", cmd_cache_gc}, {"clean", cmd_cache_clean}, {"verify", cmd_cache_verify}, }); }))) .subcommand(cl::App("index") .description("Manage configured package registries") .subcommand(cl::App("list") .description("List configured registries")) .subcommand(cl::App("add") .description("Add a custom registry") .arg(cl::Arg("name").help("Registry name").required()) .arg(cl::Arg("url").help("Registry URL").required())) .subcommand(cl::App("remove") .description("Remove a registry") .arg(cl::Arg("name").help("Registry name").required())) .subcommand(cl::App("update") .description("Refresh local registry clones") .arg(cl::Arg("name").help("If given, update only this index"))) .subcommand(cl::App("status") .description("Show local index presence/freshness (offline)")) .subcommand(cl::App("pin") .description("Pin a custom index to a commit rev in mcpp.toml") .arg(cl::Arg("name").help("Index name").required()) .arg(cl::Arg("rev").help("Commit sha (defaults to current lock rev)"))) .subcommand(cl::App("unpin") .description("Remove rev pin from a custom index in mcpp.toml") .arg(cl::Arg("name").help("Index name").required())) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { return dispatch_sub("index", p, { {"list", cmd_index_list}, {"add", cmd_index_add}, {"remove", cmd_index_remove}, {"update", cmd_index_update}, {"status", cmd_index_status}, {"pin", cmd_index_pin}, {"unpin", cmd_index_unpin}, }); }))) // ─── about mcpp itself ───────────────────────────────────────── .subcommand(cl::App("self") .description("Inspect and manage mcpp itself") .subcommand(cl::App("init") .description("Initialize or repair mcpp sandbox") .option(cl::Option("force") .help("Delete registry and re-initialize from scratch"))) .subcommand(cl::App("doctor") .description("Diagnose mcpp environment health")) .subcommand(cl::App("env") .description("Print mcpp paths and configuration") .option(cl::Option("format").takes_value().value_name("json") .help("Machine-readable output (enveloped; see docs/11-machine-output.md)"))) .subcommand(cl::App("config") .description("Show or modify mcpp's private xlings configuration") .option(cl::Option("mirror").takes_value().value_name("CN|GLOBAL") .help("Set xlings mirror for mcpp's private registry"))) .subcommand(cl::App("version") .description("Show mcpp version")) .subcommand(cl::App("explain") .description("Show extended description for an error code") .arg(cl::Arg("code").help("Error code such as E0001").required())) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { return dispatch_sub("self", p, { {"init", cmd_self_init}, {"doctor", cmd_doctor}, {"env", cmd_env}, {"config", cmd_self_config}, {"version", cmd_self_version}, {"explain", cmd_explain_action}, }); }))) // ─── top-level explain alias ────────────────────────────────── // Preserves `mcpp explain E0001` as a shortcut for // `mcpp self explain E0001`. .subcommand(cl::App("explain") .description("Show extended description for an error code") .arg(cl::Arg("code").help("Error code such as E0001").required()) .action(wrap_rc(cmd_explain_action))) // ─── bareword `version` alias ───────────────────────────────── // cmdline natively handles `--help`/`--version`/`-h`; the bareword // `mcpp help` is intercepted by the pre-scan above (prints the // canonical usage screen). `mcpp version` is wired through the // App so it shows up in the auto-generated subcommand list. .subcommand(cl::App("version") .description("Show mcpp version") .action(wrap_rc(cmd_self_version))) // ─── hidden / internal ───────────────────────────────────────── .subcommand(cl::App("dyndep") .description("(internal: invoked by ninja) Emit ninja dyndep file from .ddi inputs") .option(cl::Option("output").short_name('o').takes_value().value_name("PATH") .help("Path to write dyndep file")) .option(cl::Option("single").help("Single-file mode: one .ddi → one .dd")) .option(cl::Option("bmi-dir").takes_value().value_name("DIR") .help("BMI cache directory name (default: gcm.cache)")) .option(cl::Option("bmi-ext").takes_value().value_name("EXT") .help("BMI file extension (default: .gcm)")) .option(cl::Option("split-module") .help("Also emit a record for the provided BMI (two-phase " "schedule: BMI and object are separate edges)")) .option(cl::Option("expect-provides").takes_value().value_name("NAME") .help("(verification) planned provided module for this TU")) .option(cl::Option("expect-imports").takes_value().value_name("CSV") .help("(verification) planned imports for this TU, comma-separated")) .option(cl::Option("expect-none") .help("(verification) planner assumed no provides/imports")) .action(wrap_rc(cmd_dyndep))) .subcommand(cl::App("stage") .description("(internal: invoked by ninja) Stage a cached artifact into the build dir") .option(cl::Option("output").short_name('o').takes_value().value_name("PATH") .help("Destination path inside the build directory")) .option(cl::Option("verify").takes_value().value_name("MODE") .help("Already-staged check: size (default) | content")) .action(wrap_rc(cmd_stage))) .subcommand(cl::App("coff-def") .description("(internal: invoked by ninja) Write a .def of every exportable symbol in the given COFF objects") .option(cl::Option("output").takes_value().value_name("PATH").help("the .def to write")) .option(cl::Option("name").takes_value().value_name("DLL").help("LIBRARY name recorded in the .def")) .action(wrap_rc(cmd_coff_def))) .subcommand(cl::App("bmi-equal") .description("(internal: invoked by ninja) Compare two BMIs ignoring the compiler's embedded timestamp") .action(wrap_rc(cmd_bmi_equal))) // The three edges of the detach-codegen schedule. Internal, and named as // such: they are only ever invoked by a generated build.ninja. .subcommand(cl::App("bmi-compile") .description("(internal) Compile a module interface and return when its BMI is published") .option(cl::Option("bmi").takes_value().value_name("PATH").help("BMI this unit publishes")) .option(cl::Option("slot").takes_value().value_name("PATH").help("where .log/.rc are kept")) .option(cl::Option("self").takes_value().value_name("PATH").help("path to mcpp, re-invoked as supervisor")) .option(cl::Option("sem").takes_value().value_name("DIR").help("concurrency token directory")) .option(cl::Option("cap").takes_value().value_name("N").help("max concurrent compilers")) .option(cl::Option("command-file").takes_value().value_name("PATH").help("file holding the compiler command line")) .option(cl::Option("dep-from").takes_value().value_name("PATH").help("scanner depfile to adopt")) .option(cl::Option("dep-to").takes_value().value_name("PATH").help("where ninja expects this edge's depfile")) .action(wrap_rc(cmd_bmi_compile))) .subcommand(cl::App("bmi-supervise") .description("(internal) Run a compiler to completion and record its status") .option(cl::Option("slot").takes_value().value_name("PATH")) .option(cl::Option("token").takes_value().value_name("PATH")) .option(cl::Option("command-file").takes_value().value_name("PATH")) .action(wrap_rc(cmd_bmi_supervise))) .subcommand(cl::App("bmi-await") .description("(internal) Join a detached compiler and replay its diagnostics") .option(cl::Option("slot").takes_value().value_name("PATH")) .option(cl::Option("object").takes_value().value_name("PATH")) .action(wrap_rc(cmd_bmi_await))) ; // The bareword `mcpp help` and `mcpp` (no args) both print the // canonical help screen. if (argc <= 1) { print_usage(); return 0; } if (std::string_view(argv[1]) == "help") { print_usage(); return 0; } // Legacy `--explain CODE` form (still tested by e2e #22). cmdline // wouldn't naturally accept this as a top-level option taking a // value (it'd require declaring it on the root App, but then it'd // also need to coexist with the `explain` subcommand, which is // the form documented going forward). Special-case here before // invoking the App. if (std::string_view(argv[1]) == "--explain") { if (argc < 3) { std::println(stderr, "error: --explain requires an error code (e.g. E0001)"); return 2; } return cmd_explain(argv[2]); } // What each machine-output command does before it prints anything. // // Declared here, beside the commands themselves, rather than inside // mcpp.wire: the effects of `self env` are a fact about `self env`. A // protocol module that knew the command list would mean adding a command // in one file and remembering to describe it in another. // // `self env` carries `init-mcpp-home` because on a fresh machine it does // create $MCPP_HOME -- measured: six entries, where `xpkg parse` and // `cache list` create none. Naming the effect rather than flagging a // boolean lets an IDE ignore this one and still refuse `exec-build-script`. auto protocol_commands = [] { using mcpp::wire::Effect; return std::vector{ {"self env", {Effect::InitMcppHome}}, {"xpkg parse", {}}, {"cache list", {}}, }; }; // `--protocol-version` is answered before anything else parses, and // before any command can decide it needs a project. A client asks this // first, in a directory that may not be one. for (int i = 1; i < argc; ++i) { if (std::string_view(argv[i]) == "--protocol-version") { std::println("{}", mcpp::wire::protocol_document(protocol_commands()).dump(2)); return 0; } } // Unknown-command pre-check. cmdline doesn't error on an unknown // top-level word — it just treats it as a positional and returns // 0 silently (since the root App has no top-level action). The // pre-existing CLI returned 127 for "unknown command", and e2e // #01 asserts that exit code, so reproduce it here. { std::string_view first = argv[1]; if (!first.starts_with('-')) { // Size is deduced, not spelled: an explicit count turns "add a // command" into "add a command AND remember to bump a number", // and the compiler only catches the direction that overflows. static constexpr std::array known = std::to_array({ "new", "build", "run", "test", "clean", "add", "remove", "update", "search", "publish", "pack", "emit", "xpkg", "toolchain", "cache", "index", "self", "explain", "version", "dyndep", "why", "resolve", "stage", "bmi-equal", "coff-def", "bmi-compile", "bmi-supervise", "bmi-await", }); bool ok = false; for (auto k : known) if (k == first) { ok = true; break; } if (!ok) { std::println(stderr, "error: unknown command '{}'", first); print_usage(); return 127; } } } // Parse and dispatch separately, rather than `app.run(argc, argv)`. // // `App::run` prints its parse errors with `std::println` -- to STDOUT -- // and returns 1. stdout is the channel a machine-readable request owns, so // a client doing `mcpp cache list --format json | jq` got // `Error: unknown option: --format` fed to its parser, with nothing to // distinguish "this mcpp is too old" from "the command failed". stderr was // empty. // // That print lives in mcpplibs.cmdline, a published dependency. Taking the // ParseResult here fixes it without a cross-package release, and keeps one // rule for the whole CLI: anything mcpp says ABOUT ITSELF goes to stderr. // // Exit 2 for a usage error, matching `pack --format bogus` -- which was // already right, and was the only one of the two that was. auto parsed = app.parse(trimmed_argc, trimmed_argp); if (!parsed) { // `--help` / `--version` come back as a non-error "failure": the // parser handled them and printed. Nothing to add, nothing to report. if (!parsed.error().is_error()) return 0; mcpp::ui::error(parsed.error().message); return 2; } app.run(*parsed); return action_rc; } } // namespace mcpp::cli