feat(cli): crafter-debug, the client agents drive an app with

Cli::Run is the whole client but its transport, so tests run it in
process against a real Session; main will hand it the QUIC connection.

- Commands: ls, status, cmds, help, call, get, set, pause, resume,
  step, wait, trace, log (with --follow), screenshot, quit, debugger,
  guide and version, with -i, --json and --timeout.
- call types key=value from the command's argument schema (slot=1 is
  a number, name=1 a string, at.x=3 nests), so agents need no JSON
  for simple calls; a value that cannot fit its field is a usage
  error before anything is sent.
- The instance comes from discovery; one running instance needs no -i.
  The record's protocol is checked before any request: a mismatch
  exits 4, says which side is newer and prints the app's own client.
  Other sources only warn.
- A request with no reply is diagnosed: the app has exited, stopped
  in a debugger (with the attach command), or no longer calls Pump
  (with what to change). Exit codes: 2 usage, 3 no instance,
  4 version, 5 timeout, 6 the app's error.
- Output is short by default, arrays cut at 20 with a note; --json
  prints everything. `guide` is the full reference for agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 02:03:38 +01:00
commit 34c60dcd34
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
7 changed files with 1359 additions and 10 deletions

View file

@ -0,0 +1,213 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// The crafter-debug client end to end against a real Session in the same
// process, through a Connection that hands each request to Session::Handle
// while a thread plays the app's frame loop (Advance, Pump). Covers what an
// agent sees: typed key=value calls and the usage error for a bad value,
// suggestions for typos, get/set, pause/step/resume, waits, the log, traces,
// screenshots written where asked, --json, instance selection, the protocol
// and source checks against the discovery record, a server-side timeout,
// and a client-side one that diagnoses an app no longer calling Pump.
#include <stdio.h>
import std;
import Crafter.Debug;
import Crafter.Debug.Cli;
using namespace Crafter::Debug;
namespace fs = std::filesystem;
using namespace std::chrono_literals;
namespace {
std::int32_t failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
struct AimArgs {
Required<std::int32_t> slot;
float yaw = 10;
};
struct Aimed {
std::int32_t slot = 0;
float yaw = 0;
};
Session* session = nullptr;
class Collect : public Detail::Exchange {
public:
explicit Collect(std::function<bool(const Value&)> onLine) : onLine(std::move(onLine)) {}
void Reply(const Value& reply) override {
std::lock_guard lock(mutex);
if (final) return;
final = reply;
ready.notify_all();
}
bool Stream(const Value& line) override { return onLine ? onLine(line) : true; }
std::optional<Value> Wait(std::chrono::milliseconds timeout) {
std::unique_lock lock(mutex);
ready.wait_for(lock, timeout, [&] { return final.has_value(); });
return final;
}
private:
std::function<bool(const Value&)> onLine;
std::mutex mutex;
std::condition_variable ready;
std::optional<Value> final;
};
class InProcess : public Cli::Connection {
public:
std::expected<Value, Cli::CallError> Call(const Value& request, std::chrono::milliseconds timeout, const std::function<bool(const Value&)>& onLine) override {
auto exchange = std::make_shared<Collect>(onLine);
session->Handle(request, exchange);
if (auto reply = exchange->Wait(timeout)) return *reply;
return std::unexpected(Cli::CallError{ Cli::CallError::Kind::TimedOut, "no reply" });
}
};
struct Ran {
std::int32_t code = 0;
std::string out;
std::string err;
};
Ran Run(std::vector<std::string_view> args, const std::vector<Instance>& instances) {
std::ostringstream out;
std::ostringstream err;
Cli::Environment environment;
environment.connect = [](const InstanceRecord&) -> std::unique_ptr<Cli::Connection> { return std::make_unique<InProcess>(); };
environment.list = [instances] { return instances; };
environment.out = &out;
environment.err = &err;
environment.cwd = fs::temp_directory_path() / "crafter-debug-ShouldRunClientCommands";
environment.haveLldb = [] { return false; };
const std::int32_t code = Cli::Run(args, environment);
return { code, out.str(), err.str() };
}
bool Has(const std::string& text, std::string_view part) {
return text.find(part) != std::string::npos;
}
}
int main() {
std::thread([] {
std::this_thread::sleep_for(50s);
std::println("FAIL the test hung");
std::fflush(stdout);
std::_Exit(1);
}).detach();
Command("Player_SetAim", "Aim the turret.", [](const AimArgs& args) { return Aimed{ *args.slot, args.yaw }; });
std::int32_t hp = 100;
Watch("hp", hp);
std::int32_t score = 0;
Watch("score", score);
SetCapture([] {
return Async<std::vector<std::byte>>([]() -> std::optional<std::vector<std::byte>> {
return std::vector<std::byte>{ std::byte{ 0x89 }, std::byte{ 'P' }, std::byte{ 'N' }, std::byte{ 'G' } };
});
});
Session owned({ .name = "game", .listen = false });
session = &owned;
const InstanceRecord record{ .name = "game", .instanceId = "abc123", .pid = 2, .token = owned.Token(), .protocol = protocolVersion, .sourceId = std::string(Detail::SourceId()) };
const std::vector<Instance> instances = { { record, {} } };
std::atomic<bool> pumping = true;
std::thread app([&] {
while (pumping) {
if (Advance()) ++score;
Pump();
std::this_thread::sleep_for(2ms);
}
});
Ran r = Run({ "status" }, instances);
Check(r.code == 0 && Has(r.out, "game frame"), "status");
r = Run({ "cmds" }, instances);
Check(r.code == 0 && Has(r.out, "Player_SetAim(slot: integer (required), yaw: number = 10.0) -> Aimed -- Aim the turret."), std::format("cmds shows arguments and defaults: {}", r.out));
r = Run({ "help", "Player_SetAim" }, instances);
Check(r.code == 0 && Has(r.out, "required") && Has(r.out, "default 10.0"), "help shows each argument");
r = Run({ "call", "Player_SetAim", "slot=2", "yaw=45" }, instances);
Check(r.code == 0 && Has(r.out, R"({"slot":2,"yaw":45.0})"), std::format("call types key=value from the arguments ({})", r.out + r.err));
r = Run({ "call", "Player_SetAim", "slot=two" }, instances);
Check(r.code == 2 && Has(r.err, "slot: 'two' is not a number"), "a value that does not fit its field is a usage error");
r = Run({ "call", "Player_SetAm" }, instances);
Check(r.code == 6 && Has(r.err, "did you mean 'Player_SetAim'"), "a mistyped command gets a suggestion");
r = Run({ "get", "hp" }, instances);
Check(r.code == 0 && r.out == "hp = 100\n", "get");
r = Run({ "set", "hp", "5" }, instances);
Check(r.code == 0 && r.out == "hp = 5\n", "set");
r = Run({ "get", "nope" }, instances);
Check(r.code == 6 && Has(r.err, "no watch or sample 'nope'"), "get of an unknown path");
r = Run({ "pause" }, instances);
Check(r.code == 0 && Has(r.out, "paused at frame"), "pause");
r = Run({ "step", "2" }, instances);
Check(r.code == 0 && Has(r.out, "stepped 2 to frame"), "step");
r = Run({ "resume" }, instances);
Check(r.code == 0 && Has(r.out, "running from frame"), "resume");
r = Run({ "wait", "--frames", "3" }, instances);
Check(r.code == 0 && Has(r.out, "frame "), "wait --frames");
r = Run({ "wait", "score >= 1" }, instances);
Check(r.code == 0 && Has(r.out, "'score >= 1' held at frame"), "wait for a condition");
r = Run({ "--timeout", "100ms", "wait", "score < 0" }, instances);
Check(r.code == 5 && Has(r.err, "was not true within"), "a wait that times out exits 5 and says what the paths were");
r = Run({ "log", "--last", "5" }, instances);
Check(r.code == 0 && Has(r.out, "AGENT"), "log shows the agent's actions");
r = Run({ "trace", "hp", "--frames", "3" }, instances);
Check(r.code == 0 && Has(r.out, "changes 0"), "trace");
r = Run({ "screenshot", "-o", "shot.png" }, instances);
const fs::path shot = fs::temp_directory_path() / "crafter-debug-ShouldRunClientCommands" / "shot.png";
Check(r.code == 0 && fs::exists(shot) && fs::file_size(shot) == 4 && Has(r.out, "(4 bytes)"), "screenshot writes the PNG where asked");
r = Run({ "--json", "status" }, instances);
bool json = false;
try {
json = Value::Parse(r.out).Find("frame") != nullptr;
} catch (const ParseError&) {
}
Check(r.code == 0 && json, "--json prints JSON");
r = Run({ "stauts" }, instances);
Check(r.code == 2 && Has(r.err, "did you mean 'status'"), "a mistyped command name gets a suggestion");
r = Run({ "-i", "other", "status" }, instances);
Check(r.code == 3 && Has(r.err, "no running instance named 'other'"), "-i with no match exits 3");
r = Run({ "status" }, {});
Check(r.code == 3 && Has(r.err, "ls --wait"), "no instance exits 3 and says how to start one");
InstanceRecord newer = record;
newer.protocol = protocolVersion + 1;
r = Run({ "status" }, { { newer, {} } });
Check(r.code == 4 && Has(r.err, "the app is newer"), "a newer app's protocol exits 4");
InstanceRecord otherSources = record;
otherSources.sourceId = "0000000000000000";
r = Run({ "status" }, { { otherSources, {} } });
Check(r.code == 0 && Has(r.err, "built from other Crafter.Debug sources"), "other sources only warn");
r = Run({ "ls" }, instances);
Check(r.code == 0 && Has(r.out, "game id abc123"), "ls");
r = Run({ "debugger" }, instances);
Check(r.code == 0 && Has(r.out, "gdb -p 2"), "debugger prints the attach command");
r = Run({ "guide" }, instances);
Check(r.code == 0 && Has(r.out, "# crafter-debug"), "guide");
pumping = false;
app.join();
r = Run({ "--timeout", "100ms", "get", "hp" }, instances);
Check(r.code == 5 && Has(r.err, "has not called Debug::Pump()"), std::format("a timeout explains an app that stopped pumping ({})", r.err));
std::error_code ignored;
fs::remove_all(fs::temp_directory_path() / "crafter-debug-ShouldRunClientCommands", ignored);
return failures == 0 ? 0 : 1;
}