Run: gate 0 (the artifact, the trees, the inventory, the run lock, the scratch dir, dispositions, reuse state, resume, reads per round, seats, the board), the stack and migration preflights, the prompts, reuse of whole and block verdicts, salvage, a gate on the pool, checkpoints, the dump, the dry-run report and the unit-state store; slots_plan and app_ready beside it. The CLI parses run's 28 options, types and choices included. A full run takes its lifetime lock beside the board before it joins it. A signal - or the stop file `crafter-review stop` writes, looked at every 2 s - stops the run once gate 0 is through, and the stop stays in force for the gates after; before that it ends the process with its descendants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
467 lines
26 KiB
C++
467 lines
26 KiB
C++
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module Crafter.Review:Driver_impl;
|
|
import :Driver;
|
|
import :Core;
|
|
import :Text;
|
|
import :Platform;
|
|
import :Process;
|
|
import :Inventory;
|
|
import :Config;
|
|
import :Freeze;
|
|
import :PgWire;
|
|
import :Totp;
|
|
import :Plan;
|
|
import :OpenCode;
|
|
import :Run;
|
|
import std;
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace Crafter::Review {
|
|
namespace {
|
|
constexpr std::string_view Usage =
|
|
R"(usage: crafter-review [--config FILE] {run,freeze,slots,config} ...
|
|
crafter-review {seats,inventory,sql,totp,init,doc,browser,stop} ...
|
|
crafter-review --version
|
|
|
|
The pre-push review pipeline: adversarial lenses, one claude process per
|
|
inventory unit, against a frozen commit range.)";
|
|
|
|
std::int32_t PrintVersion() {
|
|
std::println("{}", DriverLine());
|
|
try {
|
|
std::println("home {}", Home().string());
|
|
for (const auto& [name, sha] : CharterHashes()) std::println("charter {} {}", name, sha);
|
|
} catch (const std::exception& e) {
|
|
std::println("home not found: {}", e.what());
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// A usage error: exit 3, the preflight's code, for every command that
|
|
// cannot start.
|
|
std::int32_t UsageError(std::string_view usage, std::string_view prog, std::string_view message) {
|
|
std::println(std::cerr, "{}\n{}: error: {}", usage, prog, message);
|
|
return 3;
|
|
}
|
|
|
|
// die(): the preflight's message and exit 3.
|
|
std::int32_t Die(std::string_view message) {
|
|
std::println(std::cerr, "preflight: {}", message);
|
|
return 3;
|
|
}
|
|
|
|
// ---- the option parser these commands use ---------------------------------
|
|
enum class Kind {
|
|
String,
|
|
Int,
|
|
Float,
|
|
};
|
|
|
|
struct OptionSpec {
|
|
std::string_view name;
|
|
// A value follows (`--x V` or `--x=V`); else a store_true flag.
|
|
bool takesValue;
|
|
std::string_view metavar;
|
|
std::string_view help;
|
|
// An integer or a float option: the value must parse as
|
|
// Dyn::ParseInt / Dyn::ParseFloat takes it.
|
|
Kind kind = Kind::String;
|
|
std::span<const std::string_view> choices = {};
|
|
bool required = false;
|
|
// Left out of --help.
|
|
bool hidden = false;
|
|
};
|
|
|
|
struct Parsed {
|
|
std::map<std::string, std::string, std::less<>> values;
|
|
std::set<std::string, std::less<>> flags;
|
|
bool help = false;
|
|
std::string error;
|
|
|
|
std::optional<std::string> Value(std::string_view name) const {
|
|
const auto found = values.find(name);
|
|
if (found == values.end()) return std::nullopt;
|
|
return found->second;
|
|
}
|
|
bool Flag(std::string_view name) const {
|
|
return flags.contains(name);
|
|
}
|
|
};
|
|
|
|
std::string UsageLine(std::string_view prog, std::span<const OptionSpec> specs) {
|
|
std::string line = std::format("usage: {} [-h]", prog);
|
|
for (const OptionSpec& spec : specs) {
|
|
if (spec.hidden) continue;
|
|
const std::string text = spec.takesValue ? std::format("{} {}", spec.name, spec.metavar) : std::string(spec.name);
|
|
line += spec.required ? std::format(" {}", text) : std::format(" [{}]", text);
|
|
}
|
|
return line;
|
|
}
|
|
|
|
std::string Help(std::string_view prog, std::string_view description, std::span<const OptionSpec> specs) {
|
|
std::string text = std::format("{}\n\n{}\n\noptions:\n -h, --help show this help message and exit\n", UsageLine(prog, specs), description);
|
|
for (const OptionSpec& spec : specs) {
|
|
if (spec.hidden) continue;
|
|
const std::string left = spec.takesValue ? std::format("{} {}", spec.name, spec.metavar) : std::string(spec.name);
|
|
text += left.size() < 20 ? std::format(" {:<20} {}\n", left, spec.help) : std::format(" {}\n {}\n", left, spec.help);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// `--opt value`, `--opt=value`, any unique prefix of a long option, the
|
|
// last of a repeated option, every unrecognized argument named at the
|
|
// end.
|
|
Parsed ParseOptions(std::span<const std::string_view> args, std::span<const OptionSpec> specs) {
|
|
Parsed parsed;
|
|
std::vector<std::string> unrecognized;
|
|
for (std::size_t i = 0; i < args.size(); ++i) {
|
|
const std::string_view arg = args[i];
|
|
if (arg == "-h") {
|
|
parsed.help = true;
|
|
continue;
|
|
}
|
|
if (!arg.starts_with("--") || arg == "--") {
|
|
unrecognized.emplace_back(arg);
|
|
continue;
|
|
}
|
|
const std::size_t equals = arg.find('=');
|
|
const std::string_view name = arg.substr(0, equals);
|
|
if (name.size() > 2 && std::string_view("--help").starts_with(name) && std::ranges::none_of(specs, [&](const OptionSpec& s) { return s.name.starts_with(name); })) {
|
|
parsed.help = true;
|
|
continue;
|
|
}
|
|
std::vector<const OptionSpec*> matches;
|
|
for (const OptionSpec& spec : specs) {
|
|
if (spec.name == name) {
|
|
matches = {&spec};
|
|
break;
|
|
}
|
|
if (spec.name.starts_with(name)) matches.push_back(&spec);
|
|
}
|
|
if (matches.empty()) {
|
|
unrecognized.emplace_back(arg);
|
|
continue;
|
|
}
|
|
if (matches.size() > 1) {
|
|
std::vector<std::string> names;
|
|
for (const OptionSpec* spec : matches) names.emplace_back(spec->name);
|
|
parsed.error = std::format("ambiguous option: {} could match {}", name, Dyn::Join(names, ", "));
|
|
return parsed;
|
|
}
|
|
const OptionSpec& spec = *matches.front();
|
|
const std::string key(spec.name.substr(2));
|
|
if (!spec.takesValue) {
|
|
if (equals != std::string_view::npos) {
|
|
parsed.error = std::format("argument {}: ignored explicit argument {}", spec.name, Dyn::Repr(arg.substr(equals + 1)));
|
|
return parsed;
|
|
}
|
|
parsed.flags.insert(key);
|
|
continue;
|
|
}
|
|
std::string value;
|
|
if (equals != std::string_view::npos) {
|
|
value = std::string(arg.substr(equals + 1));
|
|
} else if (i + 1 < args.size() && !(args[i + 1].starts_with('-') && args[i + 1].size() > 1)) {
|
|
value = std::string(args[++i]);
|
|
} else {
|
|
parsed.error = std::format("argument {}: expected one argument", spec.name);
|
|
return parsed;
|
|
}
|
|
if (spec.kind == Kind::Int && !Dyn::ParseInt(value)) {
|
|
parsed.error = std::format("argument {}: invalid int value: {}", spec.name, Dyn::Repr(value));
|
|
return parsed;
|
|
}
|
|
if (spec.kind == Kind::Float && !Dyn::ParseFloat(value)) {
|
|
parsed.error = std::format("argument {}: invalid float value: {}", spec.name, Dyn::Repr(value));
|
|
return parsed;
|
|
}
|
|
if (!spec.choices.empty() && !std::ranges::contains(spec.choices, value)) {
|
|
std::vector<std::string> quoted;
|
|
for (const std::string_view c : spec.choices) quoted.push_back(Dyn::Repr(c));
|
|
parsed.error = std::format("argument {}: invalid choice: {} (choose from {})", spec.name, Dyn::Repr(value), Dyn::Join(quoted, ", "));
|
|
return parsed;
|
|
}
|
|
parsed.values[key] = std::move(value);
|
|
}
|
|
if (!unrecognized.empty()) {
|
|
parsed.error = std::format("unrecognized arguments: {}", Dyn::Join(unrecognized, " "));
|
|
return parsed;
|
|
}
|
|
std::vector<std::string> missing;
|
|
for (const OptionSpec& spec : specs) {
|
|
if (spec.required && !parsed.values.contains(spec.name.substr(2))) missing.emplace_back(spec.name);
|
|
}
|
|
if (!missing.empty()) parsed.error = std::format("the following arguments are required: {}", Dyn::Join(missing, ", "));
|
|
return parsed;
|
|
}
|
|
|
|
// The consumer config, loaded before every command but `config`:
|
|
// warnings on stderr (`run` logs them with its other lines), a problem
|
|
// stops it.
|
|
std::optional<Json> LoadConfig(const std::optional<std::string>& configPath, bool warn = true) {
|
|
Config::Loaded loaded = Config::Load(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
|
|
if (warn) {
|
|
for (const Json& warning : Dyn::Or(Dyn::Get(loaded.config, "_warnings"), Dyn::EmptyList())) std::println(std::cerr, "config: warning: {}", warning.get<std::string>());
|
|
}
|
|
if (!loaded.problems.empty()) {
|
|
Die(std::format("config: {}", Dyn::Join(loaded.problems, "; ")));
|
|
return std::nullopt;
|
|
}
|
|
return std::move(loaded.config);
|
|
}
|
|
|
|
constexpr std::array<OptionSpec, 9> FreezeOptions = {{
|
|
{"--repo", true, "REPO", "the repository (toplevel of a checkout; default: the working directory)"},
|
|
{"--slug", true, "SLUG", "the artifact dir name: .review-gates/push/<slug>/"},
|
|
{"--out", true, "OUT", "the artifact dir (overrides --slug)"},
|
|
{"--base", true, "BASE", "the range base; default @{upstream}, else merge-base with the configured base branch"},
|
|
{"--replace", false, "", "re-freeze into an out dir that already holds patches (gate-state/ is kept) and supersede this repo's worktrees"},
|
|
{"--worktree", false, "", "check out a detached worktree of HEAD under .review-gates/trees/ for the lenses (printed as --trees)"},
|
|
{"--require-signed", false, "", "an unsigned commit fails instead of warning"},
|
|
{"--date", true, "DATE", "the run date (default today)"},
|
|
{"--verify", false, "", "push-time check: HEAD, a clean tree and a fresh export still match freeze.json"},
|
|
}};
|
|
|
|
constexpr std::array<std::string_view, 1> Modes = {"commits"};
|
|
constexpr std::array<std::string_view, 2> Harnesses = {"claude", "opencode"};
|
|
|
|
const std::vector<OptionSpec>& RunOptions() {
|
|
static const std::string effort = std::format("reasoning effort of every lens process (default {})", Plan::Effort);
|
|
static const std::string reads =
|
|
std::format("clean reads a code unit of the static code lenses needs before its verdict is reused, one per round (default {})", Plan::Reads);
|
|
static const std::string models = [] {
|
|
std::vector<std::string> keys;
|
|
for (const Plan::ModelTier& m : Plan::Models()) keys.push_back(m.tier);
|
|
return std::format("override the lens model table, e.g. fable=claude-fable-5-1 (keys: {})", Dyn::Join(keys, ","));
|
|
}();
|
|
static const std::string variant = std::format("opencode harness: the model's reasoning variant (default {})", OpenCode::DefaultVariant);
|
|
static const std::vector<OptionSpec> specs = {
|
|
{"--mode", true, "MODE", "", Kind::String, Modes, false, true},
|
|
{"--artifact", true, "ARTIFACT", "the frozen artifact dir (.review-gates/push/<slug>/)", Kind::String, {}, true},
|
|
{"--hash", true, "HASH", ""},
|
|
{"--dispositions", true, "DISPOSITIONS", ""},
|
|
{"--base", true, "BASE", ""},
|
|
{"--date", true, "DATE", ""},
|
|
{"--trees", true, "TREES", "the checkout the lenses read, at the range HEAD: the repository itself (`.`, default) or the frozen worktree"},
|
|
{"--context-paths", true, "CONTEXT_PATHS", "comma-separated paths of material the artifact discusses (captures, evidence) - mechanical pointers, no narrative"},
|
|
{"--only", true, "ONLY", "comma-separated lens files (test only)"},
|
|
{"--without", true, "WITHOUT", "comma-separated lens files to leave out of this run by owner ruling (logged; the run stays a full run)"},
|
|
{"--units", true, "UNITS", "comma-separated unit ids (test only)"},
|
|
{"--warm", false, "", "test runs only: let one pass per prompt prefix go first, as a full run does"},
|
|
{"--concurrency", true, "CONCURRENCY",
|
|
"lens processes in flight at once across all seats (default 128, 2026-10-15; the pool still places each one by seat headroom, launches a pass only when it fits in free "
|
|
"memory at its environment's measured cost, and gives a pass with the browser a stack of its own)",
|
|
Kind::Int},
|
|
{"--effort", true, "EFFORT", effort},
|
|
{"--reads", true, "READS", reads, Kind::Int},
|
|
{"--no-reuse", false, "", ""},
|
|
{"--no-salvage", false, "", "do not reuse finished process outputs of an earlier run on this hash"},
|
|
{"--dry-run", false, "", ""},
|
|
{"--claude-bin", true, "CLAUDE_BIN", ""},
|
|
{"--models", true, "MODELS", models},
|
|
{"--scratch", true, "SCRATCH", ""},
|
|
{"--seats", true, "SEATS", "the seats the lenses run on: auto (every Anthropic seat in the env file, else local), local, api, oauth, openrouter (never in auto), or a list like api1,1,2,local"},
|
|
{"--env-file", true, "ENV_FILE", "dotenv file with CLAUDE_CODE_OAUTH_TOKEN1..6 / ANTHROPIC_API_KEY1..6 (default $SEATS_ENV_FILE, else the repository .env)"},
|
|
{"--no-probe", false, "", "skip the one-request probe of every seat at the start"},
|
|
{"--api-budget", true, "API_BUDGET", "USD an API-credit seat may spend per cycle (default REVIEW_GATES_API_BUDGET_USD or 200)", Kind::Float},
|
|
{"--harness", true, "{claude,opencode}",
|
|
"the agent harness of every lens process: claude (default) or opencode (OpenRouter seats only; no lens whose environment needs an MCP server)", Kind::String, Harnesses},
|
|
{"--variant", true, "VARIANT", variant},
|
|
{"--opencode-bin", true, "OPENCODE_BIN", ""},
|
|
};
|
|
return specs;
|
|
}
|
|
|
|
std::int32_t RunCommand(std::span<const std::string_view> args, const std::optional<std::string>& configPath) {
|
|
constexpr std::string_view Prog = "crafter-review run";
|
|
const std::vector<OptionSpec>& specs = RunOptions();
|
|
const Parsed parsed = ParseOptions(args, specs);
|
|
if (parsed.help) {
|
|
std::print("{}", Help(Prog, "one claude process per inventory unit per lens, the gates, the dump (process/prepush.md)", specs));
|
|
return 0;
|
|
}
|
|
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, specs), Prog, parsed.error);
|
|
const std::optional<Json> config = LoadConfig(configPath, false);
|
|
if (!config) return 3;
|
|
Run::Arguments a;
|
|
a.config = configPath;
|
|
a.mode = parsed.Value("mode").value_or("commits");
|
|
a.artifact = parsed.Value("artifact").value_or("");
|
|
a.hash = parsed.Value("hash");
|
|
a.dispositions = parsed.Value("dispositions");
|
|
a.base = parsed.Value("base").value_or("");
|
|
a.date = parsed.Value("date").value_or(Platform::TodayIso());
|
|
a.trees = parsed.Value("trees");
|
|
a.contextPaths = parsed.Value("context-paths").value_or("");
|
|
a.only = parsed.Value("only");
|
|
a.without = parsed.Value("without");
|
|
if (const std::optional<std::string> units = parsed.Value("units"); units && !units->empty()) {
|
|
// A trailing comma names no unit.
|
|
a.units = std::set<std::string>();
|
|
for (const std::string& part : Dyn::Split(*units, ",")) {
|
|
if (std::string id = Dyn::Strip(part); !id.empty()) a.units->insert(std::move(id));
|
|
}
|
|
}
|
|
a.warm = parsed.Flag("warm");
|
|
if (const std::optional<std::string> n = parsed.Value("concurrency")) a.concurrency = *Dyn::ParseInt(*n);
|
|
a.effort = parsed.Value("effort").value_or("");
|
|
if (a.effort.empty()) a.effort = std::string(Plan::DefaultEffort());
|
|
a.noReuse = parsed.Flag("no-reuse");
|
|
a.noSalvage = parsed.Flag("no-salvage");
|
|
a.dryRun = parsed.Flag("dry-run");
|
|
a.claudeBin = parsed.Value("claude-bin");
|
|
a.models = parsed.Value("models").value_or("");
|
|
a.scratch = parsed.Value("scratch");
|
|
a.seats = parsed.Value("seats");
|
|
if (!a.seats) a.seats = Platform::GetEnv("REVIEW_GATES_SEATS");
|
|
a.envFile = parsed.Value("env-file");
|
|
a.noProbe = parsed.Flag("no-probe");
|
|
if (const std::optional<std::string> budget = parsed.Value("api-budget")) a.apiBudget = *Dyn::ParseFloat(*budget);
|
|
a.harness = parsed.Value("harness").value_or("claude");
|
|
a.variant = parsed.Value("variant");
|
|
if (!a.variant || a.variant->empty()) a.variant = a.harness == "opencode" ? std::optional<std::string>(std::string(OpenCode::DefaultVariant)) : std::nullopt;
|
|
a.opencodeBin = parsed.Value("opencode-bin");
|
|
if (const std::optional<std::string> reads = parsed.Value("reads")) {
|
|
const std::int64_t n = *Dyn::ParseInt(*reads);
|
|
if (n < 1) return Die("--reads: a number from 1 up");
|
|
a.reads = n;
|
|
}
|
|
for (const std::string& part : Dyn::Split(a.models, ",")) {
|
|
const std::string kv = Dyn::Strip(part);
|
|
if (kv.empty()) continue;
|
|
const std::size_t equals = kv.find('=');
|
|
if (equals == std::string::npos) throw Dyn::Error("ValueError", "not enough values to unpack (expected 2, got 1)");
|
|
const std::string key = kv.substr(0, equals);
|
|
std::vector<Plan::ModelTier>& tiers = Plan::Models();
|
|
const auto tier = std::ranges::find_if(tiers, [&](const Plan::ModelTier& m) { return m.tier == key; });
|
|
if (tier == tiers.end()) {
|
|
std::vector<std::string> known;
|
|
for (const Plan::ModelTier& m : tiers) known.push_back(m.tier);
|
|
std::println(std::cerr, "--models: unknown key {} (known: {})", Dyn::Repr(key), Dyn::Join(known, ", "));
|
|
return 1;
|
|
}
|
|
tier->id = kv.substr(equals + 1);
|
|
}
|
|
return Run::Main(std::move(a), *config);
|
|
}
|
|
|
|
constexpr std::array<OptionSpec, 1> SlotsOptions = {{
|
|
{"--artifact", true, "ARTIFACT", "a frozen artifact dir: no more slots than its browser passes can use at once"},
|
|
}};
|
|
|
|
std::int32_t SlotsCommand(std::span<const std::string_view> args, const std::optional<std::string>& configPath) {
|
|
constexpr std::string_view Prog = "crafter-review slots";
|
|
const Parsed parsed = ParseOptions(args, SlotsOptions);
|
|
if (parsed.help) {
|
|
std::print("{}", Help(Prog, "how many stacks (slots.*) fit in free memory, and the commands that bring up the missing ones", SlotsOptions));
|
|
return 0;
|
|
}
|
|
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, SlotsOptions), Prog, parsed.error);
|
|
const std::optional<Json> config = LoadConfig(configPath);
|
|
if (!config) return 3;
|
|
const std::optional<std::string> artifact = parsed.Value("artifact");
|
|
try {
|
|
Run::SlotsPlan(*config, fs::current_path(), artifact ? std::optional<fs::path>(*artifact) : std::nullopt);
|
|
} catch (const Inventory::InvariantError& e) {
|
|
return Die(e.what());
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
std::int32_t FreezeCommand(std::span<const std::string_view> args, const std::optional<std::string>& configPath) {
|
|
constexpr std::string_view Prog = "crafter-review freeze";
|
|
const Parsed parsed = ParseOptions(args, FreezeOptions);
|
|
if (parsed.help) {
|
|
std::print("{}", Help(Prog, "cut the frozen commit-range artifact for the push gates (process/prepush.md Gate 0)", FreezeOptions));
|
|
return 0;
|
|
}
|
|
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, FreezeOptions), Prog, parsed.error);
|
|
const std::optional<Json> config = LoadConfig(configPath);
|
|
if (!config) return 3;
|
|
Freeze::Arguments arguments;
|
|
arguments.repo = parsed.Value("repo");
|
|
arguments.slug = parsed.Value("slug");
|
|
arguments.out = parsed.Value("out");
|
|
arguments.base = parsed.Value("base");
|
|
arguments.replace = parsed.Flag("replace");
|
|
arguments.worktree = parsed.Flag("worktree");
|
|
arguments.requireSigned = parsed.Flag("require-signed");
|
|
arguments.date = parsed.Value("date").value_or(Platform::TodayIso());
|
|
arguments.verify = parsed.Flag("verify");
|
|
// `%G?` starts gpg, which can leave a daemon behind: as a subreaper
|
|
// this process inherits it, and takes it down on the way out.
|
|
Process::BecomeSubreaper();
|
|
const std::int32_t code = Freeze::Main(arguments, *config);
|
|
Process::KillDescendants();
|
|
return code;
|
|
}
|
|
}
|
|
|
|
std::int32_t Main(std::span<const std::string_view> args) {
|
|
const std::span<const std::string_view> rest = args.empty() ? args : args.subspan(1);
|
|
// The supervisor first and alone: it is single-threaded and has to
|
|
// stay so, and it runs between the driver and claude, not as a
|
|
// command anyone types.
|
|
if (!rest.empty() && rest.front() == Process::SuperviseVerb) return Process::SuperviseMain(rest.subspan(1));
|
|
Platform::Init();
|
|
// Before any other thread: SIGINT, SIGTERM and SIGHUP go to a thread of
|
|
// their own, which stops a run once it is under way and otherwise ends
|
|
// the process.
|
|
Process::InstallSignalThread(Run::OnSignal);
|
|
// The options before the command: --config FILE or --config=FILE, and
|
|
// any unique prefix of a long option.
|
|
std::optional<std::string> configPath;
|
|
std::size_t at = 0;
|
|
while (at < rest.size() && rest[at].starts_with("--") && rest[at] != "--") {
|
|
const std::string_view arg = rest[at];
|
|
const std::size_t equals = arg.find('=');
|
|
const std::string_view name = arg.substr(0, equals);
|
|
if (name.size() > 2 && std::string_view("--config").starts_with(name)) {
|
|
if (equals != std::string_view::npos) {
|
|
configPath = std::string(arg.substr(equals + 1));
|
|
} else if (at + 1 < rest.size()) {
|
|
configPath = std::string(rest[++at]);
|
|
} else {
|
|
return UsageError(Usage, "crafter-review", "argument --config: expected one argument");
|
|
}
|
|
} else if (name.size() > 2 && std::string_view("--version").starts_with(name)) {
|
|
return PrintVersion();
|
|
} else if (name.size() > 2 && std::string_view("--help").starts_with(name)) {
|
|
std::println("{}", Usage);
|
|
return 0;
|
|
} else {
|
|
return UsageError(Usage, "crafter-review", std::format("unrecognized arguments: {}", arg));
|
|
}
|
|
++at;
|
|
}
|
|
if (at < rest.size() && rest[at] == "-h") {
|
|
std::println("{}", Usage);
|
|
return 0;
|
|
}
|
|
if (at >= rest.size()) return UsageError(Usage, "crafter-review", "a command is required");
|
|
const std::string_view command = rest[at];
|
|
const std::span<const std::string_view> after = rest.subspan(at + 1);
|
|
try {
|
|
// The helpers a lens runs: they read neither the home nor the
|
|
// config, only their arguments, stdin and REVIEW_GATES_DB.
|
|
if (command == "sql") return PgWire::SqlMain(after);
|
|
if (command == "totp") return Totp::Main(after);
|
|
if (command == "inventory" && !configPath) return Inventory::Main(after);
|
|
if (command == "config") {
|
|
if (!after.empty()) return UsageError(Usage, "crafter-review", std::format("unrecognized arguments: {}", after.front()));
|
|
return Config::Main(fs::current_path(), configPath ? fs::path(*configPath) : fs::path());
|
|
}
|
|
if (command == "freeze") return FreezeCommand(after, configPath);
|
|
if (command == "run") return RunCommand(after, configPath);
|
|
if (command == "slots") return SlotsCommand(after, configPath);
|
|
} catch (const Dyn::Error& error) {
|
|
// An error no command catches: its message, exit 1.
|
|
std::println(std::cerr, "crafter-review: {}: {}", error.Kind(), error.what());
|
|
return 1;
|
|
}
|
|
return UsageError(Usage, "crafter-review", std::format("argument cmd: invalid choice: '{}'", command));
|
|
}
|
|
}
|