ApiConfig and PoolConfig read the seat order, the budgets and the concurrency from the process environment alone, so a `.env` that said REVIEW_GATES_SEAT_ORDER=oauth-first ordered nothing: the Python driver reads those lines from the file under the process environment (config_env, 2026-10-17: a setting lives beside the seats it orders). SeatPool::SettingsOf does the same - the file's placement settings under the process environment, in a map handed to the placement readers alone (ApiConfig, PoolConfig, Seats::ConfigFromEnv) - and leaves the process environment untouched, so no other reader sees a line of the file: a path's or a binary's line reaches nothing, and two drivers reading one .env keep one board and one ledger. The placement settings are one list, SeatPool::PlacementSettings - the fifteen names those readers read - for the map, the run's log and what makes a working directory's .env a seats file; a REVIEW_GATES_* line of any other name stays out of the map and is named, by the run's log and on `seats`'s stderr. One dotenv grammar serves the tokens and the settings (Seats::DotenvLines). `run` and `seats` resolve the file from their own --env-file (SeatPool::ResolveSeatsFile): the --env-file, else $SEATS_ENV_FILE, else the working directory's .env - the operator's, as the Python driver and `seats` read it, not the repository under review's - when it is a file holding a seat or a placement setting, each with a value, or one that cannot be read as UTF-8 text (its error is then the command's) - a readable application .env, its Vite or Supabase lines, one of blank placeholder lines or one that is no file is passed over, and the run says so - else the machine's own $XDG_CONFIG_HOME/crafter-review/env, else ~/.config/crafter-review/env: one file for every repository on a machine, a repository's seats .env winning over it. Each file is read once and the settings and the tokens are taken from that read (SeatsFile::Text), so a file that can be read only once - a pipe, `--env-file <(gpg -d seats.env.gpg)`, a FIFO - gives its settings and its seats alike, and a failed read raises its own error where the tokens are laid over the environment: SeatPool::WithFileTokens, for `run` and `seats` alike, and LoadSeats takes the merged environment and no longer reads a file by path. An empty `seats --env-file` means no file now, as it did for `run`, and the `seats` report names the side that runs first (it said "api seats run first" whatever the order). Runs, Commands and KernelModes, the tests that run `run` and `seats`, point XDG_CONFIG_HOME, and HOST_XDG_CONFIG_HOME for the Flatpak, at a path of their own that nothing creates, so a global file stays out of them; the grammar, the settings and their precedence, the lookup and the one read have their tests, a run with `--env` (a prefix) and with a repeated --env-file, and a `seats` with `--env`, take tokens and settings from the one file, an application's .env and a venv are passed over, a FIFO is read once, no line of the file reaches `stop`, every listed placement setting moves a reader, another REVIEW_GATES_* line is named and a .env of blank lines passed over, and the README, the environment table and the procedure say the new rule.
1003 lines
60 KiB
C++
1003 lines
60 KiB
C++
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module;
|
|
#include <stdio.h>
|
|
module Crafter.Review:Driver_impl;
|
|
import :Driver;
|
|
import :Core;
|
|
import :Json;
|
|
import :Text;
|
|
import :Platform;
|
|
import :Process;
|
|
import :Inventory;
|
|
import :Config;
|
|
import :Freeze;
|
|
import :PgWire;
|
|
import :Totp;
|
|
import :Plan;
|
|
import :Mcp;
|
|
import :Seats;
|
|
import :SeatPool;
|
|
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;
|
|
std::vector<std::string> positionals;
|
|
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);
|
|
}
|
|
};
|
|
|
|
// A positional as the usage line names it: its choices in braces, else
|
|
// its name.
|
|
std::string PositionalName(const OptionSpec& positional) {
|
|
if (positional.choices.empty()) return std::string(positional.name);
|
|
const std::vector<std::string> names(positional.choices.begin(), positional.choices.end());
|
|
return std::format("{{{}}}", Dyn::Join(names, ","));
|
|
}
|
|
|
|
std::string UsageLine(std::string_view prog, std::span<const OptionSpec> specs, std::span<const OptionSpec> positionals = {}) {
|
|
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);
|
|
}
|
|
for (const OptionSpec& positional : positionals) {
|
|
line += positional.required ? std::format(" {}", PositionalName(positional)) : std::format(" [{}]", PositionalName(positional));
|
|
}
|
|
return line;
|
|
}
|
|
|
|
std::string HelpRow(std::string_view left, std::string_view help) {
|
|
return left.size() < 20 ? std::format(" {:<20} {}\n", left, help) : std::format(" {}\n {}\n", left, help);
|
|
}
|
|
|
|
std::string Help(std::string_view prog, std::string_view description, std::span<const OptionSpec> specs, std::span<const OptionSpec> positionals = {}) {
|
|
std::string text = std::format("{}\n\n{}\n\n", UsageLine(prog, specs, positionals), description);
|
|
if (!positionals.empty()) {
|
|
text += "positional arguments:\n";
|
|
for (const OptionSpec& positional : positionals) text += HelpRow(PositionalName(positional), positional.help);
|
|
text += "\n";
|
|
}
|
|
text += "options:\n -h, --help show this help message and exit\n";
|
|
for (const OptionSpec& spec : specs) {
|
|
if (spec.hidden) continue;
|
|
text += HelpRow(spec.takesValue ? std::format("{} {}", spec.name, spec.metavar) : std::string(spec.name), 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; positionals in their order, each one word, optional unless
|
|
// required.
|
|
Parsed ParseOptions(std::span<const std::string_view> args, std::span<const OptionSpec> specs, std::span<const OptionSpec> positionals = {}) {
|
|
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 == "--") {
|
|
if (arg != "--" && !(arg.starts_with('-') && arg.size() > 1) && parsed.positionals.size() < positionals.size()) {
|
|
const OptionSpec& positional = positionals[parsed.positionals.size()];
|
|
if (!positional.choices.empty() && !std::ranges::contains(positional.choices, arg)) {
|
|
std::vector<std::string> quoted;
|
|
for (const std::string_view c : positional.choices) quoted.push_back(Dyn::Repr(c));
|
|
parsed.error = std::format("argument {}: invalid choice: {} (choose from {})", positional.name, Dyn::Repr(arg), Dyn::Join(quoted, ", "));
|
|
return parsed;
|
|
}
|
|
parsed.positionals.emplace_back(arg);
|
|
} else {
|
|
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);
|
|
}
|
|
for (std::size_t i = parsed.positionals.size(); i < positionals.size(); ++i) {
|
|
if (positionals[i].required) missing.emplace_back(positionals[i].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, 2> Harnesses = {"claude", "opencode"};
|
|
constexpr std::array<std::string_view, 2> PersonaBlocks = {"pruned", "all"};
|
|
|
|
const std::vector<OptionSpec>& RunOptions() {
|
|
static const std::string effort = std::format("reasoning effort of every lens process (default: the mode's, else {} for a reply or a series and {} for a commit range)", Plan::KernelEffort, 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", "a mode of the config (modes.<name>: its artifact kind, the lenses it runs, its effort; default commits)"},
|
|
{"--subsystem", true, "SUBSYSTEM", "journal subsystem: required for reply and series; optional in commits mode (names the ledger and captures the lenses may read)"},
|
|
{"--artifact", true, "ARTIFACT", "the frozen artifact: a dir of *.patch (.review-gates/push/<slug>/, a series), or the reply draft file"},
|
|
{"--hash", true, "HASH", ""},
|
|
{"--reviewers", true, "REVIEWERS", "reply and series: the reviewers a persona reads as, comma-separated (the persona gate runs after the others)"},
|
|
{"--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; reply and series default work/linux,work/milos-linux"},
|
|
{"--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",
|
|
"a cap on the lens processes in flight on this machine, every driver's counted (default none: each OAuth account takes at most 128 - "
|
|
"REVIEW_GATES_OAUTH_CONCURRENCY - and an API seat REVIEW_GATES_API_CONCURRENCY; the pool still places each pass by seat headroom, launches it 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"},
|
|
{"--persona-blocks", true, "{pruned,all}", "reply and series: pruned (default) skips the block passes of the units a persona's whole pass marked not-mine", Kind::String, PersonaBlocks},
|
|
{"--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 and the placement settings (REVIEW_GATES_SEAT_ORDER, the budgets, the concurrency): default $SEATS_ENV_FILE, else the working directory's .env when it holds a seat or a placement setting, else $XDG_CONFIG_HOME/crafter-review/env (~/.config/crafter-review/env); an empty value: none"},
|
|
{"--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");
|
|
const std::optional<Config::Mode> mode = Config::ModeOf(*config, a.mode);
|
|
if (!mode) {
|
|
return Die(std::format("--mode {}: no such mode in the config (modes: {}) - a mode is an artifact kind and the lenses it runs, modes.<name> in .review-gates/config.json", a.mode,
|
|
Dyn::Join(Config::ModeNames(*config), ", ")));
|
|
}
|
|
a.kind = mode->kind;
|
|
a.subsystem = parsed.Value("subsystem");
|
|
a.artifact = parsed.Value("artifact").value_or("");
|
|
if (a.artifact.empty()) return UsageError(UsageLine(Prog, specs), Prog, "the following arguments are required: --artifact");
|
|
a.hash = parsed.Value("hash");
|
|
a.reviewers = parsed.Value("reviewers").value_or("");
|
|
a.personaBlocks = parsed.Value("persona-blocks").value_or("pruned");
|
|
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));
|
|
}
|
|
}
|
|
// Empty items are skipped, so a typo such as `--units ,` must not
|
|
// leave no filter at all: that is a full run.
|
|
auto namesNothing = [](const std::optional<std::string>& list) {
|
|
return list && !list->empty() && std::ranges::all_of(Dyn::Split(*list, ","), [](const std::string& part) { return Dyn::Strip(part).empty(); });
|
|
};
|
|
if (namesNothing(a.only)) return UsageError(UsageLine(Prog, specs), Prog, std::format("argument --only: names no lens file ({})", Dyn::Repr(*a.only)));
|
|
if (namesNothing(parsed.Value("units"))) return UsageError(UsageLine(Prog, specs), Prog, std::format("argument --units: names no unit id ({})", Dyn::Repr(*parsed.Value("units"))));
|
|
a.warm = parsed.Flag("warm");
|
|
if (const std::optional<std::string> n = parsed.Value("concurrency")) {
|
|
a.concurrency = *Dyn::ParseInt(*n);
|
|
if (*a.concurrency < 1) return UsageError(UsageLine(Prog, specs), Prog, "argument --concurrency: a number from 1 up (leave it out for no cap)");
|
|
}
|
|
a.effort = parsed.Value("effort").value_or("");
|
|
if (a.effort.empty()) a.effort = mode->effort.value_or(std::string(Plan::DefaultEffort(a.kind)));
|
|
if (a.kind == "commits") {
|
|
if (!a.reviewers.empty()) return Die("commits mode has no persona gate (the owner is the last gate): drop --reviewers");
|
|
} else {
|
|
if (!a.subsystem || a.subsystem->empty()) return Die(std::format("{} mode: --subsystem is required", a.mode));
|
|
if (!a.trees || a.trees->empty()) a.trees = "work/linux,work/milos-linux";
|
|
}
|
|
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");
|
|
a.reads = Plan::ReadsFor(a.kind);
|
|
if (const std::optional<std::string> reads = parsed.Value("reads")) {
|
|
const std::int64_t n = *Dyn::ParseInt(*reads);
|
|
if (n < 1 || (a.kind == "reply" && n != 1)) return Die("--reads: a number from 1 up; a reply has no code units and is read once");
|
|
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();
|
|
struct Sweep {
|
|
~Sweep() {
|
|
Process::KillDescendants();
|
|
}
|
|
} const sweep;
|
|
return Freeze::Main(arguments, *config);
|
|
}
|
|
|
|
// ---- seats: the report, the ledger and the board beside it ------------------
|
|
constexpr std::array<OptionSpec, 5> SeatsOptions = {{
|
|
{"--env-file", true, "ENV_FILE", "dotenv file holding the seat tokens and the placement settings: default $SEATS_ENV_FILE, else the working directory's .env when it holds a seat or a placement setting, else $XDG_CONFIG_HOME/crafter-review/env (~/.config/crafter-review/env); an empty value: none"},
|
|
{"--json", false, "", "the probe results as JSON instead of the report"},
|
|
{"--no-fable", false, "", "probe on Haiku instead of Fable (cheaper; the Fable weekly window stays unread)"},
|
|
{"--local", false, "", "also probe the CLI's own login, shown as the seat `local`"},
|
|
{"--timeout", true, "TIMEOUT", "seconds per probe (default 30)", Kind::Float},
|
|
}};
|
|
|
|
// datetime.fromtimestamp(t, timezone.utc).isoformat(timespec='milliseconds')
|
|
// with Z for +00:00.
|
|
std::string IsoUtcMillis(double epoch) {
|
|
double whole = std::floor(epoch);
|
|
auto micros = static_cast<std::int64_t>(std::nearbyint((epoch - whole) * 1e6));
|
|
if (micros >= 1'000'000) {
|
|
whole += 1;
|
|
micros -= 1'000'000;
|
|
}
|
|
const std::chrono::sys_seconds at {std::chrono::seconds(static_cast<std::int64_t>(whole))};
|
|
return std::format("{:%Y-%m-%dT%H:%M:%S}.{:03d}Z", at, micros / 1000);
|
|
}
|
|
|
|
struct SeatJob {
|
|
std::int32_t seat = 0;
|
|
std::string token;
|
|
bool local = false;
|
|
};
|
|
|
|
// Every seat probed, eight at once at most; a probe that throws ends
|
|
// the command.
|
|
std::vector<Json> ProbeAll(const std::vector<SeatJob>& todo, const Seats::ProbeOptions& base) {
|
|
std::vector<Json> out(todo.size());
|
|
std::atomic<std::size_t> next = 0;
|
|
std::mutex lock;
|
|
std::exception_ptr failed;
|
|
std::vector<std::thread> workers;
|
|
for (std::size_t w = 0; w < std::min<std::size_t>(8, todo.size()); ++w) {
|
|
workers.emplace_back([&] {
|
|
for (std::size_t i = next++; i < todo.size(); i = next++) {
|
|
try {
|
|
Seats::ProbeOptions options = base;
|
|
options.local = todo[i].local;
|
|
out[i] = Seats::ProbeSeat(todo[i].seat, todo[i].token, options);
|
|
} catch (const std::exception&) {
|
|
const std::lock_guard guard(lock);
|
|
if (!failed) failed = std::current_exception();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
for (std::thread& worker : workers) worker.join();
|
|
if (failed) std::rethrow_exception(failed);
|
|
return out;
|
|
}
|
|
|
|
std::int32_t SeatsCommand(std::span<const std::string_view> args) {
|
|
constexpr std::string_view Prog = "crafter-review seats";
|
|
const Parsed parsed = ParseOptions(args, SeatsOptions);
|
|
if (parsed.help) {
|
|
std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 from the environment and/or the dotenv file. Never prints a token.\n",
|
|
Help(Prog, "How much usage each Claude seat has left: every seat probed, both windows and their resets.", SeatsOptions));
|
|
return 0;
|
|
}
|
|
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, SeatsOptions), Prog, parsed.error);
|
|
const std::optional<std::string> timeoutText = parsed.Value("timeout");
|
|
const double timeout = timeoutText ? Dyn::ParseFloat(*timeoutText).value_or(Seats::ProbeTimeoutS) : Seats::ProbeTimeoutS;
|
|
// The seats file, read once: its placement settings, under the
|
|
// process environment, for the placement readers alone
|
|
// (SeatPool::SettingsOf; any other REVIEW_GATES_* line of it is
|
|
// named on stderr), its tokens over the process environment for
|
|
// the probes (SeatPool::WithFileTokens).
|
|
const SeatPool::SeatsFile seatsFile = SeatPool::ResolveSeatsFile(parsed.Value("env-file"), fs::current_path());
|
|
const std::optional<fs::path> envPath = seatsFile.path;
|
|
const std::map<std::string, std::string> process = Platform::Environment();
|
|
const SeatPool::Settings fileSettings = SeatPool::SettingsOf(seatsFile);
|
|
const std::map<std::string, std::string>& settings = fileSettings.env;
|
|
if (!fileSettings.ignored.empty() && envPath) {
|
|
std::println(std::cerr, "seats file: {} in {} - no placement setting, read from the process environment alone", Dyn::Join(fileSettings.ignored, ", "), envPath->string());
|
|
}
|
|
const Json cfg = Seats::ConfigFromEnv(settings);
|
|
std::string envFile = envPath ? envPath->string() : std::string();
|
|
// The file's one read: one that could not be read, or is not UTF-8,
|
|
// ends the command with the read's error.
|
|
std::map<std::string, std::string> env = SeatPool::WithFileTokens(process, seatsFile);
|
|
std::vector<SeatJob> jobs;
|
|
for (const auto& [seat, token] : Seats::SeatTokensFromEnv(env)) jobs.push_back({seat, token, false});
|
|
if (parsed.Flag("local")) jobs.insert(jobs.begin(), SeatJob {0, "", true});
|
|
const std::vector<std::pair<std::int32_t, std::string>> apiJobs = Seats::ApiKeysFromEnv(env);
|
|
if (jobs.empty() && apiJobs.empty()) {
|
|
std::println("no seat configured (CLAUDE_CODE_OAUTH_TOKEN1..6 / ANTHROPIC_API_KEY1..6{}; --local probes your own login)", envFile.empty() ? std::string() : std::format(" in {}", envFile));
|
|
return 1;
|
|
}
|
|
const std::string claudeBin = Seats::FindClaudeBin();
|
|
const double now = Platform::Now();
|
|
Seats::ProbeOptions haiku;
|
|
haiku.timeoutS = timeout;
|
|
haiku.claudeBin = claudeBin;
|
|
std::vector<Json> seats;
|
|
if (!jobs.empty() && parsed.Flag("no-fable")) {
|
|
seats = ProbeAll(jobs, haiku);
|
|
} else if (!jobs.empty()) {
|
|
// One Fable probe per seat carries all three windows and is the
|
|
// seat; only a seat whose Fable probe could not be read, or was
|
|
// refused for the Fable window alone, is probed again on Haiku
|
|
// for its own state.
|
|
Seats::ProbeOptions fable = haiku;
|
|
fable.model = std::string(Seats::FableProbeModel);
|
|
const std::vector<Json> first = ProbeAll(jobs, fable);
|
|
std::vector<SeatJob> again;
|
|
for (std::size_t i = 0; i < jobs.size(); ++i) {
|
|
if (Seats::NeedsHaiku(first[i])) again.push_back(jobs[i]);
|
|
}
|
|
std::map<std::int64_t, Json> standIns;
|
|
const std::vector<Json> got = again.empty() ? std::vector<Json>() : ProbeAll(again, haiku);
|
|
for (std::size_t i = 0; i < again.size(); ++i) standIns[again[i].seat] = got[i];
|
|
for (const Json& s : first) {
|
|
const auto found = standIns.find(s.at("seat").get<std::int64_t>());
|
|
seats.push_back(Seats::MergeFableProbe(found != standIns.end() ? found->second : s, s));
|
|
}
|
|
}
|
|
const Json apiConfig = SeatPool::ApiConfig(settings);
|
|
std::vector<Json> apiSeats;
|
|
if (!apiJobs.empty()) {
|
|
std::vector<SeatJob> todo;
|
|
for (const auto& [n, key] : apiJobs) todo.push_back({n, key, false});
|
|
Seats::ProbeOptions api = haiku;
|
|
api.api = true;
|
|
apiSeats = ProbeAll(todo, api);
|
|
// The driver's ledger: what it spent on each key this cycle.
|
|
SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig);
|
|
for (Json& s : apiSeats) s["ledger"] = ledger.Entry(std::format("api{}", s.at("seat").get<std::int64_t>()));
|
|
}
|
|
// The board: the drivers running now and what they hold on each
|
|
// account - read, never written.
|
|
const Json board = SeatPool::Board(SeatPool::BoardPath()).View(now);
|
|
std::map<std::int32_t, std::string> keys;
|
|
for (const SeatJob& j : jobs) keys[j.seat] = SeatPool::SeatKey(j.local ? "local" : "oauth", j.token);
|
|
std::map<std::int32_t, std::string> apiKeys;
|
|
for (const auto& [n, key] : apiJobs) apiKeys[n] = SeatPool::SeatKey("api", key);
|
|
const Seats::Zone zone = Seats::LocalZone();
|
|
if (parsed.Flag("json")) {
|
|
Json eligible = Json::array();
|
|
for (const Json& s : seats) {
|
|
if (Seats::IsEligible(s, cfg)) eligible.push_back(s.at("seat"));
|
|
}
|
|
const Json out = {{"probedAt", IsoUtcMillis(now)}, {"config", cfg}, {"seats", seats}, {"apiSeats", apiSeats}, {"eligible", eligible},
|
|
{"next", seats.empty() ? Json() : Seats::PickNext(seats, cfg)}, {"board", board}};
|
|
std::println("{}", out.dump(2, ' ', false, Json::error_handler_t::replace));
|
|
return 0;
|
|
}
|
|
const std::string source = std::format("{}{}", envFile.empty() ? std::string("the environment") : envFile, parsed.Flag("local") ? " and your own login" : "");
|
|
std::vector<std::string> ids;
|
|
for (const Json& s : seats) ids.push_back(s.at("seat") == 0 ? std::string("local") : std::to_string(s.at("seat").get<std::int64_t>()));
|
|
for (const Json& s : apiSeats) ids.push_back(std::format("api{}", s.at("seat").get<std::int64_t>()));
|
|
std::println("seat pool: {} seat(s) ({}) from {}, probed {} {}, claude: {}\n", ids.size(), Dyn::Join(ids, ", "), source, Seats::Clock(now, zone), zone.label, claudeBin);
|
|
if (!seats.empty()) std::println("{}", Seats::RenderStatusReport(seats, cfg, now, zone, board, keys));
|
|
if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, board, apiKeys));
|
|
std::println("\n{}", Seats::RenderDrivers(board, now, zone));
|
|
// The pids above are no use from inside the sandbox: the way to
|
|
// stop a run instead.
|
|
if (Platform::InSandbox() && !Dyn::Get(board, "drivers").empty()) {
|
|
std::println("stop a run with `{} stop --hash <hash8>` or `{} stop <its scratch dir>`", Invocation(), Invocation());
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// ---- stop: the file a running driver looks for every 2 s ------------------------
|
|
constexpr std::array<OptionSpec, 1> StopOptions = {{
|
|
{"--hash", true, "HASH8", "every live run of this artifact hash on the board (its first 8 characters)"},
|
|
}};
|
|
constexpr std::array<OptionSpec, 1> StopPositionals = {{
|
|
{"scratch", false, "", "the run's scratch dir (the last word of its gate-0 log)"},
|
|
}};
|
|
|
|
std::int32_t StopCommand(std::span<const std::string_view> args) {
|
|
constexpr std::string_view Prog = "crafter-review stop";
|
|
const Parsed parsed = ParseOptions(args, StopOptions, StopPositionals);
|
|
if (parsed.help) {
|
|
std::print("{}", Help(Prog, "stop a running review as SIGTERM would - its running passes killed, no later gate started, the dump written - from any sandbox", StopOptions,
|
|
StopPositionals));
|
|
return 0;
|
|
}
|
|
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, StopOptions, StopPositionals), Prog, parsed.error);
|
|
const std::optional<std::string> hash = parsed.Value("hash");
|
|
if (hash.has_value() == !parsed.positionals.empty()) return UsageError(UsageLine(Prog, StopOptions, StopPositionals), Prog, "a scratch dir or --hash, one of the two");
|
|
const fs::path boardPath = SeatPool::BoardPath();
|
|
std::vector<std::pair<std::string, fs::path>> targets;
|
|
if (!hash) {
|
|
const fs::path dir = fs::absolute(parsed.positionals.front());
|
|
const std::string key = Dyn::Strip(Platform::ReadFile(dir / ".driver").value_or(""));
|
|
if (key.empty()) {
|
|
std::println(std::cerr, "stop: {} is no run's scratch dir (it has no .driver marker)", dir.string());
|
|
return 1;
|
|
}
|
|
if (!SeatPool::DriverAlive(key, boardPath.parent_path(), false)) {
|
|
std::println(std::cerr, "stop: the driver of {} ({}) is no longer running", dir.string(), key);
|
|
return 1;
|
|
}
|
|
targets.emplace_back(key, dir);
|
|
} else {
|
|
const std::string h8 = hash->substr(0, 8);
|
|
const Json board = SeatPool::Board(boardPath).View(Platform::Now());
|
|
const Json& drivers = Dyn::Get(board, "drivers");
|
|
for (auto it = drivers.begin(); it != drivers.end(); ++it) {
|
|
const Json& d = it.value();
|
|
if (Dyn::StrOr(Dyn::Get(d, "run"), "").ends_with(std::format("@ {}", h8)) && Dyn::Truthy(Dyn::Get(d, "scratch"))) targets.emplace_back(it.key(), Dyn::Str(d.at("scratch")));
|
|
}
|
|
if (targets.empty()) {
|
|
std::println(std::cerr, "stop: no live run of {} on the board ({})", h8, boardPath.string());
|
|
return 1;
|
|
}
|
|
}
|
|
for (const auto& [key, dir] : targets) {
|
|
std::ofstream file(dir / "stop", std::ios::binary);
|
|
file << std::format("asked by `{} stop` at {}\n", Invocation(), Platform::LocalTime("%Y-%m-%dT%H:%M:%S"));
|
|
if (!file) {
|
|
std::println(std::cerr, "stop: cannot write {}", (dir / "stop").string());
|
|
return 1;
|
|
}
|
|
std::println("stop: driver {} ({}) - asked to stop; a run stops within 2 s, else SIGTERM its pid", key, dir.string());
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// ---- doc: the texts the Flatpak ships -------------------------------------------------
|
|
constexpr std::array<std::string_view, 3> DocNames = {"prepush", "manifest", "config"};
|
|
constexpr std::array<OptionSpec, 1> DocOptions = {{
|
|
{"--path", false, "", "where the file is, as the host sees it, instead of the file (the procedure is 35 KB: the skill reads it from there)"},
|
|
}};
|
|
constexpr std::array<OptionSpec, 1> DocPositionals = {{
|
|
{"name", false, "", "prepush: the procedure; manifest: the manifest template; config: the config template", Kind::String, DocNames, true},
|
|
}};
|
|
|
|
// Each text under process/.
|
|
std::string_view DocFile(std::string_view name) {
|
|
if (name == "manifest") return "templates/manifest.md";
|
|
if (name == "config") return "templates/config.json";
|
|
return "prepush.md";
|
|
}
|
|
|
|
// A file under the home as the host sees it. In the Flatpak that is
|
|
// under /.flatpak-info's app-path, with the deployment's `active` link
|
|
// in place of its commit, so the path outlives an update.
|
|
fs::path HostPath(const fs::path& path) {
|
|
if (!Platform::InSandbox()) return path;
|
|
const std::string info = Platform::ReadFile(Platform::Paths().flatpakInfo).value_or("");
|
|
for (const std::string& line : Dyn::Split(info, "\n")) {
|
|
if (!line.starts_with("app-path=")) continue;
|
|
const fs::path files(line.substr(9));
|
|
std::error_code error;
|
|
const fs::path inside = fs::relative(path, "/app", error);
|
|
if (error || inside.empty() || inside.string().starts_with("..")) return path;
|
|
return files.parent_path().parent_path() / "active" / files.filename() / inside;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
std::int32_t DocCommand(std::span<const std::string_view> args) {
|
|
constexpr std::string_view Prog = "crafter-review doc";
|
|
const Parsed parsed = ParseOptions(args, DocOptions, DocPositionals);
|
|
if (parsed.help) {
|
|
std::print("{}", Help(Prog, "print a text the pipeline ships, or with --path where it is", DocOptions, DocPositionals));
|
|
return 0;
|
|
}
|
|
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, DocOptions, DocPositionals), Prog, parsed.error);
|
|
const fs::path file = Home() / "process" / DocFile(parsed.positionals.front());
|
|
if (parsed.Flag("path")) {
|
|
std::println("{}", HostPath(file).string());
|
|
return 0;
|
|
}
|
|
const std::optional<std::string> text = Platform::ReadFile(file);
|
|
if (!text) {
|
|
std::println(std::cerr, "doc: cannot read {}", file.string());
|
|
return 1;
|
|
}
|
|
std::print("{}", *text);
|
|
return 0;
|
|
}
|
|
|
|
// ---- browser install: the Chromium the MCP server drives ----------------------------
|
|
constexpr std::array<std::string_view, 1> BrowserActions = {"install"};
|
|
constexpr std::array<OptionSpec, 1> BrowserPositionals = {{
|
|
{"action", false, "", "install: the Chromium build the pinned Playwright MCP server drives, where it looks for it", Kind::String, BrowserActions, true},
|
|
}};
|
|
|
|
// Where Playwright keeps its browsers on Linux: PLAYWRIGHT_BROWSERS_PATH,
|
|
// else $XDG_CACHE_HOME/ms-playwright, else ~/.cache/ms-playwright. In
|
|
// the Flatpak XDG_CACHE_HOME is the host's again (Platform::Init).
|
|
fs::path PlaywrightBrowsers() {
|
|
const std::string set = Platform::GetEnv("PLAYWRIGHT_BROWSERS_PATH").value_or("");
|
|
if (!set.empty() && set != "0") return set;
|
|
const std::string cache = Platform::GetEnv("XDG_CACHE_HOME").value_or("");
|
|
return fs::path(cache.empty() ? std::format("{}/.cache", Platform::Home()) : cache) / "ms-playwright";
|
|
}
|
|
|
|
std::int32_t BrowserCommand(std::span<const std::string_view> args) {
|
|
constexpr std::string_view Prog = "crafter-review browser";
|
|
const Parsed parsed = ParseOptions(args, {}, BrowserPositionals);
|
|
if (parsed.help) {
|
|
std::print("{}", Help(Prog, "the browser a lens with the browser environment drives", {}, BrowserPositionals));
|
|
return 0;
|
|
}
|
|
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, {}, BrowserPositionals), Prog, parsed.error);
|
|
const std::vector<std::string> argv = {"npx", "-y", std::format("playwright@{}", Mcp::PlaywrightVersion), "install", "chromium"};
|
|
std::println("browser: {} (the Playwright of @playwright/mcp@{})", Dyn::Join(argv, " "), Mcp::PlaywrightMcpVersion);
|
|
std::fflush(stdout);
|
|
std::optional<std::int32_t> code;
|
|
try {
|
|
code = Process::Wait(Process::Start({.argv = argv}).pid);
|
|
} catch (const std::exception& e) {
|
|
std::println(std::cerr, "browser: {}", e.what());
|
|
return 1;
|
|
}
|
|
if (code.value_or(1) != 0) {
|
|
std::println(std::cerr, "browser: the install failed (exit {})", code.value_or(1));
|
|
return 1;
|
|
}
|
|
const fs::path dir = PlaywrightBrowsers() / std::format("chromium-{}", Mcp::ChromiumRevision);
|
|
std::error_code error;
|
|
if (!fs::is_directory(dir, error)) {
|
|
std::println(std::cerr, "browser: installed, but {} is not there - the pinned Playwright and Chromium revision disagree", dir.string());
|
|
return 1;
|
|
}
|
|
std::println("browser: chromium-{} in {}", Mcp::ChromiumRevision, dir.string());
|
|
return 0;
|
|
}
|
|
|
|
// ---- init: a repository set up for the gates ---------------------------------------------
|
|
constexpr std::array<OptionSpec, 1> InitOptions = {{
|
|
{"--force", false, "", "replace a SKILL.md or a link init did not make, and run from your home directory"},
|
|
}};
|
|
// After the skill's frontmatter: what tells a later `init` the file is
|
|
// its own to refresh.
|
|
constexpr std::string_view InitMarker = "<!-- written by crafter-review init";
|
|
|
|
std::string WithMarker(const std::string& skill) {
|
|
const std::string marker = std::format("{} {}: `init` refreshes this file while this line is in it -->\n", InitMarker, Version());
|
|
if (skill.starts_with("---\n")) {
|
|
const std::size_t end = skill.find("\n---\n", 3);
|
|
if (end != std::string::npos) return std::format("{}{}{}", skill.substr(0, end + 5), marker, skill.substr(end + 5));
|
|
}
|
|
return std::format("{}{}", marker, skill);
|
|
}
|
|
|
|
std::int32_t InitCommand(std::span<const std::string_view> args) {
|
|
constexpr std::string_view Prog = "crafter-review init";
|
|
const Parsed parsed = ParseOptions(args, InitOptions);
|
|
if (parsed.help) {
|
|
std::print("{}", Help(Prog, "set this repository up for the pre-push gates: .review-gates/config.json from the template, the artifact dirs ignored, the prepush skill", InitOptions));
|
|
return 0;
|
|
}
|
|
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, InitOptions), Prog, parsed.error);
|
|
const bool force = parsed.Flag("force");
|
|
auto refuse = [](const std::string& message) {
|
|
std::println(std::cerr, "init: {}", message);
|
|
return 3;
|
|
};
|
|
// A directory the Flatpak cannot see becomes $HOME inside it, PWD
|
|
// included: the toplevel check and the home check catch that.
|
|
const fs::path cwd = fs::current_path();
|
|
const Process::Captured top = Process::Run({"git", "rev-parse", "--show-toplevel"});
|
|
if (top.returnCode != 0) return refuse(std::format("{} is not in a git checkout: run init from the repository's toplevel", cwd.string()));
|
|
std::error_code error;
|
|
const fs::path toplevel = Dyn::Strip(top.out);
|
|
if (!fs::equivalent(toplevel, cwd, error)) return refuse(std::format("run it from the repository's toplevel, {}, not {}", toplevel.string(), cwd.string()));
|
|
if (!force && fs::equivalent(cwd, Platform::Home(), error)) {
|
|
return refuse(std::format("{} is your home directory, which is where the Flatpak lands in a directory it cannot see: cd into the repository (--force if it is the repository)", cwd.string()));
|
|
}
|
|
// The skill's place. The old setup linked .claude/skills/prepush
|
|
// into the review-gates submodule: the link goes, never written
|
|
// through, which would change the submodule's tracked file and
|
|
// fail the next freeze on a dirty tree. Any other link stays
|
|
// unless --force - checked before anything is written.
|
|
const fs::path skillDir = cwd / ".claude" / "skills" / "prepush";
|
|
const fs::path skill = skillDir / "SKILL.md";
|
|
std::vector<std::pair<fs::path, fs::path>> links;
|
|
for (const fs::path& link : {skillDir, skill}) {
|
|
if (!fs::is_symlink(fs::symlink_status(link, error))) continue;
|
|
const fs::path target = fs::read_symlink(link, error);
|
|
if (!force && !target.string().contains("review-gates")) {
|
|
return refuse(std::format("{} is a link to {}, not into the review-gates submodule: --force replaces it", link.lexically_relative(cwd).string(), target.string()));
|
|
}
|
|
links.emplace_back(link, target);
|
|
}
|
|
std::println("init: {}", cwd.string());
|
|
// The config, from the template, only when there is none. Written
|
|
// fresh, not copied: the Flatpak's files are read-only.
|
|
const fs::path config = cwd / ".review-gates" / "config.json";
|
|
if (fs::exists(config, error)) {
|
|
std::println(" kept .review-gates/config.json");
|
|
} else {
|
|
fs::create_directories(config.parent_path());
|
|
std::ofstream(config, std::ios::binary) << Platform::ReadFile(Home() / "process" / "templates" / "config.json").value_or("{}\n");
|
|
std::println(" wrote .review-gates/config.json (the template: the three kinds as modes with every lens of their kind - trim each to what this repository runs, set base, then what the lenses' environments need)");
|
|
}
|
|
// The artifacts and the worktrees stay out of git.
|
|
const fs::path ignore = cwd / ".gitignore";
|
|
const std::string ignored = Platform::ReadFile(ignore).value_or("");
|
|
std::vector<std::string> present;
|
|
for (const std::string& line : Dyn::Split(ignored, "\n")) present.push_back(Dyn::Strip(Dyn::Strip(line), "/"));
|
|
std::vector<std::string> added;
|
|
for (const std::string_view entry : {std::string_view(".review-gates/push/"), std::string_view(".review-gates/trees/")}) {
|
|
if (!std::ranges::contains(present, Dyn::Strip(entry, "/")) && !std::ranges::contains(present, std::string(".review-gates"))) added.emplace_back(entry);
|
|
}
|
|
if (added.empty()) {
|
|
std::println(" kept .gitignore");
|
|
} else {
|
|
std::string text = ignored;
|
|
if (!text.empty() && !text.ends_with('\n')) text += '\n';
|
|
for (const std::string& entry : added) text += std::format("{}\n", entry);
|
|
std::ofstream(ignore, std::ios::binary) << text;
|
|
std::println(" added {} to .gitignore", Dyn::Join(added, " and "));
|
|
}
|
|
// The skill.
|
|
for (const auto& [link, target] : links) {
|
|
fs::remove(link, error);
|
|
std::println(" removed the link {} -> {}", link.lexically_relative(cwd).string(), target.string());
|
|
}
|
|
fs::create_directories(skillDir);
|
|
const std::optional<std::string> shipped = Platform::ReadFile(Home() / "process" / "skill" / "prepush" / "SKILL.md");
|
|
if (!shipped) return refuse(std::format("cannot read the skill under {}", Home().string()));
|
|
const std::optional<std::string> existing = Platform::ReadFile(skill);
|
|
if (existing && !existing->contains(InitMarker) && !force) {
|
|
std::println(" kept .claude/skills/prepush/SKILL.md (init did not write it: --force replaces it)");
|
|
} else {
|
|
std::ofstream(skill, std::ios::binary) << WithMarker(*shipped);
|
|
std::println(" {} .claude/skills/prepush/SKILL.md", existing ? "refreshed" : "wrote ");
|
|
}
|
|
// What comes next.
|
|
const Config::Loaded loaded = Config::Load(cwd);
|
|
const std::vector<std::string> off = Config::LensesOff(loaded.config);
|
|
const Json environments = Config::Environments(loaded.config);
|
|
bool browser = false;
|
|
for (auto it = environments.begin(); it != environments.end(); ++it) {
|
|
const Plan::Environment* environment = Plan::FindEnvironment(Dyn::Str(it.value()));
|
|
if (!std::ranges::contains(off, it.key()) && environment && !environment->mcp.empty()) browser = true;
|
|
}
|
|
std::println("\nnext:\n {} config - until it reports no problem", Invocation());
|
|
// Only a config that runs no lens has a lens step left: the
|
|
// template init wrote says to trim itself, on its own line, and
|
|
// a kept config that lists lenses is the repository's own.
|
|
if (off.size() == Plan::Lenses.size()) std::println(" list the lenses each mode runs in .review-gates/config.json (modes.<mode>.lenses) - every lens is off until a mode lists it");
|
|
if (browser) std::println(" {} browser install - a lens drives the application in Chromium", Invocation());
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
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") {
|
|
const Parsed parsed = ParseOptions(after, {});
|
|
if (parsed.help) {
|
|
std::print("{}", Help("crafter-review config", "print the merged consumer config and its problems", {}));
|
|
return 0;
|
|
}
|
|
if (!parsed.error.empty()) return UsageError(Usage, "crafter-review", parsed.error);
|
|
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);
|
|
if (command == "seats") return SeatsCommand(after);
|
|
if (command == "stop") return StopCommand(after);
|
|
if (command == "doc") return DocCommand(after);
|
|
if (command == "browser") return BrowserCommand(after);
|
|
if (command == "init") return InitCommand(after);
|
|
} 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;
|
|
} catch (const std::exception& error) {
|
|
// Anything else that escapes a command, as a traceback would.
|
|
std::println(std::cerr, "crafter-review: {}", error.what());
|
|
return 1;
|
|
}
|
|
return UsageError(Usage, "crafter-review", std::format("argument cmd: invalid choice: '{}'", command));
|
|
}
|
|
}
|