Crafter.Debug/implementations/Crafter.Debug.Cli-Client.cpp
Jorijn van der Graaf 34c60dcd34
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>
2026-11-01 02:03:38 +01:00

1023 lines
53 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Crafter.Debug.Cli:Client_impl;
import std;
import Crafter.Debug.Core;
import :Client;
using namespace Crafter::Debug;
using namespace Crafter::Debug::Cli;
namespace fs = std::filesystem;
using namespace std::chrono_literals;
namespace {
constexpr std::string_view usage = R"(usage: crafter-debug [-i <name|id>] [--json] [--timeout <30s>] <command> ...
ls [--wait 60s] running instances
status frame, fps, pause state, failing checks
cmds [glob] the app's commands
help <command|watch> arguments, types and defaults
call <command> [k=v ...] call a command (or --json '{...}')
get <path|glob> ... read watches and samples
set <path> <value> write a watch, or one member of it
pause [--when <cond>] [--on-check] [--trap] [--clear]
resume
step [n]
wait <cond> | --frames <n> | --event <name> | --paused
trace <path> ... [--frames 60] [--history]
log [--last 50] [--level warn] [--cat <glob>] [--grep <text>] [--since-frame <n>] [--follow [--for 30m]]
screenshot [-o <file.png>]
quit [--force]
debugger how to attach lldb or gdb
guide the full reference
)";
constexpr std::array<std::string_view, 18> commandNames = {
"ls", "status", "cmds", "help", "call", "get", "set", "pause", "resume",
"step", "wait", "trace", "log", "screenshot", "quit", "debugger", "guide", "version",
};
struct Options {
std::string instance;
bool json = false;
std::chrono::milliseconds timeout = 30s;
std::string command;
std::vector<std::string> rest;
};
Value Object(std::initializer_list<std::pair<std::string_view, Value>> members) {
Value object = Value::MakeObject();
for (const auto& [key, value] : members) object.Set(key, value);
return object;
}
std::string Short(std::string_view name) {
const std::size_t colon = name.rfind("::");
return std::string(colon == std::string_view::npos ? name : name.substr(colon + 2));
}
std::string FileName(std::string_view path) {
const std::size_t slash = path.rfind('/');
return std::string(slash == std::string_view::npos ? path : path.substr(slash + 1));
}
// A value as an agent reads it in a terminal: arrays cut to 20 items
// with a note saying how many more, deeply; --json shows everything.
Value Truncated(const Value& value) {
if (value.IsArray()) {
Value cut = Value::MakeArray();
const auto items = value.Items();
for (std::size_t i = 0; i < items.size() && i < 20; ++i) cut.Push(Truncated(items[i]));
if (items.size() > 20) cut.Push(Value(std::format("... {} more (--json for all)", items.size() - 20)));
return cut;
}
if (value.IsObject()) {
Value cut = Value::MakeObject();
for (const auto& [key, member] : value.Members()) cut.Set(key, Truncated(member));
return cut;
}
return value;
}
std::string Display(const Value& value) {
if (value.IsString()) return value.AsString();
return Truncated(value).Dump();
}
std::string TypeText(const Value& schema) {
const Value* type = schema.Find("type");
if (type == nullptr || !type->IsString()) return "?";
const std::string& kind = type->AsString();
std::string text;
if (kind == "integer") {
text = "integer";
const Value* low = schema.Find("minimum");
const Value* high = schema.Find("maximum");
if (low != nullptr && high != nullptr) text += std::format(" {}..{}", low->Dump(), high->Dump());
} else if (kind == "enum") {
for (const Value& name : schema.Find("values")->Items()) text += text.empty() ? name.AsString() : "|" + name.AsString();
} else if (kind == "array") {
text = std::format("array of {}", schema.Find("items") ? TypeText(*schema.Find("items")) : "?");
if (const Value* length = schema.Find("length")) text += std::format(" [{}]", length->Dump());
} else if (kind == "object") {
if (const Value* name = schema.Find("name")) {
text = Short(name->AsString());
} else if (const Value* values = schema.Find("values")) {
text = std::format("map of {}", TypeText(*values));
} else {
text = "object";
}
} else if (kind == "any") {
text = "json";
} else {
text = kind;
}
if (schema.Find("nullable") != nullptr) text += "?";
return text;
}
// "slot: integer 0..255 (required), yaw: number = 10"
std::string ArgsText(const Value& args) {
const Value* fields = args.Find("fields");
if (fields == nullptr) return args.IsNull() ? std::string() : TypeText(args);
std::string text;
for (const Value& field : fields->Items()) {
if (!text.empty()) text += ", ";
text += std::format("{}: {}", field.Find("name")->AsString(), TypeText(*field.Find("type")));
if (field.Find("required") != nullptr) {
text += " (required)";
} else if (const Value* fallback = field.Find("default")) {
text += std::format(" = {}", fallback->Dump());
}
}
return text;
}
// A k=v value typed by the field's schema: numbers and booleans parsed,
// strings and enum names taken as written, anything else read as JSON.
std::expected<Value, std::string> Coerce(std::string_view raw, const Value* schema) {
const auto json = [&]() -> std::optional<Value> {
try {
return Value::Parse(raw);
} catch (const ParseError&) {
return std::nullopt;
}
};
const Value* type = schema != nullptr ? schema->Find("type") : nullptr;
const std::string kind = type != nullptr && type->IsString() ? type->AsString() : std::string();
if (kind == "string" || kind == "enum") return Value(raw);
if (kind == "boolean") {
if (raw == "true" || raw == "1" || raw == "yes") return Value(true);
if (raw == "false" || raw == "0" || raw == "no") return Value(false);
return std::unexpected(std::format("'{}' is not a boolean (true or false)", raw));
}
if (kind == "integer" || kind == "number") {
const auto parsed = json();
if (parsed && parsed->IsNumber()) return *parsed;
return std::unexpected(std::format("'{}' is not a number", raw));
}
if (raw == "null" && schema != nullptr && schema->Find("nullable") != nullptr) return Value();
if (const auto parsed = json()) return *parsed;
if (kind == "array" || kind == "object") return std::unexpected(std::format("'{}' is not JSON; an {} is written as JSON", raw, kind));
return Value(raw);
}
const Value* FieldSchema(const Value* objectSchema, std::string_view name) {
if (objectSchema == nullptr) return nullptr;
const Value* fields = objectSchema->Find("fields");
if (fields == nullptr) return nullptr;
for (const Value& field : fields->Items()) {
if (field.Find("name")->AsString() == name) return field.Find("type");
}
return nullptr;
}
// key=value pairs into an object; dotted keys nest ("at.x=1").
std::expected<Value, std::string> BuildParams(std::span<const std::string> pairs, const Value* schema) {
Value params = Value::MakeObject();
for (const std::string& pair : pairs) {
const std::size_t equals = pair.find('=');
if (equals == std::string::npos || equals == 0) return std::unexpected(std::format("'{}' is not key=value; values are passed as key=value, or all at once with --json '{{...}}'", pair));
const std::string_view key(pair.data(), equals);
const std::string_view raw = std::string_view(pair).substr(equals + 1);
Value* target = &params;
const Value* targetSchema = schema;
std::string_view rest = key;
while (true) {
const std::size_t dot = rest.find('.');
const std::string_view name = rest.substr(0, dot);
const Value* fieldSchema = FieldSchema(targetSchema, name);
if (dot == std::string_view::npos) {
auto value = Coerce(raw, fieldSchema);
if (!value) return std::unexpected(std::format("{}: {}", key, value.error()));
target->Set(name, std::move(*value));
break;
}
Value* nested = target->Find(name);
if (nested == nullptr) nested = &target->Set(name, Value::MakeObject());
target = nested;
targetSchema = fieldSchema;
rest = rest.substr(dot + 1);
}
}
return params;
}
// A value given on the command line: JSON when it parses, else a string.
Value Literal(std::string_view raw) {
try {
return Value::Parse(raw);
} catch (const ParseError&) {
return Value(raw);
}
}
std::string Age(std::int64_t startedAt) {
const std::int64_t now = std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
const std::int64_t seconds = std::max<std::int64_t>(0, now - startedAt);
if (seconds < 120) return std::format("{}s", seconds);
if (seconds < 7200) return std::format("{}m", seconds / 60);
return std::format("{}h", seconds / 3600);
}
std::optional<std::uint64_t> Number(std::string_view text) {
std::uint64_t number = 0;
const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), number);
if (error != std::errc() || end != text.data() + text.size()) return std::nullopt;
return number;
}
class Client {
public:
Client(Environment& environment, Options options) : env(environment), opts(std::move(options)) {}
std::int32_t Execute() {
const std::string& c = opts.command;
if (c == "guide") {
Out() << Guide();
return exitOk;
}
if (c == "version") {
Out() << std::format("crafter-debug {}, protocol {}, sources {}\n", libraryVersion, protocolVersion, Detail::SourceId());
return exitOk;
}
if (c == "ls") return Ls();
if (const std::int32_t code = Select(); code != exitOk) return code;
if (c == "debugger") return Debugger();
if (c == "status") return Status();
if (c == "cmds") return Cmds();
if (c == "help") return Help();
if (c == "call") return Call();
if (c == "get") return Get();
if (c == "set") return Set();
if (c == "pause") return Pause();
if (c == "resume") return Resume();
if (c == "step") return Step();
if (c == "wait") return Wait();
if (c == "trace") return Trace();
if (c == "log") return Log();
if (c == "screenshot") return Screenshot();
if (c == "quit") return Quit();
return exitUsage;
}
private:
Environment& env;
Options opts;
std::optional<Instance> instance;
std::unique_ptr<Connection> connection;
std::ostream& Out() { return *env.out; }
std::ostream& Err() { return *env.err; }
std::int32_t Usage(std::string_view problem) {
Err() << std::format("crafter-debug {}: {}\n", opts.command, problem);
return exitUsage;
}
// ---- choosing the instance ----
std::int32_t Select() {
std::vector<Instance> live = env.list();
if (!opts.instance.empty()) {
std::erase_if(live, [&](const Instance& candidate) {
return candidate.record.name != opts.instance && !candidate.record.instanceId.starts_with(opts.instance);
});
}
if (live.empty()) {
if (!opts.instance.empty()) {
std::string running;
for (const Instance& other : env.list()) running += std::format("{}{} ({})", running.empty() ? "" : ", ", other.record.name, other.record.instanceId);
Err() << std::format("crafter-debug: no running instance named '{}'{}\n", opts.instance, running.empty() ? std::string() : std::format("; running: {}", running));
} else {
Err() << "crafter-debug: no running instance. Start the app from its debug build in the background (e.g. crafter-build --debug-api -r), then: crafter-debug ls --wait 60s\n";
}
return exitNoInstance;
}
if (live.size() > 1) {
Err() << "crafter-debug: several instances run; pick one with -i <name|id>:\n";
for (const Instance& candidate : live) Err() << std::format(" {} id {} up {}\n", candidate.record.name, candidate.record.instanceId, Age(candidate.record.startedAt));
return exitUsage;
}
instance = live.front();
const InstanceRecord& record = instance->record;
if (record.protocol != protocolVersion) {
const bool appNewer = record.protocol > protocolVersion;
Err() << std::format("crafter-debug: {} speaks protocol {} and this client {}: the {} is newer.\n", record.name, record.protocol, protocolVersion, appNewer ? "app" : "client");
if (appNewer) {
Err() << " This launcher points at an older build. Rebuild with the project's debug flag, or run the app's own client:\n";
} else {
Err() << " Restart the app from the current build (crafter-build <debug flag> -r), or talk to it with its own client:\n";
}
Err() << std::format(" flatpak run --die-with-parent --command={} net.catcrafts.CrafterBuild {}\n", record.client, opts.command);
return exitVersion;
}
if (!record.sourceId.empty() && record.sourceId != Detail::SourceId()) {
Err() << std::format("note: {} was built from other Crafter.Debug sources ({} here, {} there); restart it from the current build if something misbehaves\n",
record.name, Detail::SourceId(), record.sourceId);
}
connection = env.connect(record);
return exitOk;
}
// ---- talking to it ----
// The result, or the exit code once the problem has been reported.
std::expected<Value, std::int32_t> Request(std::string_view method, Value params, std::chrono::milliseconds timeout,
const std::function<bool(const Value& line)>& onLine = {}) {
Value request = Object({
{ "token", Value(instance->record.token) },
{ "method", Value(method) },
{ "params", std::move(params) },
{ "timeoutMs", Value(static_cast<std::int64_t>(timeout.count())) },
});
if (!connection) return std::unexpected(Unreachable("no connection"));
auto reply = connection->Call(request, timeout + 2s, onLine);
if (!reply) {
if (reply.error().kind == CallError::Kind::TimedOut) return std::unexpected(TimedOut(method, timeout));
if (reply.error().kind == CallError::Kind::Broken) {
Err() << std::format("crafter-debug: a malformed reply: {}\n", reply.error().message);
return std::unexpected(exitError);
}
return std::unexpected(Unreachable(reply.error().message));
}
if (const Value* error = reply->Find("error")) {
const std::string code = error->Find("code") ? error->Find("code")->AsString() : std::string("Error");
const std::string message = error->Find("message") ? error->Find("message")->AsString() : std::string();
if (opts.json) {
Err() << reply->Dump() << '\n';
} else {
Err() << std::format("error {}: {}\n", code, message);
}
return std::unexpected(code == Codes::timeout ? exitTimeout : exitError);
}
const Value* result = reply->Find("result");
return result != nullptr ? *result : Value();
}
std::int32_t Unreachable(std::string_view why) {
const bool alive = std::ranges::any_of(env.list(), [&](const Instance& candidate) { return candidate.record.instanceId == instance->record.instanceId; });
if (!alive) {
Err() << std::format("crafter-debug: {} has exited\n", instance->record.name);
} else {
Err() << std::format("crafter-debug: cannot reach {} on port {}: {}\n", instance->record.name, instance->record.port, why);
}
return exitNoInstance;
}
// A request that got no reply. The app may be gone, stopped in a
// debugger, or no longer calling Pump; a short status call tells
// which, and what to do next.
std::int32_t TimedOut(std::string_view method, std::chrono::milliseconds timeout) {
const bool alive = std::ranges::any_of(env.list(), [&](const Instance& candidate) { return candidate.record.instanceId == instance->record.instanceId; });
if (!alive) {
Err() << std::format("crafter-debug: {} exited while {} waited\n", instance->record.name, method);
return exitNoInstance;
}
const auto probe = env.connect(instance->record);
const Value status = Object({ { "token", Value(instance->record.token) }, { "method", Value("debug.status") }, { "timeoutMs", Value(1000) } });
const auto reply = probe ? probe->Call(status, 2s, {}) : std::unexpected(CallError{});
Err() << std::format("crafter-debug: {} got no reply within {} ms. ", method, timeout.count());
if (!reply || reply->Find("result") == nullptr) {
Err() << std::format("{} is alive but not answering at all: stopped in a debugger, or hung.\n", instance->record.name);
PrintAttach(true);
return exitTimeout;
}
const Value* last = reply->Find("result")->Find("lastPumpAgoMs");
if (last != nullptr && last->IsInteger() && last->AsInteger() > 1000) {
Err() << std::format("{} answers, but has not called Debug::Pump() for {} ms: its main loop is stalled, or its window hidden "
"(set Window::updateInterval to keep it pumping).\n",
instance->record.name, last->AsInteger());
} else {
Err() << "The app is pumping; the request took longer than its timeout (raise it with --timeout).\n";
}
return exitTimeout;
}
void PrintAttach(bool toErr) {
std::ostream& stream = toErr ? Err() : Out();
const InstanceRecord& record = instance->record;
const std::string debugger = env.haveLldb && env.haveLldb() ? "lldb" : "gdb";
if (!record.flatpakInstance.empty()) {
stream << std::format(" attach (on the host): flatpak enter {} {} -p {}\n", record.flatpakInstance, debugger, record.pid);
stream << std::format(" check whether a debugger holds it: flatpak enter {} grep State /proc/{}/status (t = stopped by a tracer)\n", record.flatpakInstance, record.pid);
if (record.hostInitPid > 0) stream << std::format(" end it, sandbox and all (on the host): kill -KILL {}\n", record.hostInitPid);
} else {
stream << std::format(" attach: {} -p {}\n", debugger, record.pid);
stream << std::format(" check whether a debugger holds it: grep State /proc/{}/status (t = stopped by a tracer)\n", record.pid);
}
}
std::int32_t Print(const Value& result) {
if (opts.json) {
Out() << result.Dump() << '\n';
return exitOk;
}
Out() << (result.IsNull() ? std::string("ok") : Display(result)) << '\n';
return exitOk;
}
// ---- commands ----
std::int32_t Ls() {
std::optional<std::chrono::milliseconds> wait;
for (std::size_t i = 0; i < opts.rest.size(); ++i) {
if (opts.rest[i] == "--wait" && i + 1 < opts.rest.size()) {
wait = ParseDuration(opts.rest[++i]);
if (!wait) return Usage(std::format("'{}' is not a duration (30s, 500ms, 2m)", opts.rest[i]));
} else {
return Usage(std::format("unknown argument '{}'", opts.rest[i]));
}
}
const auto deadline = std::chrono::steady_clock::now() + wait.value_or(0ms);
std::vector<Instance> live = env.list();
while (live.empty() && wait && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(200ms);
live = env.list();
}
if (opts.json) {
Value list = Value::MakeArray();
for (const Instance& entry : live) {
Value record = Encode(entry.record);
record.Erase("token");
list.Push(std::move(record));
}
Out() << list.Dump() << '\n';
return live.empty() ? exitNoInstance : exitOk;
}
if (live.empty()) {
Err() << "no running instances. Start the app from its debug build in the background (e.g. crafter-build --debug-api -r)"
<< (wait ? ", and wait longer if it is still building\n" : ", then: crafter-debug ls --wait 60s\n");
return exitNoInstance;
}
for (const Instance& entry : live) {
const InstanceRecord& r = entry.record;
Out() << std::format("{} id {} pid {}{} up {} port {}{}\n", r.name, r.instanceId, r.pid,
r.hostInitPid > 0 ? std::format(" (sandbox {})", r.hostInitPid) : std::string(), Age(r.startedAt), r.port,
r.protocol != protocolVersion ? std::format(" protocol {} (this client {})", r.protocol, protocolVersion) : std::string());
}
return exitOk;
}
std::int32_t Debugger() {
Out() << std::format("{}: the app stops while a debugger holds it, and crafter-debug calls then report it as not answering.\n", instance->record.name);
PrintAttach(false);
return exitOk;
}
std::int32_t Status() {
if (!opts.rest.empty()) return Usage("takes no arguments");
const auto result = Request("debug.status", Value(), opts.timeout);
if (!result) return result.error();
if (opts.json) return Print(*result);
const Value& s = *result;
const bool paused = s.Find("paused")->AsBool();
const std::string reason = s.Find("pauseReason") ? s.Find("pauseReason")->AsString() : std::string();
Out() << std::format("{} frame {} {:.1f} fps {}\n", s.Find("name")->AsString(), s.Find("frame")->Dump(), s.Find("fps")->AsNumber(),
paused ? std::format("PAUSED ({})", reason) : std::string("running"));
std::string armed;
if (const Value* when = s.Find("armed")->Find("when")) armed += std::format(" pause armed: when '{}'", when->AsString());
if (s.Find("armed")->Find("onCheck") != nullptr) armed += " pause armed: on the next failing check";
Out() << std::format("pause: {}{}\n", s.Find("pauseSupported")->AsBool() ? "supported" : "unsupported (the app never calls Debug::Advance or Debug::Paused)", armed);
const std::int64_t failures = s.Find("checkFailures")->AsInteger();
if (failures > 0) {
std::string sites;
for (const Value& site : s.Find("checkSites")->Items()) {
sites += std::format("{}{}:{} x{} ({})", sites.empty() ? "" : ", ", site.Find("file")->AsString(), site.Find("line")->Dump(), site.Find("failures")->Dump(), site.Find("category")->AsString());
}
Out() << std::format("checks: {} failures: {}\n", failures, sites);
}
Out() << std::format("{} commands, {} watches; requests: {} queued, {} waiting, {} following\n", s.Find("commands")->Dump(), s.Find("watches")->Dump(),
s.Find("queued")->Dump(), s.Find("waiting")->Dump(), s.Find("following")->Dump());
const Value* last = s.Find("lastPumpAgoMs");
Out() << (last == nullptr || last->IsNull() ? std::string("Pump: never called: the app does not call Debug::Pump(), so calls, gets and waits will not run\n")
: std::format("last Pump {} ms ago\n", last->Dump()));
return exitOk;
}
std::int32_t Cmds() {
if (opts.rest.size() > 1) return Usage("takes at most one glob");
const std::string glob = opts.rest.empty() ? "*" : opts.rest[0];
const auto result = Request("debug.list", Object({ { "glob", Value(glob) } }), opts.timeout);
if (!result) return result.error();
if (opts.json) return Print(*result->Find("commands"));
const auto commands = result->Find("commands")->Items();
if (commands.empty()) {
Out() << std::format("no commands match '{}'\n", glob);
return exitOk;
}
for (const Value& command : commands) {
const std::string returns = command.Find("returns")->IsNull() ? std::string() : std::format(" -> {}", TypeText(*command.Find("returns")));
const std::string description = command.Find("description")->AsString();
Out() << std::format("{}({}){}{}\n", command.Find("name")->AsString(), ArgsText(*command.Find("args")), returns,
description.empty() ? std::string() : std::format(" -- {}", description));
}
return exitOk;
}
std::int32_t Help() {
if (opts.rest.size() != 1) return Usage("names one command or watch: crafter-debug help <name>");
const std::string& name = opts.rest[0];
const auto result = Request("debug.list", Object({ { "glob", Value("*") } }), opts.timeout);
if (!result) return result.error();
for (const Value& command : result->Find("commands")->Items()) {
if (command.Find("name")->AsString() != name) continue;
if (opts.json) return Print(command);
Out() << std::format("{} -- {}\n", name, command.Find("description")->AsString());
const Value& args = *command.Find("args");
if (const Value* fields = args.Find("fields")) {
for (const Value& field : fields->Items()) {
const std::string detail = field.Find("required") != nullptr ? "required" : std::format("default {}", field.Find("default")->Dump());
Out() << std::format(" {:<16} {:<24} {}\n", field.Find("name")->AsString(), TypeText(*field.Find("type")), detail);
}
} else if (!args.IsNull()) {
Out() << std::format(" takes {}\n", TypeText(args));
} else {
Out() << " takes no arguments\n";
}
if (!command.Find("returns")->IsNull()) Out() << std::format("returns {}\n", TypeText(*command.Find("returns")));
Out() << std::format("call: crafter-debug call {}{}\n", name, args.Find("fields") ? " key=value ..." : "");
return exitOk;
}
for (const Value& watch : result->Find("watches")->Items()) {
if (watch.Find("path")->AsString() != name) continue;
if (opts.json) return Print(watch);
Out() << std::format("{} {}{} {}\n", name, TypeText(*watch.Find("type")), watch.Find("readOnly")->AsBool() ? " read-only" : "", watch.Find("help")->AsString());
return exitOk;
}
std::vector<std::string> names;
for (const Value& command : result->Find("commands")->Items()) names.push_back(command.Find("name")->AsString());
for (const Value& watch : result->Find("watches")->Items()) names.push_back(watch.Find("path")->AsString());
std::vector<std::string_view> views(names.begin(), names.end());
const auto suggestion = Suggest(name, views);
Err() << std::format("crafter-debug: no command or watch '{}'{}\n", name, suggestion ? std::format("; did you mean '{}'?", *suggestion) : std::string("; crafter-debug cmds lists them"));
return exitError;
}
std::int32_t Call() {
if (opts.rest.empty()) return Usage("names a command: crafter-debug call <command> [key=value ...]");
const std::string command = opts.rest[0];
std::vector<std::string> pairs;
std::optional<Value> json;
for (std::size_t i = 1; i < opts.rest.size(); ++i) {
if (opts.rest[i] == "--json" && i + 1 < opts.rest.size()) {
try {
json = Value::Parse(opts.rest[++i]);
} catch (const ParseError& error) {
return Usage(std::format("--json: {}", error.what()));
}
} else {
pairs.push_back(opts.rest[i]);
}
}
Value params = json.value_or(Value());
if (!pairs.empty()) {
// The argument schema types each value: slot=1 is a number,
// name=1 a string, if that is what the fields are.
const auto listed = Request("debug.list", Object({ { "glob", Value(command) } }), opts.timeout);
if (!listed) return listed.error();
const Value* schema = nullptr;
for (const Value& entry : listed->Find("commands")->Items()) {
if (entry.Find("name")->AsString() == command) schema = entry.Find("args");
}
auto built = BuildParams(pairs, schema);
if (!built) return Usage(built.error());
if (json && json->IsObject()) {
for (const auto& [key, value] : built->Members()) json->Set(key, value);
params = *json;
} else {
params = std::move(*built);
}
}
const auto result = Request(command, std::move(params), opts.timeout);
if (!result) return result.error();
return Print(*result);
}
std::int32_t Get() {
if (opts.rest.empty()) return Usage("names one or more paths or globs: crafter-debug get 'fort1.*'");
Value paths = Value::MakeArray();
for (const std::string& path : opts.rest) paths.Push(Value(path));
const auto result = Request("debug.get", Object({ { "paths", std::move(paths) } }), opts.timeout);
if (!result) return result.error();
if (opts.json) return Print(*result);
for (const auto& [path, value] : result->Find("values")->Members()) Out() << std::format("{} = {}\n", path, Display(value));
if (const Value* errors = result->Find("errors")) {
for (const auto& [path, error] : errors->Members()) Err() << std::format("{}: {}\n", path, error.AsString());
}
return exitOk;
}
std::int32_t Set() {
if (opts.rest.size() != 2) return Usage("takes a path and a value: crafter-debug set physics.gravity -9.81");
const auto result = Request("debug.set", Object({ { "path", Value(opts.rest[0]) }, { "value", Literal(opts.rest[1]) } }), opts.timeout);
if (!result) return result.error();
if (opts.json) return Print(*result);
Out() << std::format("{} = {}\n", result->Find("path")->AsString(), Display(*result->Find("value")));
return exitOk;
}
std::int32_t Pause() {
Value params = Value::MakeObject();
for (std::size_t i = 0; i < opts.rest.size(); ++i) {
const std::string& arg = opts.rest[i];
if (arg == "--when" && i + 1 < opts.rest.size()) {
params.Set("when", Value(opts.rest[++i]));
} else if (arg == "--on-check") {
params.Set("onCheck", Value(true));
} else if (arg == "--trap") {
params.Set("trap", Value(true));
} else if (arg == "--clear") {
params.Set("clear", Value(true));
} else {
return Usage(std::format("unknown argument '{}'", arg));
}
}
const auto result = Request("debug.pause", std::move(params), opts.timeout);
if (!result) return result.error();
if (opts.json) return Print(*result);
if (const Value* armed = result->Find("armed")) {
if (armed->IsBool()) {
Out() << "disarmed\n";
} else if (armed->AsString() == "when") {
Out() << std::format("armed: pause when '{}'; then: crafter-debug wait --paused\n", result->Find("condition")->AsString());
} else {
Out() << "armed: pause on the next failing Debug::Check; then: crafter-debug wait --paused\n";
}
return exitOk;
}
Out() << std::format("paused at frame {}\n", result->Find("frame")->Dump());
return exitOk;
}
std::int32_t Resume() {
if (!opts.rest.empty()) return Usage("takes no arguments");
const auto result = Request("debug.resume", Value(), opts.timeout);
if (!result) return result.error();
if (opts.json) return Print(*result);
Out() << std::format("running from frame {}\n", result->Find("frame")->Dump());
return exitOk;
}
std::int32_t Step() {
std::uint64_t count = 1;
if (opts.rest.size() > 1) return Usage("takes at most a count: crafter-debug step 3");
if (opts.rest.size() == 1) {
const auto parsed = Number(opts.rest[0]);
if (!parsed || *parsed == 0) return Usage(std::format("'{}' is not a positive count", opts.rest[0]));
count = *parsed;
}
const auto result = Request("debug.step", Object({ { "count", Value(count) } }), opts.timeout);
if (!result) return result.error();
if (opts.json) return Print(*result);
Out() << std::format("stepped {} to frame {}, paused\n", count, result->Find("frame")->Dump());
return exitOk;
}
std::int32_t Wait() {
Value params = Value::MakeObject();
for (std::size_t i = 0; i < opts.rest.size(); ++i) {
const std::string& arg = opts.rest[i];
if (arg == "--frames" && i + 1 < opts.rest.size()) {
const auto frames = Number(opts.rest[++i]);
if (!frames) return Usage(std::format("'{}' is not a number of frames", opts.rest[i]));
params.Set("frames", Value(*frames));
} else if (arg == "--event" && i + 1 < opts.rest.size()) {
params.Set("event", Value(opts.rest[++i]));
} else if (arg == "--paused") {
params.Set("paused", Value(true));
} else if (!arg.starts_with("--") && params.Find("condition") == nullptr) {
params.Set("condition", Value(arg));
} else {
return Usage(std::format("unknown argument '{}'", arg));
}
}
if (params.Size() == 0) return Usage("waits for something: a condition, --frames <n>, --event <name> or --paused");
const auto result = Request("debug.wait", std::move(params), opts.timeout);
if (!result) return result.error();
if (opts.json) return Print(*result);
const Value& r = *result;
if (const Value* condition = r.Find("condition")) {
Out() << std::format("'{}' held at frame {}\n", condition->AsString(), r.Find("frame")->Dump());
} else if (const Value* event = r.Find("event")) {
Out() << std::format("{} at frame {}: {}\n", event->AsString(), r.Find("frame")->Dump(), Display(*r.Find("payload")));
} else if (const Value* reason = r.Find("reason")) {
Out() << std::format("paused at frame {}: {}\n", r.Find("frame")->Dump(), reason->AsString());
} else {
Out() << std::format("frame {}\n", r.Find("frame")->Dump());
}
return exitOk;
}
std::int32_t Trace() {
Value paths = Value::MakeArray();
Value params = Value::MakeObject();
for (std::size_t i = 0; i < opts.rest.size(); ++i) {
const std::string& arg = opts.rest[i];
if (arg == "--frames" && i + 1 < opts.rest.size()) {
const auto frames = Number(opts.rest[++i]);
if (!frames || *frames == 0) return Usage(std::format("'{}' is not a positive number of frames", opts.rest[i]));
params.Set("frames", Value(*frames));
} else if (arg == "--history") {
params.Set("history", Value(true));
} else if (arg.starts_with("--")) {
return Usage(std::format("unknown argument '{}'", arg));
} else {
paths.Push(Value(arg));
}
}
if (paths.Size() == 0) return Usage("names one or more paths: crafter-debug trace score --frames 120");
params.Set("paths", std::move(paths));
const auto result = Request("debug.trace", std::move(params), opts.timeout);
if (!result) return result.error();
if (opts.json) return Print(*result);
const Value* frames = result->Find("frames");
for (const auto& [path, series] : result->Find("series")->Members()) {
const Value& framesOf = series.Find("frames") ? *series.Find("frames") : *frames;
const auto values = series.Find("values")->Items();
Out() << std::format("{} {} values, frames {}..{}\n", path, values.size(), framesOf.Items().front().Dump(), framesOf.Items().back().Dump());
Out() << std::format(" {}\n", Display(*series.Find("values")));
const Value& stats = *series.Find("stats");
std::string line;
for (const std::string_view key : { "min", "max", "mean" }) {
if (const Value* number = stats.Find(key)) line += std::format("{} {} ", key, number->Dump());
}
line += std::format("changes {}", stats.Find("changes")->Dump());
if (const Value* first = stats.Find("firstChangeFrame")) line += std::format(" (first at frame {})", first->Dump());
Out() << std::format(" {}\n", line);
}
if (result->Find("truncated") != nullptr) Out() << "(cut short by the timeout; raise --timeout for more frames)\n";
return exitOk;
}
void PrintEntry(const Value& entry) {
if (opts.json) {
Out() << entry.Dump() << '\n';
return;
}
std::string kind = entry.Find("kind")->AsString();
std::ranges::transform(kind, kind.begin(), [](char c) { return static_cast<char>(c >= 'a' && c <= 'z' ? c - 32 : c); });
std::string text = entry.Find("message")->AsString();
if (const Value* payload = entry.Find("payload")) text = Display(*payload);
if (const Value* count = entry.Find("count"); count != nullptr && count->AsInteger() > 1) text += std::format(" (x{})", count->Dump());
const std::string where = entry.Find("kind")->AsString() == "agent" ? std::string() : std::format(" {}:{}", FileName(entry.Find("file")->AsString()), entry.Find("line")->Dump());
Out() << std::format("{:>7} {:<6} {:<10} {}{}\n", entry.Find("frame")->Dump(), kind, entry.Find("category")->AsString(), text, where) << std::flush;
}
std::int32_t Log() {
Value params = Value::MakeObject();
std::chrono::milliseconds timeout = opts.timeout;
bool follow = false;
for (std::size_t i = 0; i < opts.rest.size(); ++i) {
const std::string& arg = opts.rest[i];
const bool hasValue = i + 1 < opts.rest.size();
if (arg == "--last" && hasValue) {
const auto last = Number(opts.rest[++i]);
if (!last) return Usage(std::format("'{}' is not a number", opts.rest[i]));
params.Set("last", Value(*last));
} else if (arg == "--level" && hasValue) {
params.Set("level", Value(opts.rest[++i]));
} else if (arg == "--cat" && hasValue) {
params.Set("category", Value(opts.rest[++i]));
} else if (arg == "--grep" && hasValue) {
params.Set("grep", Value(opts.rest[++i]));
} else if (arg == "--since-frame" && hasValue) {
const auto frame = Number(opts.rest[++i]);
if (!frame) return Usage(std::format("'{}' is not a frame number", opts.rest[i]));
params.Set("sinceFrame", Value(*frame));
} else if (arg == "--kinds" && hasValue) {
Value kinds = Value::MakeArray();
for (const auto part : std::views::split(std::string_view(opts.rest[++i]), ',')) kinds.Push(Value(std::string_view(std::ranges::data(part), std::ranges::size(part))));
params.Set("kinds", std::move(kinds));
} else if (arg == "--follow") {
follow = true;
params.Set("follow", Value(true));
} else if (arg == "--for" && hasValue) {
const auto duration = ParseDuration(opts.rest[++i]);
if (!duration) return Usage(std::format("'{}' is not a duration", opts.rest[i]));
params.Set("forMs", Value(static_cast<std::int64_t>(duration->count())));
} else {
return Usage(std::format("unknown argument '{}'", arg));
}
}
if (follow) {
const Value* forMs = params.Find("forMs");
timeout = std::chrono::milliseconds(forMs != nullptr ? forMs->AsInteger() : 30 * 60 * 1000) + 5s;
const auto result = Request("debug.log", std::move(params), timeout, [&](const Value& line) {
if (const Value* entry = line.Find("entry")) PrintEntry(*entry);
return true;
});
if (!result) return result.error();
if (!opts.json) Err() << "(follow ended)\n";
return exitOk;
}
const auto result = Request("debug.log", std::move(params), timeout);
if (!result) return result.error();
for (const Value& entry : result->Find("entries")->Items()) PrintEntry(entry);
return exitOk;
}
std::int32_t Screenshot() {
fs::path file;
for (std::size_t i = 0; i < opts.rest.size(); ++i) {
if ((opts.rest[i] == "-o" || opts.rest[i] == "--output") && i + 1 < opts.rest.size()) {
file = opts.rest[++i];
} else {
return Usage(std::format("unknown argument '{}'", opts.rest[i]));
}
}
const auto result = Request("debug.screenshot", Value(), opts.timeout);
if (!result) return result.error();
const auto png = Base64Decode(result->Find("png")->AsString());
if (!png) {
Err() << "crafter-debug: the screenshot was not valid base64\n";
return exitError;
}
if (file.empty()) {
const std::int64_t now = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
file = fs::path("build") / "crafter-debug-shots" / std::format("{}-{}.png", instance->record.name, now);
}
if (file.is_relative()) file = env.cwd / file;
std::error_code ec;
fs::create_directories(file.parent_path(), ec);
std::ofstream out(file, std::ios::binary | std::ios::trunc);
out.write(reinterpret_cast<const char*>(png->data()), static_cast<std::streamsize>(png->size()));
if (!out) {
Err() << std::format("crafter-debug: could not write {}\n", file.string());
return exitError;
}
if (opts.json) return Print(Object({ { "file", Value(file.string()) }, { "bytes", Value(png->size()) } }));
Out() << std::format("{} ({} bytes)\n", file.string(), png->size());
return exitOk;
}
std::int32_t Quit() {
bool force = false;
for (const std::string& arg : opts.rest) {
if (arg == "--force") {
force = true;
} else {
return Usage(std::format("unknown argument '{}'", arg));
}
}
const auto result = Request("debug.quit", Object({ { "force", Value(force) } }), opts.timeout);
if (!result) return result.error();
if (opts.json) return Print(*result);
if (force) {
Out() << std::format("ended {}\n", instance->record.name);
} else {
Out() << std::format("asked {} to quit\n", instance->record.name);
}
return exitOk;
}
};
std::variant<Options, std::int32_t> Parse(std::span<const std::string_view> args, Environment& env) {
Options options;
for (std::size_t i = 0; i < args.size(); ++i) {
const std::string_view arg = args[i];
if (!options.command.empty()) {
// Global flags may follow the command too, before its own.
if (arg == "--json") {
options.json = true;
continue;
}
options.rest.emplace_back(arg);
continue;
}
if ((arg == "-i" || arg == "--instance") && i + 1 < args.size()) {
options.instance = args[++i];
} else if (arg == "--json") {
options.json = true;
} else if (arg == "--timeout" && i + 1 < args.size()) {
const auto timeout = ParseDuration(args[++i]);
if (!timeout) {
*env.err << std::format("crafter-debug: '{}' is not a duration (30s, 500ms, 2m)\n", args[i]);
return exitUsage;
}
options.timeout = *timeout;
} else if (arg == "-h" || arg == "--help") {
*env.out << usage;
return exitOk;
} else if (arg.starts_with("-")) {
*env.err << std::format("crafter-debug: unknown option '{}'\n{}", arg, usage);
return exitUsage;
} else {
options.command = std::string(arg);
}
}
if (options.command.empty()) {
*env.err << usage;
return exitUsage;
}
if (std::ranges::find(commandNames, options.command) == commandNames.end()) {
const auto suggestion = Suggest(options.command, commandNames);
*env.err << std::format("crafter-debug: no command '{}'{}\n{}", options.command,
suggestion ? std::format("; did you mean '{}'?", *suggestion) : std::string(), usage);
return exitUsage;
}
return options;
}
}
std::optional<std::chrono::milliseconds> Crafter::Debug::Cli::ParseDuration(std::string_view text) {
std::size_t digits = 0;
while (digits < text.size() && text[digits] >= '0' && text[digits] <= '9') ++digits;
if (digits == 0) return std::nullopt;
std::int64_t number = 0;
std::from_chars(text.data(), text.data() + digits, number);
const std::string_view unit = text.substr(digits);
if (unit.empty() || unit == "s") return std::chrono::seconds(number);
if (unit == "ms") return std::chrono::milliseconds(number);
if (unit == "m") return std::chrono::minutes(number);
if (unit == "h") return std::chrono::hours(number);
return std::nullopt;
}
std::int32_t Crafter::Debug::Cli::Run(std::span<const std::string_view> args, Environment& environment) {
auto parsed = Parse(args, environment);
if (const std::int32_t* code = std::get_if<std::int32_t>(&parsed)) return *code;
Client client(environment, std::get<Options>(std::move(parsed)));
return client.Execute();
}
std::string_view Crafter::Debug::Cli::Guide() {
return R"(# crafter-debug: driving a running app
crafter-debug talks to an app built with Crafter.Debug while it runs: call its
commands, read and set its state, pause and step it, wait for conditions,
trace values over frames, read its log, take screenshots. Breakpoints and
memory are a debugger's job; `crafter-debug debugger` prints how to attach one.
## The loop
1. Build and start the app from its debug build, in the background (it does
not exit): `crafter-build --debug-api -r` (use the project's own flag).
2. `./build/crafter-debug ls --wait 60s` until it is listed.
3. `cmds` and `help <command>` to see what it offers, `get '*'` for its state.
4. Act and observe: `call`, `set`, `pause --when`, `wait`, `step`, `trace`,
`log --level warn`, `screenshot` (open the PNG it prints with Read).
5. `quit` when done. Killing the background task may leave the app running
if crafter-build's alias lacks --die-with-parent; `ls` shows leftovers.
## Commands
ls [--wait 60s]
status
cmds [glob] name(args = defaults) -> result -- description
help <command|watch>
call <command> [key=value ...] values typed by the command's arguments:
slot=1 yaw=45 at.x=3 (dotted keys nest),
or --json '{"slot": 1}'
get <path|glob> ... 'fort1.*', 'bodies[3].pos.y'
set <path> <value> a JSON value, or text: set scene Game
pause pause now
pause --when '<condition>' pause the frame it holds
pause --on-check [--trap] pause on the next failing Debug::Check;
--trap also stops an attached debugger there
pause --clear disarm
resume
step [n] replies once the n steps are taken
wait '<condition>' | --frames n | --event Name | --paused
trace <path> ... [--frames 60] the value every frame, with min/max/mean
trace <sample> --history a Debug::Sample's recent history
log [--last 50] [--level info|warn|error] [--cat <glob>] [--grep <text>]
[--since-frame n] [--kinds log,warn,error,check,event,agent,pause,stdio]
log --follow [--for 30m] streams new entries; run it under Monitor
screenshot [-o shot.png] default ./build/crafter-debug-shots/
quit [--force] --force exits even if the loop is stuck
debugger how to attach lldb or gdb
version
Global: -i <name|id> picks an instance when several run; --json prints raw
JSON (never truncated); --timeout 30s bounds the request (waits included).
## Conditions
fort1.core.hp <= 0
scene == "Game" && !debug.paused
bodies[3].pos.y < -10 || (lives == 0 && score > highScore)
Compare watch paths with literals or each other; && || ! and parentheses;
a lone path tests truthiness. debug.frame, debug.paused and debug.fps always
exist. A condition that cannot be decided (unknown path, ordering a string
against a number) is an error, not false, so a typo never waits forever.
## Exit codes
0 done, 2 usage, 3 no instance, 4 app and client speak different protocols,
5 timed out, 6 the app answered an error. Errors name the next step.
## Gotchas
- Calls, gets, sets and waits run when the app calls Debug::Pump(), once per
frame. If `status` says Pump was never called or is long ago, the main loop
is stalled, stopped in a debugger, or its window is hidden.
- pause and step only hold what the app gates on Debug::Advance() or
Debug::Paused(); rendering and Pump keep running while paused.
- A request that timed out before Pump is guaranteed not to have run.
- Run long waits and `log --follow` in the background, and chain short
steps with &&: `call Lobby_Start && wait --frames 30 && screenshot`.
)";
}