An independent check of every behaviour AGENTS.md was to describe: - `--units ,` and `--only ,` skipped their empty items and so had no filter left: a typo started a run of every unit. They are usage errors now (exit 3). - Two fixes the plan lists were missing: a dry run now clears the prompts a dry run before it left in the dir they share (the report counted them), and `config` reports an absolute functional.mcp.command that is no program here - /usr/bin/npx inside the Flatpak, whose /usr is the Sdk's. - A patch that is not UTF-8 stops `run` at preflight (exit 3), as the plan has it; it exited 1. - An exception no command caught aborted the process (134), e.g. a home that holds no lenses: it ends with its message and exit 1. An error escaping `run` or `freeze` now also clears the signal handler's run and sweeps what is left below the subreaper. - `config -h` printed "unrecognized arguments" (exit 3): it prints the usage, exit 0. - An API seat's .claude.json is linked into place, so a second writer never replaces the first's file and what claude wrote to it since. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
18 KiB
C++
260 lines
18 KiB
C++
// 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);
|
|
Write(base / "plain" / "bad.env", std::string("\xff\n", 2));
|
|
const Process::Captured badFile = Self({"seats", "--env-file", "bad.env"}, base / "plain", withFake);
|
|
Check("seats: no seat configured is exit 1 with what it looked for; a missing env file and one that is not UTF-8 are 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'") && badFile.returnCode == 1
|
|
&& badFile.err.contains("UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte"),
|
|
none.out + noFile.err + badFile.err);
|
|
|
|
// What escapes a command ends it with exit 1, never an abort; config's
|
|
// help.
|
|
const Process::Captured noHome = Self({"doc", "prepush"}, base, {{"CRAFTER_REVIEW_HOME", (base / "nowhere").string()}});
|
|
const Process::Captured configHelp = Self({"config", "-h"}, base);
|
|
Check("an error no command catches ends it with exit 1 and its message, never an abort; config -h prints its usage, exit 0",
|
|
noHome.returnCode == 1 && noHome.err.starts_with("crafter-review: ") && noHome.err.contains("holds no process/lenses") && configHelp.returnCode == 0
|
|
&& configHelp.out.starts_with("usage: crafter-review config [-h]"),
|
|
noHome.err + configHelp.out + configHelp.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;
|
|
}
|
|
}
|