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 ) ;
fix(cli): print lldb by its path, and report a held app as held
Tried on the example with the app's sandbox, two things were wrong:
- `debugger` printed `flatpak enter <id> lldb -p <pid>`, and flatpak
enter runs a command without the sandbox's PATH, which /app/bin is
on: "Failed to execute child process lldb". It now prints the path
it found, /app/bin/lldb, which attached, listed threads and detached.
- With the app held by that debugger, the handshake times out, and
the client said "cannot reach basic on port ..." and exited 3, no
instance, though its lock said it lived. It now says the app is
alive but not answering, stopped in a debugger or hung, prints how
to attach, check and end it, and exits 5.
Environment::haveLldb becomes lldb, the path or empty. ShouldServeOverQuic
covers the second with an instance whose lock is held and whose port
is silent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 03:21:41 +01:00
return exitNoInstance ;
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
}
fix(cli): print lldb by its path, and report a held app as held
Tried on the example with the app's sandbox, two things were wrong:
- `debugger` printed `flatpak enter <id> lldb -p <pid>`, and flatpak
enter runs a command without the sandbox's PATH, which /app/bin is
on: "Failed to execute child process lldb". It now prints the path
it found, /app/bin/lldb, which attached, listed threads and detached.
- With the app held by that debugger, the handshake times out, and
the client said "cannot reach basic on port ..." and exited 3, no
instance, though its lock said it lived. It now says the app is
alive but not answering, stopped in a debugger or hung, prints how
to attach, check and end it, and exits 5.
Environment::haveLldb becomes lldb, the path or empty. ShouldServeOverQuic
covers the second with an instance whose lock is held and whose port
is silent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 03:21:41 +01:00
// Its lock says it lives, yet not even the handshake is
// answered: the whole process is stopped, by a debugger most
// likely, or its network threads are stuck.
Err ( ) < < std : : format ( " crafter-debug: {} is alive but not answering on port {} ({}): stopped in a debugger, or hung. \n " ,
instance - > record . name , instance - > record . port , why ) ;
PrintAttach ( true ) ;
return exitTimeout ;
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
}
// 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 ;
fix(cli): print lldb by its path, and report a held app as held
Tried on the example with the app's sandbox, two things were wrong:
- `debugger` printed `flatpak enter <id> lldb -p <pid>`, and flatpak
enter runs a command without the sandbox's PATH, which /app/bin is
on: "Failed to execute child process lldb". It now prints the path
it found, /app/bin/lldb, which attached, listed threads and detached.
- With the app held by that debugger, the handshake times out, and
the client said "cannot reach basic on port ..." and exited 3, no
instance, though its lock said it lived. It now says the app is
alive but not answering, stopped in a debugger or hung, prints how
to attach, check and end it, and exits 5.
Environment::haveLldb becomes lldb, the path or empty. ShouldServeOverQuic
covers the second with an instance whose lock is held and whose port
is silent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 03:21:41 +01:00
const std : : string lldb = env . lldb ? env . lldb ( ) : std : : string ( ) ;
const std : : string debugger = lldb . empty ( ) ? std : : string ( " gdb " ) : lldb ;
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
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 ( ) ;
2026-11-01 03:21:41 +01:00
const Value * pausedAt = s . Find ( " pausedAt " ) ;
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
Out ( ) < < std : : format ( " {} frame {} {:.1f} fps {} \n " , s . Find ( " name " ) - > AsString ( ) , s . Find ( " frame " ) - > Dump ( ) , s . Find ( " fps " ) - > AsNumber ( ) ,
2026-11-01 03:21:41 +01:00
! paused ? std : : string ( " running " )
: pausedAt ! = nullptr ? std : : format ( " PAUSED since frame {} ({}) " , pausedAt - > Dump ( ) , reason )
: std : : format ( " PAUSED ({}) " , reason ) ) ;
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
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 " ) ) {
2026-11-01 03:21:41 +01:00
// When it stopped, not when this wait looked: frames go on
// counting while paused.
const Value * pausedAt = r . Find ( " pausedAt " ) ;
const std : : string since = pausedAt ! = nullptr ? pausedAt - > Dump ( ) : r . Find ( " frame " ) - > Dump ( ) ;
Out ( ) < < std : : format ( " paused at frame {}{}: {} \n " , since , since ! = r . Find ( " frame " ) - > Dump ( ) ? std : : format ( " (now {}) " , r . Find ( " frame " ) - > Dump ( ) ) : std : : string ( ) , reason - > AsString ( ) ) ;
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
} 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 ( ) ) {
fix(cli): take global flags after the command, and call --json again
Only --json was taken after the command, so `wait --paused --timeout
60s`, the order the guide's own synopsis and agents use, failed with
"unknown argument '--timeout'", and `-i` after the command did too.
And since that --json was taken as the output flag, `call X --json
'{...}'`, the documented way to pass arguments as JSON, never reached
call: its object then failed as "not key=value".
--timeout, -i and --json now go anywhere on the line; no command has
options of those names. After `call`, a --json followed by an object
is the arguments, and any other --json the output flag. A trailing
--timeout or -i without its value is a usage error that says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 03:21:41 +01:00
// Global flags go anywhere, as agents write them: `wait
// --paused --timeout 60s`. No command has options of these
// names, but for `call --json '{...}'`, its arguments as JSON.
const bool callArguments = options . command = = " call " & & arg = = " --json " & & i + 1 < args . size ( ) & & args [ i + 1 ] . starts_with ( ' { ' ) ;
if ( arg = = " --json " & & ! callArguments ) {
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
options . json = true ;
continue ;
}
fix(cli): take global flags after the command, and call --json again
Only --json was taken after the command, so `wait --paused --timeout
60s`, the order the guide's own synopsis and agents use, failed with
"unknown argument '--timeout'", and `-i` after the command did too.
And since that --json was taken as the output flag, `call X --json
'{...}'`, the documented way to pass arguments as JSON, never reached
call: its object then failed as "not key=value".
--timeout, -i and --json now go anywhere on the line; no command has
options of those names. After `call`, a --json followed by an object
is the arguments, and any other --json the output flag. A trailing
--timeout or -i without its value is a usage error that says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 03:21:41 +01:00
if ( arg ! = " --timeout " & & arg ! = " -i " & & arg ! = " --instance " ) {
options . rest . emplace_back ( arg ) ;
if ( callArguments ) options . rest . emplace_back ( args [ + + i ] ) ;
continue ;
}
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
}
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 ;
fix(cli): take global flags after the command, and call --json again
Only --json was taken after the command, so `wait --paused --timeout
60s`, the order the guide's own synopsis and agents use, failed with
"unknown argument '--timeout'", and `-i` after the command did too.
And since that --json was taken as the output flag, `call X --json
'{...}'`, the documented way to pass arguments as JSON, never reached
call: its object then failed as "not key=value".
--timeout, -i and --json now go anywhere on the line; no command has
options of those names. After `call`, a --json followed by an object
is the arguments, and any other --json the output flag. A trailing
--timeout or -i without its value is a usage error that says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 03:21:41 +01:00
} else if ( arg = = " --timeout " | | arg = = " -i " | | arg = = " --instance " ) {
* env . err < < std : : format ( " crafter-debug: {} needs a value \n " , arg ) ;
return exitUsage ;
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
} 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
fix(tooling): build first, then start the app, then list it
The notes had agents start `crafter-build <flag> -r` in the background
and run `crafter-debug ls --wait 60s` straight after. That raced the
build: the first agent to follow them ran the plain build's stub, which
said "no debug build yet" and exited, since the client `ls --wait` needs
is one of the things the debug build compiles, and it polled by hand
until the build was done.
The race came from the order, so the order changes: build with the flag
in the foreground, which ends once the app and the client are built,
then start the app with -r in the background, then `ls --wait`, which
now only waits for what it is for, the app registering. A launcher that
polls for the build instead was tried and dropped: a shell loop that
reruns itself each second, and waits out its whole --wait when no build
is coming at all.
The consumer notes, the client's guide and the README say so, and the
stub and the not-yet-built launcher name the step that was skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:24:35 +01:00
1. Build with the debug flag , in the foreground : ` crafter - build - - debug - api `
( use the project ' s own flag ) . That build compiles this client too .
2. Start the app in the background , since it does not exit :
` crafter - build - - debug - api - r ` .
3. ` . / build / crafter - debug ls - - wait 60 s ` until it is listed .
4. ` cmds ` and ` help < command > ` to see what it offers , ` get ' * ' ` for its state .
5. Act and observe : ` call ` , ` set ` , ` pause - - when ` , ` wait ` , ` step ` , ` trace ` ,
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
` log - - level warn ` , ` screenshot ` ( open the PNG it prints with Read ) .
fix(tooling): build first, then start the app, then list it
The notes had agents start `crafter-build <flag> -r` in the background
and run `crafter-debug ls --wait 60s` straight after. That raced the
build: the first agent to follow them ran the plain build's stub, which
said "no debug build yet" and exited, since the client `ls --wait` needs
is one of the things the debug build compiles, and it polled by hand
until the build was done.
The race came from the order, so the order changes: build with the flag
in the foreground, which ends once the app and the client are built,
then start the app with -r in the background, then `ls --wait`, which
now only waits for what it is for, the app registering. A launcher that
polls for the build instead was tried and dropped: a shell loop that
reruns itself each second, and waits out its whole --wait when no build
is coming at all.
The consumer notes, the client's guide and the README say so, and the
stub and the not-yet-built launcher name the step that was skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:24:35 +01:00
6. ` quit ` when done . Killing the background task may leave the app running
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
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 ` .
) " ;
}