diff --git a/implementations/Crafter.Review-Driver.cpp b/implementations/Crafter.Review-Driver.cpp index 22b13ef..389536f 100644 --- a/implementations/Crafter.Review-Driver.cpp +++ b/implementations/Crafter.Review-Driver.cpp @@ -1,9 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include 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> values; std::set> flags; + std::vector 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 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 names(positional.choices.begin(), positional.choices.end()); + return std::format("{{{}}}", Dyn::Join(names, ",")); + } + + std::string UsageLine(std::string_view prog, std::span specs, std::span 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 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 specs, std::span 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 args, std::span specs) { + // end; positionals in their order, each one word, optional unless + // required. + Parsed ParseOptions(std::span args, std::span specs, std::span positionals = {}) { Parsed parsed; std::vector 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 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 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::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(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 ProbeAll(const std::vector& todo, const Seats::ProbeOptions& base) { + std::vector out(todo.size()); + std::atomic next = 0; + std::mutex lock; + std::exception_ptr failed; + std::vector workers; + for (std::size_t w = 0; w < std::min(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 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 timeoutText = parsed.Value("timeout"); + const double timeout = timeoutText ? Dyn::ParseFloat(*timeoutText).value_or(Seats::ProbeTimeoutS) : Seats::ProbeTimeoutS; + const std::map 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 env = process; + if (!envFile.empty()) { + const std::optional 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 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> 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 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 first = ProbeAll(jobs, fable); + std::vector again; + for (std::size_t i = 0; i < jobs.size(); ++i) { + if (Seats::NeedsHaiku(first[i])) again.push_back(jobs[i]); + } + std::map standIns; + const std::vector got = again.empty() ? std::vector() : 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()); + seats.push_back(Seats::MergeFableProbe(found != standIns.end() ? found->second : s, s)); + } + } + const Json apiConfig = SeatPool::ApiConfig(process); + std::vector apiSeats; + if (!apiJobs.empty()) { + std::vector 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())); + } + // 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 keys; + for (const SeatJob& j : jobs) keys[j.seat] = SeatPool::SeatKey(j.local ? "local" : "oauth", j.token); + std::map 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 ids; + for (const Json& s : seats) ids.push_back(s.at("seat") == 0 ? std::string("local") : std::to_string(s.at("seat").get())); + for (const Json& s : apiSeats) ids.push_back(std::format("api{}", s.at("seat").get())); + 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 ` or `{} stop `", Invocation(), Invocation()); + } + return 0; + } + + // ---- stop: the file a running driver looks for every 2 s ------------------------ + constexpr std::array StopOptions = {{ + {"--hash", true, "HASH8", "every live run of this artifact hash on the board (its first 8 characters)"}, + }}; + constexpr std::array StopPositionals = {{ + {"scratch", false, "", "the run's scratch dir (the last word of its gate-0 log)"}, + }}; + + std::int32_t StopCommand(std::span 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 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> 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 DocNames = {"prepush", "manifest", "config"}; + constexpr std::array 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 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 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 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 BrowserActions = {"install"}; + constexpr std::array 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 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 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 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 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 = "\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 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> 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 present; + for (const std::string& line : Dyn::Split(ignored, "\n")) present.push_back(Dyn::Strip(Dyn::Strip(line), "/")); + std::vector 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 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 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 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 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()); diff --git a/implementations/Crafter.Review-Seats.cpp b/implementations/Crafter.Review-Seats.cpp index d2cfd14..78840bf 100644 --- a/implementations/Crafter.Review-Seats.cpp +++ b/implementations/Crafter.Review-Seats.cpp @@ -820,6 +820,11 @@ namespace Crafter::Review::Seats { } } + std::string Clock(double epoch, const Zone& zone) { + const std::tm d = InZone(static_cast(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(JsRound((epoch - now) / 60)); std::string relative; diff --git a/interfaces/Crafter.Review-Mcp.cppm b/interfaces/Crafter.Review-Mcp.cppm index feb044c..2908364 100644 --- a/interfaces/Crafter.Review-Mcp.cppm +++ b/interfaces/Crafter.Review-Mcp.cppm @@ -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. diff --git a/interfaces/Crafter.Review-Seats.cppm b/interfaces/Crafter.Review-Seats.cppm index 3692c0e..0fe9663 100644 --- a/interfaces/Crafter.Review-Seats.cppm +++ b/interfaces/Crafter.Review-Seats.cppm @@ -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); diff --git a/project.cpp b/project.cpp index 5433f23..2fdf5e5 100644 --- a/project.cpp +++ b/project.cpp @@ -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 Tests = { + constexpr std::array 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 diff --git a/tests/Commands/main.cpp b/tests/Commands/main.cpp new file mode 100644 index 0000000..3924eeb --- /dev/null +++ b/tests/Commands/main.cpp @@ -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 +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 Env; + + std::map With(std::map env, const std::map& changes) { + for (const auto& [k, v] : changes) env[k] = v; + return env; + } + + Process::Captured Self(std::vector args, const fs::path& cwd, const std::map& 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 = "\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 : 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 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 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; + } +}