feat(cli): seats, init, doc, browser install and stop
- `seats` reports every seat probed (Fable first, Haiku for a seat that needs it), the API seats beside the driver's ledger, the drivers on the board, or all of it as JSON. In the sandbox a last line names `stop`, since the pids above cannot be signalled from there. - `init` sets a repository up: the config from the template, the artifact and worktree dirs ignored, the prepush skill with a marker that lets a later init refresh it. The old link into the review-gates submodule is replaced by a real directory, never written through. A foreign link, a subdirectory, the home directory and a dir outside git are refused before anything is written. - `doc [--path] prepush|manifest|config` prints a shipped text, or where the host sees it (the deployment's `active` link in the Flatpak). - `browser install` runs the installer of the Playwright the pinned MCP server depends on, and checks the Chromium revision landed. - `stop <scratch>` and `stop --hash <hash8>` write the stop file a running driver checks every 2 s, for live drivers only; the board is read, never written. The option parser takes positionals, in the usage line, the help and the errors. The Commands test runs all of them through this binary, seats on a claude stand-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
952eb13f5c
commit
2dd73b59bd
6 changed files with 751 additions and 9 deletions
|
|
@ -1,9 +1,12 @@
|
|||
// 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;
|
||||
|
|
@ -13,6 +16,9 @@ import :Freeze;
|
|||
import :PgWire;
|
||||
import :Totp;
|
||||
import :Plan;
|
||||
import :Mcp;
|
||||
import :Seats;
|
||||
import :SeatPool;
|
||||
import :OpenCode;
|
||||
import :Run;
|
||||
import std;
|
||||
|
|
@ -78,6 +84,7 @@ inventory unit, against a frozen commit range.)";
|
|||
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;
|
||||
|
||||
|
|
@ -91,30 +98,51 @@ inventory unit, against a frozen commit range.)";
|
|||
}
|
||||
};
|
||||
|
||||
std::string UsageLine(std::string_view prog, std::span<const OptionSpec> specs) {
|
||||
// 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 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);
|
||||
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;
|
||||
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);
|
||||
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.
|
||||
Parsed ParseOptions(std::span<const std::string_view> args, std::span<const OptionSpec> specs) {
|
||||
// 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) {
|
||||
|
|
@ -124,7 +152,18 @@ inventory unit, against a frozen commit range.)";
|
|||
continue;
|
||||
}
|
||||
if (!arg.starts_with("--") || arg == "--") {
|
||||
unrecognized.emplace_back(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('=');
|
||||
|
|
@ -194,6 +233,9 @@ inventory unit, against a frozen commit range.)";
|
|||
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;
|
||||
}
|
||||
|
|
@ -398,6 +440,442 @@ inventory unit, against a frozen commit range.)";
|
|||
Process::KillDescendants();
|
||||
return code;
|
||||
}
|
||||
|
||||
// ---- 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 (default: $SEATS_ENV_FILE, else ./.env when it exists)"},
|
||||
{"--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;
|
||||
const std::map<std::string, std::string> process = Platform::Environment();
|
||||
const Json cfg = Seats::ConfigFromEnv(process);
|
||||
std::string envFile = parsed.Value("env-file").value_or("");
|
||||
if (envFile.empty()) envFile = Platform::GetEnv("SEATS_ENV_FILE").value_or("");
|
||||
std::error_code error;
|
||||
if (envFile.empty() && fs::exists(".env", error)) envFile = ".env";
|
||||
std::map<std::string, std::string> env = process;
|
||||
if (!envFile.empty()) {
|
||||
const std::optional<std::string> text = Platform::ReadFile(envFile);
|
||||
if (!text) throw Dyn::Error("FileNotFoundError", std::format("[Errno 2] No such file or directory: {}", Dyn::Repr(envFile)));
|
||||
for (const auto& [name, value] : Seats::SeatTokensFromEnvFile(*text)) env[name] = value;
|
||||
}
|
||||
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(process);
|
||||
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: 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());
|
||||
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) {
|
||||
|
|
@ -457,6 +935,11 @@ inventory unit, against a frozen commit range.)";
|
|||
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());
|
||||
|
|
|
|||
|
|
@ -820,6 +820,11 @@ namespace Crafter::Review::Seats {
|
|||
}
|
||||
}
|
||||
|
||||
std::string Clock(double epoch, const Zone& zone) {
|
||||
const std::tm d = InZone(static_cast<std::time_t>(std::floor(epoch)), zone);
|
||||
return std::format("{:02d}:{:02d}", d.tm_hour, d.tm_min);
|
||||
}
|
||||
|
||||
std::string FormatWhen(double epoch, double now, const Zone& zone) {
|
||||
const auto diffMin = static_cast<std::int64_t>(JsRound((epoch - now) / 60));
|
||||
std::string relative;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import :Json;
|
|||
export namespace Crafter::Review::Mcp {
|
||||
// @playwright/mcp, verified 2026-10-15 with CLI 2.1.300.
|
||||
constexpr std::string_view PlaywrightMcpVersion = "0.0.83";
|
||||
// The playwright that version depends on, exactly (its package.json):
|
||||
// `crafter-review browser install` runs its installer.
|
||||
constexpr std::string_view PlaywrightVersion = "1.64.0-alpha-1790635538000";
|
||||
// The Chromium build that version's Playwright drives, as `npx playwright
|
||||
// install chromium` puts it under ~/.cache/ms-playwright: what
|
||||
// `crafter-review browser install` provides.
|
||||
|
|
|
|||
|
|
@ -112,6 +112,8 @@ export namespace Crafter::Review::Seats {
|
|||
};
|
||||
Zone LocalZone();
|
||||
Zone ZoneNamed(std::string_view name);
|
||||
// "02:40": the clock in the zone.
|
||||
std::string Clock(double epoch, const Zone& zone);
|
||||
// "02:40 (in 1h 50m)"; more than a day out with its date: "Wed 16 Sept,
|
||||
// 11:00 (in 6d 10h)".
|
||||
std::string FormatWhen(double epoch, double now, const Zone& zone);
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ namespace {
|
|||
|
||||
// One per directory under tests/, each an ordinary main() that links the
|
||||
// library: exit 0 passes, nonzero fails, 77 skips.
|
||||
constexpr std::array<std::string_view, 24> Tests = {
|
||||
constexpr std::array<std::string_view, 25> Tests = {
|
||||
"Crypto",
|
||||
"Text",
|
||||
"Platform",
|
||||
|
|
@ -109,6 +109,7 @@ namespace {
|
|||
"StacksMemory",
|
||||
"ReviewFixes",
|
||||
"Runs",
|
||||
"Commands",
|
||||
};
|
||||
|
||||
// The version the binary reports: CI sets it from the tag, a checkout
|
||||
|
|
|
|||
248
tests/Commands/main.cpp
Normal file
248
tests/Commands/main.cpp
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The commands around a run, this binary as crafter-review: init (writes,
|
||||
// refreshes, keeps what it did not write, replaces the old link into the
|
||||
// submodule, refuses a foreign link, a subdirectory and the home directory
|
||||
// before it writes anything), doc, stop (a scratch dir, --hash on the
|
||||
// board), seats on a claude stand-in, and browser's usage.
|
||||
|
||||
#include <unistd.h>
|
||||
import std;
|
||||
import Crafter.Review;
|
||||
namespace fs = std::filesystem;
|
||||
using namespace Crafter::Review;
|
||||
|
||||
namespace {
|
||||
std::int32_t Failures = 0;
|
||||
|
||||
void Check(std::string_view name, bool cond, std::string_view detail = {}) {
|
||||
std::println("{}{}{}", cond ? " ok " : " FAIL ", name, !cond && !detail.empty() ? std::format(" ({})", detail) : std::string());
|
||||
if (!cond) ++Failures;
|
||||
}
|
||||
|
||||
void Write(const fs::path& path, std::string_view text) {
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream(path, std::ios::binary) << text;
|
||||
}
|
||||
|
||||
std::string Read(const fs::path& path) {
|
||||
return Platform::ReadFile(path).value_or("");
|
||||
}
|
||||
|
||||
// A claude that answers a seat probe as the CLI does: a rate_limit_event
|
||||
// with both windows, and on a Fable request the Fable window too.
|
||||
constexpr std::string_view FakeClaude = R"sh(#!/bin/sh
|
||||
model=
|
||||
prev=
|
||||
for a in "$@"; do [ "$prev" = --model ] && model=$a; prev=$a; done
|
||||
cat > /dev/null
|
||||
now=$(date +%s)
|
||||
fable=
|
||||
[ "$model" = claude-fable-5-1 ] && fable=",\"seven_day_overage_included\":{\"utilization\":0.3,\"resetsAt\":$((now+172800))}"
|
||||
echo '{"type":"system","subtype":"init","session_id":"0b6f3c56-1111-4222-8333-944455556666"}'
|
||||
echo "{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed\",\"rateLimitType\":\"five_hour\",\"resetsAt\":$((now+3600)),\"unifiedWindows\":{\"five_hour\":{\"utilization\":0.42,\"resetsAt\":$((now+3600))},\"seven_day\":{\"utilization\":0.13,\"resetsAt\":$((now+259200))}$fable}}}"
|
||||
echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.0001}'
|
||||
)sh";
|
||||
|
||||
// The environment of every child: git with no user config, this
|
||||
// checkout as the home, nothing of the shell's review or seat setup.
|
||||
std::map<std::string, std::string> Env;
|
||||
|
||||
std::map<std::string, std::string> With(std::map<std::string, std::string> env, const std::map<std::string, std::string>& changes) {
|
||||
for (const auto& [k, v] : changes) env[k] = v;
|
||||
return env;
|
||||
}
|
||||
|
||||
Process::Captured Self(std::vector<std::string> args, const fs::path& cwd, const std::map<std::string, std::string>& env = {}) {
|
||||
args.insert(args.begin(), Platform::SelfExe().string());
|
||||
return Process::Run(args, cwd, std::chrono::seconds(60), {}, With(Env, env));
|
||||
}
|
||||
|
||||
std::int32_t Checks() {
|
||||
std::println("commands: init, doc, stop, seats on a claude stand-in, browser's usage");
|
||||
Platform::SetMode(Platform::Mode::Native);
|
||||
const fs::path checkout = fs::current_path();
|
||||
SetHome(checkout);
|
||||
const fs::path base = fs::temp_directory_path() / std::format("crafter-review-commands-{}", Platform::PublishedPid());
|
||||
fs::remove_all(base);
|
||||
for (const auto& [k, v] : Platform::Environment()) {
|
||||
const bool review = k.starts_with("REVIEW_GATES_") || k.starts_with("CRAFTER_REVIEW_") || k == "SEATS_ENV_FILE" || k == "CLAUDE_BIN";
|
||||
const bool seat = k.starts_with("CLAUDE_CODE_OAUTH_TOKEN") || k.starts_with("ANTHROPIC_API_KEY") || k.starts_with("OPENROUTER_KEY");
|
||||
if (!review && !seat) Env[k] = v;
|
||||
}
|
||||
Env = With(Env, {{"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"CRAFTER_REVIEW_HOME", checkout.string()},
|
||||
{"REVIEW_GATES_BOARD", (base / "board.json").string()}, {"REVIEW_GATES_SCRATCH", (base / "scratch").string()}, {"TZ", "UTC"}});
|
||||
auto repo = [&](std::string_view name) {
|
||||
const fs::path dir = base / name;
|
||||
fs::create_directories(dir);
|
||||
Process::Run({"git", "init", "-q", "-b", "main", dir.string()}, std::nullopt, std::chrono::seconds(60), {}, Env);
|
||||
return dir;
|
||||
};
|
||||
const std::string shipped = Read(checkout / "process" / "skill" / "prepush" / "SKILL.md");
|
||||
const std::string marker = "<!-- written by crafter-review init";
|
||||
|
||||
// init on a repository that has nothing yet, then again.
|
||||
const fs::path r = repo("r");
|
||||
const Process::Captured first = Self({"init"}, r);
|
||||
const std::string skill = Read(r / ".claude" / "skills" / "prepush" / "SKILL.md");
|
||||
const std::size_t frontmatter = shipped.find("\n---\n", 3) + 5;
|
||||
Check("init: the config from the template, the artifact and worktree dirs ignored, the skill with init's marker after its frontmatter, the next steps",
|
||||
first.returnCode == 0 && Read(r / ".review-gates" / "config.json") == Read(checkout / "process" / "templates" / "config.json")
|
||||
&& Read(r / ".gitignore") == ".review-gates/push/\n.review-gates/trees/\n" && skill.starts_with(shipped.substr(0, frontmatter))
|
||||
&& skill.substr(frontmatter).starts_with(marker) && skill.ends_with(shipped.substr(frontmatter)) && first.out.contains(" config - until it reports no problem")
|
||||
&& first.out.contains(" browser install - "),
|
||||
first.out + first.err);
|
||||
Write(r / ".review-gates" / "config.json", R"({"base": "origin/dev"})");
|
||||
Write(r / ".gitignore", "node_modules/\n/.review-gates/push\n");
|
||||
Write(r / ".claude" / "skills" / "prepush" / "SKILL.md", std::format("---\nname: prepush\n---\n{} 0.0.1: old -->\nold text\n", marker));
|
||||
const Process::Captured again = Self({"init"}, r);
|
||||
Check("init again: the config and an entry already ignored are kept, the missing one added once, the skill it wrote refreshed",
|
||||
again.returnCode == 0 && Read(r / ".review-gates" / "config.json") == R"({"base": "origin/dev"})"
|
||||
&& Read(r / ".gitignore") == "node_modules/\n/.review-gates/push\n.review-gates/trees/\n" && Read(r / ".claude" / "skills" / "prepush" / "SKILL.md") == skill
|
||||
&& again.out.contains("refreshed .claude/skills/prepush/SKILL.md"),
|
||||
again.out + again.err);
|
||||
Write(r / ".claude" / "skills" / "prepush" / "SKILL.md", "my own skill\n");
|
||||
const Process::Captured kept = Self({"init"}, r);
|
||||
const std::string mine = Read(r / ".claude" / "skills" / "prepush" / "SKILL.md");
|
||||
const Process::Captured forced = Self({"init", "--force"}, r);
|
||||
Check("init: a SKILL.md init did not write is kept; --force replaces it",
|
||||
kept.returnCode == 0 && mine == "my own skill\n" && kept.out.contains("--force replaces it") && forced.returnCode == 0 && Read(r / ".claude" / "skills" / "prepush" / "SKILL.md") == skill,
|
||||
kept.out + forced.out);
|
||||
|
||||
// The old setup: the skill dir a link into the submodule.
|
||||
const fs::path old = repo("old");
|
||||
Write(old / "review-gates" / "process" / "skill" / "prepush" / "SKILL.md", "the submodule's file\n");
|
||||
fs::create_directories(old / ".claude" / "skills");
|
||||
fs::create_directory_symlink("../../review-gates/process/skill/prepush", old / ".claude" / "skills" / "prepush");
|
||||
const Process::Captured relinked = Self({"init"}, old);
|
||||
std::error_code error;
|
||||
Check("init: the old link into the submodule goes - a real directory with the skill in its place, the submodule's file untouched",
|
||||
relinked.returnCode == 0 && !fs::is_symlink(fs::symlink_status(old / ".claude" / "skills" / "prepush", error))
|
||||
&& Read(old / ".claude" / "skills" / "prepush" / "SKILL.md") == skill && Read(old / "review-gates" / "process" / "skill" / "prepush" / "SKILL.md") == "the submodule's file\n"
|
||||
&& relinked.out.contains("removed the link .claude/skills/prepush -> ../../review-gates/process/skill/prepush"),
|
||||
relinked.out + relinked.err);
|
||||
|
||||
// What init refuses, before it writes anything.
|
||||
const fs::path foreign = repo("foreign");
|
||||
fs::create_directories(base / "elsewhere");
|
||||
fs::create_directories(foreign / ".claude" / "skills");
|
||||
fs::create_directory_symlink("../../../elsewhere", foreign / ".claude" / "skills" / "prepush");
|
||||
const Process::Captured refused = Self({"init"}, foreign);
|
||||
fs::create_directories(r / "sub");
|
||||
const Process::Captured sub = Self({"init"}, r / "sub");
|
||||
const fs::path home = repo("home");
|
||||
const Process::Captured atHome = Self({"init"}, home, {{"HOME", home.string()}});
|
||||
const bool homeUntouched = !fs::exists(home / ".review-gates");
|
||||
const Process::Captured atHomeForced = Self({"init", "--force"}, home, {{"HOME", home.string()}});
|
||||
fs::create_directories(base / "plain");
|
||||
const Process::Captured plain = Self({"init"}, base / "plain");
|
||||
Check("init refuses, exit 3, writing nothing: a link that is not into the submodule (--force replaces it), a subdirectory, the home directory (--force goes on), no checkout",
|
||||
refused.returnCode == 3 && refused.err.contains(".claude/skills/prepush is a link to ../../../elsewhere") && !fs::exists(foreign / ".review-gates") && !fs::exists(foreign / ".gitignore")
|
||||
&& sub.returnCode == 3 && sub.err.contains("from the repository's toplevel") && !fs::exists(r / "sub" / ".review-gates") && atHome.returnCode == 3
|
||||
&& atHome.err.contains("is your home directory") && homeUntouched && atHomeForced.returnCode == 0 && plain.returnCode == 3
|
||||
&& plain.err.contains("is not in a git checkout"),
|
||||
refused.err + sub.err + atHome.err + plain.err);
|
||||
|
||||
// doc: the texts, and where they are.
|
||||
const Process::Captured prepush = Self({"doc", "prepush"}, base);
|
||||
const Process::Captured where = Self({"doc", "--path", "config"}, base);
|
||||
const Process::Captured missing = Self({"doc"}, base);
|
||||
const Process::Captured wrong = Self({"doc", "nope"}, base);
|
||||
Check("doc: the procedure byte for byte, a template's path, usage errors for no name and an unknown one (exit 3)",
|
||||
prepush.returnCode == 0 && prepush.out == Read(checkout / "process" / "prepush.md") && where.returnCode == 0
|
||||
&& where.out == std::format("{}\n", (checkout / "process" / "templates" / "config.json").string()) && missing.returnCode == 3
|
||||
&& missing.err.contains("the following arguments are required: name") && wrong.returnCode == 3
|
||||
&& wrong.err.contains("argument name: invalid choice: 'nope' (choose from 'prepush', 'manifest', 'config')"),
|
||||
prepush.err + where.out + missing.err + wrong.err);
|
||||
|
||||
// stop: a live driver is this process; one that is gone, a sleep that
|
||||
// was reaped.
|
||||
const std::string me = Platform::Self()->Key();
|
||||
const fs::path live = base / "scratch" / "abcd1234-run-1";
|
||||
Write(live / ".driver", me);
|
||||
const std::int64_t gone = Process::Start({.argv = {"true"}}).pid;
|
||||
Process::Wait(gone);
|
||||
const fs::path dead = base / "scratch" / "abcd1234-run-2";
|
||||
Write(dead / ".driver", std::format("{}:{}", gone, 1));
|
||||
const Process::Captured byDir = Self({"stop", live.string()}, base);
|
||||
const bool wroteDir = fs::exists(live / "stop");
|
||||
fs::remove(live / "stop");
|
||||
const Process::Captured byDead = Self({"stop", dead.string()}, base);
|
||||
const Process::Captured byNone = Self({"stop", (base / "plain").string()}, base);
|
||||
const Process::Captured neither = Self({"stop"}, base);
|
||||
Check("stop <dir>: the stop file for the live driver its .driver names (exit 0); a driver that is gone, a dir no run holds, no target: nothing written",
|
||||
byDir.returnCode == 0 && wroteDir && byDir.out.contains(std::format("stop: driver {}", me)) && byDead.returnCode == 1 && byDead.err.contains("is no longer running")
|
||||
&& !fs::exists(dead / "stop") && byNone.returnCode == 1 && byNone.err.contains("no .driver marker") && neither.returnCode == 3,
|
||||
byDir.out + byDir.err + byDead.err + byNone.err);
|
||||
Json drivers = Json::object();
|
||||
drivers[me] = {{"run", "commits x @ abcd1234"}, {"scratch", live.string()}, {"since", Platform::Now()}, {"boot", Platform::BootId()}};
|
||||
Write(base / "board.json", DumpFile({{"version", 1}, {"drivers", drivers}, {"seats", Json::object()}}));
|
||||
const std::string boardBefore = Read(base / "board.json");
|
||||
const Process::Captured byHash = Self({"stop", "--hash", "abcd1234ffff"}, base);
|
||||
const bool wroteHash = fs::exists(live / "stop");
|
||||
const Process::Captured otherHash = Self({"stop", "--hash", "00000000"}, base);
|
||||
Check("stop --hash: every live run of the hash on the board gets its stop file (the first 8 characters decide); another hash finds none (exit 1); the board is read, never written",
|
||||
byHash.returnCode == 0 && wroteHash && otherHash.returnCode == 1 && otherHash.err.contains("no live run of 00000000 on the board") && Read(base / "board.json") == boardBefore,
|
||||
byHash.out + byHash.err + otherHash.err);
|
||||
|
||||
// seats on the stand-in: a token from the env file, an API key, the
|
||||
// CLI's own login.
|
||||
const fs::path fake = base / "claude";
|
||||
Write(fake, FakeClaude);
|
||||
fs::permissions(fake, fs::perms::owner_all);
|
||||
Write(base / "seats" / ".env", "CLAUDE_CODE_OAUTH_TOKEN1=\"tok-one\"\nANTHROPIC_API_KEY1=sk-ant-api-one\n");
|
||||
fs::remove(base / "board.json");
|
||||
const std::map<std::string, std::string> withFake = {{"CLAUDE_BIN", fake.string()}};
|
||||
const Process::Captured report = Self({"seats", "--local", "--env-file", ".env"}, base / "seats", withFake);
|
||||
const Process::Captured json = Self({"seats", "--json", "--no-fable"}, base / "seats", withFake);
|
||||
Json parsed;
|
||||
try {
|
||||
parsed = Json::parse(json.out);
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
Check("seats: every seat probed - the CLI's own login, the env file's token, the API key - its windows read, the API seat beside the driver's ledger, the drivers on the board",
|
||||
report.returnCode == 0 && report.out.starts_with("seat pool: 3 seat(s) (local, 1, api1) from .env and your own login, probed ")
|
||||
&& report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | $0.00 of $200") && report.out.contains("drivers: none running"),
|
||||
report.out + report.err);
|
||||
Check("seats --json: the probe results as JSON - the seats, the API seats, the eligible ones and the next placement; ./.env read by default; no token, no key printed",
|
||||
json.returnCode == 0 && parsed.is_object() && parsed.at("seats").size() == 1 && parsed.at("seats").at(0).at("probedWith") == "haiku" && parsed.at("apiSeats").size() == 1
|
||||
&& parsed.at("eligible") == Json::array({1}) && parsed.at("next").at("seat") == 1 && Dyn::Str(parsed.at("probedAt")).ends_with("Z")
|
||||
&& !(report.out + json.out).contains("tok-one") && !(report.out + json.out).contains("sk-ant-api-one"),
|
||||
json.out.substr(0, 600) + json.err);
|
||||
const Process::Captured none = Self({"seats"}, base / "plain", withFake);
|
||||
const Process::Captured noFile = Self({"seats", "--env-file", "missing.env"}, base / "plain", withFake);
|
||||
Check("seats: no seat configured is exit 1 with what it looked for; a missing env file is exit 1 with the read error",
|
||||
none.returnCode == 1 && none.out.starts_with("no seat configured (CLAUDE_CODE_OAUTH_TOKEN1..6 / ANTHROPIC_API_KEY1..6; --local probes your own login)")
|
||||
&& noFile.returnCode == 1 && noFile.err.contains("FileNotFoundError: [Errno 2] No such file or directory: 'missing.env'"),
|
||||
none.out + noFile.err);
|
||||
|
||||
// browser: its usage (an install fetches from npm).
|
||||
const Process::Captured noAction = Self({"browser"}, base);
|
||||
const Process::Captured badAction = Self({"browser", "remove"}, base);
|
||||
Check("browser: an action is required, and install is the one there is (exit 3)",
|
||||
noAction.returnCode == 3 && noAction.err.contains("the following arguments are required: action") && badAction.returnCode == 3
|
||||
&& badAction.err.contains("argument action: invalid choice: 'remove' (choose from 'install')"),
|
||||
noAction.err + badAction.err);
|
||||
fs::remove_all(base);
|
||||
return Failures ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const std::vector<std::string_view> args(argv, argv + argc);
|
||||
// With arguments this binary is crafter-review itself, in native mode
|
||||
// whatever builds it.
|
||||
if (args.size() > 1) {
|
||||
Platform::SystemPaths paths = Platform::Paths();
|
||||
paths.flatpakInfo = "/nonexistent/.flatpak-info";
|
||||
Platform::SetPaths(paths);
|
||||
return Crafter::Review::Main(args);
|
||||
}
|
||||
try {
|
||||
return Checks();
|
||||
} catch (const std::exception& error) {
|
||||
std::println(std::cerr, "commands: {}", error.what());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue