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

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,26 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Spike placeholder for the crafter-debug client: reports where it runs.
// crafter-debug: Cli::Run with the real transport.
import std;
namespace fs = std::filesystem;
import Crafter.Debug.Core;
import Crafter.Debug.Cli;
using namespace Crafter::Debug;
namespace {
// Until the QUIC transport lands: every call reports it unreachable.
class Unbuilt : public Cli::Connection {
public:
std::expected<Value, Cli::CallError> Call(const Value&, std::chrono::milliseconds, const std::function<bool(const Value&)>&) override {
return std::unexpected(Cli::CallError{ Cli::CallError::Kind::Unreachable, "this client was built without its transport" });
}
};
}
int main(int argc, char** argv) {
std::println("crafter-debug spike client at {}", fs::read_symlink("/proc/self/exe").string());
std::println("inside a Flatpak sandbox: {}", fs::exists("/.flatpak-info"));
for (std::int32_t i = 1; i < argc; ++i) std::println("arg: {}", std::string_view(argv[i]));
return 0;
const std::vector<std::string_view> args(argv + 1, argv + argc);
Cli::Environment environment;
environment.connect = [](const InstanceRecord&) -> std::unique_ptr<Cli::Connection> { return std::make_unique<Unbuilt>(); };
environment.haveLldb = [] { return std::filesystem::exists("/app/bin/lldb") || std::filesystem::exists("/usr/bin/lldb"); };
return Cli::Run(args, environment);
}

View file

@ -0,0 +1,72 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// crafter-debug, the client agents drive an app with. Run is the whole
// program but for the transport: main hands it a Connector that reaches an
// instance over QUIC, and tests hand it one that reaches a Session in the
// same process.
//
// Exit codes:
// 0 done
// 2 a usage error, or no debug build yet (the launcher's stub)
// 3 no instance to talk to
// 4 the app and this client speak different protocols
// 5 timed out
// 6 the app answered with an error
// 1 is left to the launcher and `flatpak run` failing.
export module Crafter.Debug.Cli:Client;
import std;
import Crafter.Debug.Core;
export namespace Crafter::Debug::Cli {
inline constexpr std::int32_t exitOk = 0;
inline constexpr std::int32_t exitUsage = 2;
inline constexpr std::int32_t exitNoInstance = 3;
inline constexpr std::int32_t exitVersion = 4;
inline constexpr std::int32_t exitTimeout = 5;
inline constexpr std::int32_t exitError = 6;
struct CallError {
enum class Kind : std::uint8_t {
// No connection: the app is gone, or its port does not answer.
Unreachable,
// Connected, but no reply within the timeout.
TimedOut,
// The reply was not what the protocol says.
Broken,
};
Kind kind = Kind::Unreachable;
std::string message;
};
// One request to an instance. The reply is the last object it sends;
// objects before it are stream lines (`log --follow`), handed to
// `onLine`, which returns false to stop.
class Connection {
public:
virtual ~Connection() = default;
virtual std::expected<Value, CallError> Call(const Value& request, std::chrono::milliseconds timeout,
const std::function<bool(const Value& line)>& onLine) = 0;
};
using Connector = std::function<std::unique_ptr<Connection>(const InstanceRecord& instance)>;
struct Environment {
Connector connect;
std::function<std::vector<Instance>()> list = ListInstances;
std::ostream* out = &std::cout;
std::ostream* err = &std::cerr;
// Where a screenshot without -o goes, relative paths included.
std::filesystem::path cwd = std::filesystem::current_path();
// Whether `lldb` exists here, for the attach command `debugger` prints.
std::function<bool()> haveLldb;
};
std::int32_t Run(std::span<const std::string_view> args, Environment& environment);
// The full reference an agent reads with `crafter-debug guide`.
std::string_view Guide();
// "30s", "500ms", "2m", "1h"; a bare number is seconds.
std::optional<std::chrono::milliseconds> ParseDuration(std::string_view text);
}

View file

@ -0,0 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// The crafter-debug client, as a library so tests can run it in process.
export module Crafter.Debug.Cli;
export import :Client;

View file

@ -181,7 +181,8 @@ export namespace Crafter::Debug {
static Value Describe() {
Value description = Detail::Type("integer");
if constexpr (sizeof(T) < sizeof(std::int64_t)) {
// The range only where it tells an agent something: 8 and 16 bits.
if constexpr (sizeof(T) < sizeof(std::int32_t)) {
description.Set("minimum", Value(std::numeric_limits<T>::min()));
description.Set("maximum", Value(std::numeric_limits<T>::max()));
}

View file

@ -1,11 +1,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Three configurations:
// Four configurations:
// Crafter.Debug.Core what the app and the client share (values, codecs,
// the protocol, discovery, build info)
// Crafter.Debug.Cli the client's code, a library so tests can run it
// crafter.debug-cli the client, crafter-debug
// Crafter.Debug what an app links (returned), depending on both
// Crafter.Debug what an app links (returned), depending on the core
// and the client
// The client is one of the library's dependencies only so that a consumer's
// debug build compiles it in the same pass as the app; an executable adds no
// link flags to what depends on it. Consumers load this file through
@ -135,7 +137,20 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
client.mtune = cfg.mtune;
client.sysroot = cfg.sysroot;
client.debug = cfg.debug;
client.dependencies = { &core };
// The client's code as a library, so tests can run it in process.
Configuration& cli = owned.emplace_back();
cli.path = Root();
cli.name = "Crafter.Debug.Cli";
cli.outputName = "Crafter.Debug.Cli";
cli.type = ConfigurationType::LibraryStatic;
cli.target = cfg.target;
cli.march = cfg.march;
cli.mtune = cfg.mtune;
cli.sysroot = cfg.sysroot;
cli.debug = cfg.debug;
cli.dependencies = { &core };
client.dependencies = { &cli };
{
std::array<fs::path, 8> interfaces = {
@ -157,6 +172,11 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
};
core.GetInterfacesAndImplementations(interfaces, implementations);
}
{
std::array<fs::path, 2> interfaces = { "interfaces/Crafter.Debug.Cli-Client", "interfaces/Crafter.Debug.Cli" };
std::array<fs::path, 1> implementations = { "implementations/Crafter.Debug.Cli-Client" };
cli.GetInterfacesAndImplementations(interfaces, implementations);
}
{
std::array<fs::path, 0> interfaces = {};
std::array<fs::path, 1> implementations = { "implementations/main" };
@ -186,6 +206,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
if (IsTopLevelProject()) {
for (const std::string_view test : coreTests) cfg.AddTest(test).Dependencies({ &core });
for (const std::string_view test : libraryTests) cfg.AddTest(test).Dependencies({ &cfg });
cfg.AddTest("ShouldRunClientCommands").Dependencies({ &cfg, &cli });
}
return cfg;
}

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;
}