1. The UI-CLI manager (copy-paste layer)
examples/1-ui-cli-manager/ is a complete CLI layer on top of
micronetcode: a raylib app whose entire command surface is a JSON file, and every action is
reachable over a local TCP socket.
python examples/1-ui-cli-manager/app.py --headless # run the app
printf 'set_text "speed: 12 m/s" 620 340\ndraw_circle 100 100 25\n' | ncat localhost 42069
Why it is an example and not a module
Up to 0.3.1, ui_cli_manager shipped as a second package next to micronetcode. Nobody used it
as shipped: robosim drives ConnectionManager directly with its own msgpack protocol, and
visual-graph-navigation forked the file to swap the argument-count dict for a schema. That is the
pattern, not the accident — transport is stable, policy diverges. micronetcode owns the
transport; the line format, the quoting rules, the argument typing and the help text are yours.
So ui_cli_manager.py is a single file with no __init__.py and no PyPI entry. Copy it into your
project, change it, own it. It is 170 lines; the day you need different rules you edit them
instead of filing an upstream issue.
The protocol is the command set
The app declares its commands in protocol.json, parsed
by microspec. Each endpoint names its inputs and their
dtypes (and optionally a range or an enum):
{
"set_text": {
"description": "Draw `text` at (x, y). Quote the text to keep spaces in one argument",
"input": {"text": {"dtype": "str"},
"x": {"dtype": "int32", "range": [0, 4000]},
"y": {"dtype": "int32", "range": [0, 4000]}},
"output": {"status": {"dtype": "str"}}
}
}
UICLIManager reads that and does all the boring work before the app sees anything — split the
line (shlex, so quoted arguments stay whole), check the arity, convert each token to its dtype,
range/enum-validate it. Bad input never reaches the app; it gets a reply on its own:
> set_text a 1 cmd_args=['set_text', 'a', '1']. Expected args: 3, got 2
> draw_circle 1 1 abc Conversion error could not convert string to float: 'abc'. Arg: radius (dtype: float32). Value: abc
> set_text a 99999 0 Validation error: Out of range: 99999 not in [0, 4000]
> nope Unknown command 'nope'. Supported: ['help', 'exit', ...]
There is no built-in help: make it an endpoint like any other and answer it in the app — only
the app knows how it wants to render its own spec.
The manager
with open(PROTOCOL_PATH, "r") as fp:
spec = Protocol.from_dict(json.load(fp))
cli_manager = UICLIManager.with_spec(host="0.0.0.0", port=args.port, spec=spec,
script_lines=script_lines, max_connections=3)
cli_manager.start()
with_spec is the whole layer in one call: host + port, the spec, a bound on connections, and an
optional script of startup commands. .start() spawns the listener thread that accepts clients and
answers them. --headless just hides the window (FLAG_WINDOW_HIDDEN) so the app can run on a
server with no display — the CLI is the frontend there.
I/O handling sits next to the frame poll
get_cli_command() is called where rl.IsKeyPressed / rl.IsMousePressed would be: once per
frame, never blocks, returns the next command if a client sent one. Every command is answered
exactly once via respond(), and clients are colored per connection:
cli_cmd = cli_manager.get_cli_command()
if cli_cmd is not None:
color = [rl.RAYWHITE, rl.RED, rl.BLUE][cli_cmd.message.client.channel.idx
if cli_cmd.message.client is not None else 0]
if cli_cmd.command == "help":
cli_cmd.respond(f"Supported commands: {list(spec.endpoints)}")
if cli_cmd.command == "clear":
cli_cmd.respond("Cleared all drawing from the UI")
data_to_draw.clear()
if cli_cmd.command == "set_text":
data_to_draw.append(("text", *cli_cmd.args, color)) # args are already (str, int, int)
cli_cmd.respond(f"Added text: {data_to_draw[-1][0:-1]} to the UI.")
...
Two things to notice. cli_cmd.args is a tuple of converted values — no int(...) anywhere in
the app, because the spec already said int32. And cli_cmd.message.client.channel.idx — the
message carries which client sent it, and the client carries which channel it owns, so a
multi-client reply always routes back to the right socket. That is the strict-channel contract: one
outstanding command per client until you respond().
Adding a command is one protocol.json entry plus one if.
Scripted startup
--script cmds.txt feeds lines through the same parse/validate path before any TCP client is
served, in order. Those replies have no socket to go to, so they are journaled on
manager.script_responses as (command, reply) pairs — which makes a cmds.txt file a literal,
assertable contract. That is exactly how
test/ui_cli_manager/integration/scripts/calculator/
tests a whole app in one file: a protocol, a script, and the journal.
Drawing is just raylib
The command handler only appends draw items; the render section is plain raylib, unchanged from a
window-only app. The exit command breaks the loop (and the app).
rl.BeginDrawing()
rl.ClearBackground(rl.BLACK)
for kind, *params, color in data_to_draw: # every item is (kind, *the command's args, color)
if kind == "text":
rl.DrawText(params[0].encode(), params[1], params[2], 20, color)
elif kind == "circle":
rl.DrawCircle(params[0], params[1], params[2], color)
elif kind == "line":
rl.DrawLineEx((params[0], params[1]), (params[2], params[3]), params[4], color)
rl.EndDrawing()