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
// 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 60 s ] 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 30 m ] ]
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 = 30 s ;
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 + 2 s , 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 , 2 s , { } ) : 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 ( 0 ms ) ;
std : : vector < Instance > live = env . list ( ) ;
while ( live . empty ( ) & & wait & & std : : chrono : : steady_clock : : now ( ) < deadline ) {
std : : this_thread : : sleep_for ( 200 ms ) ;
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 ) + 5 s ;
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 60 s ` 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 60 s ]
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 ]
2026-11-01 03:06:38 +01:00
log - - follow [ - - for 30 m ] the last 50 ( - - last ; 0 for none ) , then new
entries as they come ; run it under Monitor
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
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 30 s 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 ` .
) " ;
}