Ver Fonte

app: add pydrk utility for working with the scenegraph realtime and interactively

x há 2 semanas atrás
pai
commit
2b4a35b4c6

+ 1 - 1
bin/app/pydrk/__init__.py

@@ -1,6 +1,6 @@
 from .api import (Api, ErrorCode, SceneNodeType,
                   PropertyType, PropertySubType, CallArgType, Property,
-                  Expr, vertex, face)
+                  Expr)
 from .event import EventLoop, make_sub_socket
 from .print_tree import print_tree
 from .vector_shape import VectorShape

+ 4 - 0
bin/app/pydrk/__main__.py

@@ -0,0 +1,4 @@
+from pydrk import cli
+
+if __name__ == "__main__":
+    cli.main()

+ 64 - 164
bin/app/pydrk/api.py

@@ -209,6 +209,8 @@ class ErrorCode:
     CONTACT_NOT_FOUND = 47
     SERIAL_ERR = 48
     TURSO_ERR = 49
+    UNSUPPORTED_NODE_TYPE = 50
+    NODE_NOT_REMOVABLE = 51
 
     @staticmethod
     def to_str(errc):
@@ -297,42 +299,51 @@ class ErrorCode:
                 return "serial_err"
             case ErrorCode.TURSO_ERR:
                 return "turso_err"
+            case ErrorCode.UNSUPPORTED_NODE_TYPE:
+                return "unsupported_node_type"
+            case ErrorCode.NODE_NOT_REMOVABLE:
+                return "node_not_removable"
             case _:
                 return "unknown"
 
-def vertex(x, y, r, g, b, a, u, v):
-    buf = bytearray()
-    serial.write_f32(buf, x)
-    serial.write_f32(buf, y)
-    serial.write_f32(buf, r)
-    serial.write_f32(buf, g)
-    serial.write_f32(buf, b)
-    serial.write_f32(buf, a)
-    serial.write_f32(buf, u)
-    serial.write_f32(buf, v)
-    return buf
-
-def face(idx1, idx2, idx3):
-    buf = bytearray()
-    serial.write_u32(buf, idx1)
-    serial.write_u32(buf, idx2)
-    serial.write_u32(buf, idx3)
-    return buf
-
 class Api:
 
     def __init__(self, addr="127.0.0.1", port=9484):
-        context = zmq.Context()
-        self.socket = context.socket(zmq.REQ)
+        self.addr = addr
+        self.port = port
+        self.context = zmq.Context()
+        self.socket = self._make_socket()
+
+    def _make_socket(self):
+        socket = self.context.socket(zmq.REQ)
         #self.socket.setsockopt(zmq.IPV6, True)
-        self.socket.connect(f"tcp://{addr}:{port}")
+        # Fail fast with zmq.error.Again when no app is listening, so the
+        # CLI can report the endpoint it tried instead of hanging forever.
+        socket.setsockopt(zmq.RCVTIMEO, 3000)
+        # Discard undelivered messages at exit instead of blocking on
+        # context teardown when no app ever answered.
+        socket.setsockopt(zmq.LINGER, 0)
+        socket.connect(f"tcp://{self.addr}:{self.port}")
+        return socket
+
+    def _reset_socket(self):
+        # A REQ socket whose reply timed out is stuck mid-request and
+        # rejects further sends; replace it so later requests work.
+        try:
+            self.socket.close(linger=0)
+        except zmq.error.ZMQError:
+            pass
+        self.socket = self._make_socket()
 
     def _make_request(self, cmd, payload):
         req_cmd = bytearray()
         serial.write_u8(req_cmd, cmd)
-        self.socket.send_multipart([req_cmd, payload])
-
-        errc, reply = self.socket.recv_multipart()
+        try:
+            self.socket.send_multipart([req_cmd, payload])
+            errc, reply = self.socket.recv_multipart()
+        except zmq.error.ZMQError:
+            self._reset_socket()
+            raise
         errc = int.from_bytes(errc, "little")
         cursor = serial.Cursor(reply)
         match errc:
@@ -422,6 +433,10 @@ class Api:
                 raise exc.SerialErr
             case ErrorCode.TURSO_ERR:
                 raise exc.TursoErr
+            case ErrorCode.UNSUPPORTED_NODE_TYPE:
+                raise exc.UnsupportedNodeType
+            case ErrorCode.NODE_NOT_REMOVABLE:
+                raise exc.NodeNotRemovable
             case _:
                 raise exc.UnknownError(f"unknown error code: {errc}")
         return cursor
@@ -430,14 +445,6 @@ class Api:
         response = self._make_request(Command.HELLO, bytearray())
         return serial.decode_str(response)
 
-    def get_info(self, node_id):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        cur = self._make_request(Command.GET_INFO, req)
-        name = serial.decode_str(cur)
-        type = serial.read_u8(cur)
-        return (name, type)
-
     def get_children(self, node_path):
         req = bytearray()
         serial.encode_str(req, node_path)
@@ -451,19 +458,6 @@ class Api:
             children.append((child_name, child_id, child_type))
         return children
 
-    def get_parents(self, node_id):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        cur = self._make_request(Command.GET_PARENTS, req)
-        parents_len = serial.decode_varint(cur)
-        parents = []
-        for _ in range(parents_len):
-            parent_name = serial.decode_str(cur)
-            parent_id = serial.read_u32(cur)
-            parent_type = serial.read_u8(cur)
-            parents.append((parent_name, parent_id, parent_type))
-        return parents
-
     def get_properties(self, node_path):
         req = bytearray()
         serial.encode_str(req, node_path)
@@ -537,7 +531,7 @@ class Api:
             case _:
                 raise Exception("unknown property type returned")
 
-    def get_property_value(self, node_path, prop_name):
+    def get_property_value_full(self, node_path, prop_name):
         req = bytearray()
         serial.encode_str(req, node_path)
         serial.encode_str(req, prop_name)
@@ -548,116 +542,31 @@ class Api:
             prop_status = serial.read_u8(cur)
             match prop_status:
                 case PropertyStatus.NULL:
-                    return None
+                    return (PropertyStatus.NULL, None)
                 case PropertyStatus.EXPR:
-                    return Expr(serial.decode_str(cur))
+                    return (PropertyStatus.EXPR, Expr(serial.decode_str(cur)))
                 case PropertyStatus.UNSET | PropertyStatus.OK:
-                    return Api.read_prop_val(cur, prop_type)
+                    return (prop_status, Api.read_prop_val(cur, prop_type))
 
-        vals = serial.decode_arr(cur, prop_read_fn)
-        return vals
+        return serial.decode_arr(cur, prop_read_fn)
 
-    def add_node(self, node_name, node_type):
+    def get_property_value(self, node_path, prop_name):
+        vals = self.get_property_value_full(node_path, prop_name)
+        return [val for (_, val) in vals]
+
+    def add_node(self, parent_path, name, node_type):
         req = bytearray()
-        serial.encode_str(req, node_name)
+        serial.encode_str(req, parent_path)
+        serial.encode_str(req, name)
         serial.write_u8(req, int(node_type))
         cur = self._make_request(Command.ADD_NODE, req)
         node_id = serial.read_u32(cur)
         return node_id
 
-    def remove_node(self, node_id):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        self._make_request(Command.REMOVE_NODE, req)
-
-    def rename_node(self, node_id, node_name):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        serial.encode_str(req, node_name)
-        self._make_request(Command.RENAME_NODE, req)
-
-    def scan_dangling(self):
-        cur = self._make_request(Command.SCAN_DANGLING, bytearray())
-        dangling_len = serial.decode_varint(cur)
-        dangling = []
-        for _ in range(dangling_len):
-            node_id = serial.read_u32(cur)
-            dangling.append(node_id)
-        return dangling
-
-    def lookup_node_id(self, node_path):
+    def remove_node(self, node_path):
         req = bytearray()
         serial.encode_str(req, node_path)
-        try:
-            cur = self._make_request(Command.LOOKUP_NODE_ID, req)
-        except exc.NodeNotFound:
-            return None
-        return serial.read_u32(cur)
-
-    def add_property(self, node_id, prop):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        serial.encode_str(req, prop.name)
-        serial.write_u8(req, int(prop.type))
-        serial.write_u8(req, int(prop.subtype))
-        serial.write_u32(req, int(prop.array_len))
-
-        def write_defaults(by):
-            assert prop.defaults is not None
-            defaults_len = len(prop.defaults)
-            serial.encode_varint(req, defaults_len)
-            for default in prop.defaults:
-                match prop.type:
-                    case PropertyType.UINT32:
-                        serial.write_u32(req, default)
-                    case PropertyType.FLOAT32:
-                        serial.write_f32(req, default)
-                    case PropertyType.STR:
-                        serial.encode_str(req, default)
-                    case _:
-                        raise exc.PropertyWrongType
-
-        serial.encode_opt(req, prop.defaults, write_defaults)
-
-        serial.encode_str(req, prop.ui_name)
-        serial.encode_str(req, prop.desc)
-        serial.write_u8(req, int(prop.is_null_allowed))
-        serial.write_u8(req, int(prop.is_expr_allowed))
-
-        def write_mxx(v, by):
-            assert v is not None
-            match prop.type:
-                case PropertyType.UINT32:
-                    serial.write_u32(req, v)
-                case PropertyType.FLOAT32:
-                    serial.write_f32(req, v)
-                case _:
-                    raise exc.PropertyWrongType
-
-        write_min = lambda by: write_mxx(prop.min_val, by)
-        write_max = lambda by: write_mxx(prop.max_val, by)
-
-        serial.encode_opt(req, prop.min_val, write_min)
-        serial.encode_opt(req, prop.max_val, write_max)
-
-        serial.encode_varint(req, len(prop.enum_items))
-        for enum_item in prop.enum_items:
-            if prop.type != PropertyType.ENUM:
-                raise exc.PropertyWrongType
-            serial.encode_str(req, enum_item)
-        self._make_request(Command.ADD_PROPERTY, req)
-
-    def link_node(self, child_id, parent_id):
-        req = bytearray()
-        serial.write_u32(req, child_id)
-        serial.write_u32(req, parent_id)
-        self._make_request(Command.LINK_NODE, req)
-
-    def unlink_node(self, child_id, parent_id):
-        req = bytearray()
-        serial.write_u32(req, child_id)
-        serial.write_u32(req, parent_id)
-        self._make_request(Command.UNLINK_NODE, req)
+        self._make_request(Command.REMOVE_NODE, req)
 
     def set_property_null(self, node_path, prop_name, i):
         req = bytearray()
@@ -712,6 +621,15 @@ class Api:
         serial.encode_str(req, val)
         self._make_request(Command.SET_PROPERTY_VALUE, req)
 
+    def set_property_node_id(self, node_path, prop_name, i, val):
+        req = bytearray()
+        serial.encode_str(req, node_path)
+        serial.encode_str(req, prop_name)
+        serial.write_u32(req, i)
+        serial.write_u8(req, PropertyType.SCENE_NODE_ID)
+        serial.write_u32(req, val)
+        self._make_request(Command.SET_PROPERTY_VALUE, req)
+
     def set_property_expr(self, node_path, prop_name, i, expr_str):
         req = bytearray()
         serial.encode_str(req, node_path)
@@ -761,24 +679,6 @@ class Api:
         slot_id = serial.read_u32(cur)
         return slot_id
 
-    def unregister_slot(self, node_id, sig_name, slot_id):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        serial.encode_str(req, sig_name)
-        serial.write_u32(req, slot_id)
-        self._make_request(Command.UNREGISTER_SLOT, req)
-
-    def lookup_slot_id(self, node_id, sig_name, slot_name):
-        req = bytearray()
-        serial.write_u32(req, node_id)
-        serial.encode_str(req, sig_name)
-        serial.encode_str(req, slot_name)
-        try:
-            cur = self._make_request(Command.LOOKUP_SLOT_ID, req)
-        except exc.SlotNotFound:
-            return None
-        return serial.read_u32(cur)
-
     def get_slots(self, node_path, sig_name):
         req = bytearray()
         serial.encode_str(req, node_path)
@@ -818,7 +718,7 @@ class Api:
             return (arg_name, arg_desc, arg_type)
 
         args = serial.decode_arr(cur, read_arg)
-        results = serial.decode_arr(cur, read_arg)
+        results = serial.decode_opt(cur, lambda cur: serial.decode_arr(cur, read_arg))
 
         return (args, results)
 

+ 1127 - 0
bin/app/pydrk/cli.py

@@ -0,0 +1,1127 @@
+"""Command-line interface for driving a running app's scene graph over
+the netdebug ZeroMQ backend. Run from `bin/app` as `python -m pydrk ...`.
+With no subcommand an interactive shell is started."""
+
+import argparse
+import math
+import re
+import shlex
+import sys
+
+import zmq
+
+from . import exc, serial
+from .api import (
+    Api,
+    CallArgType,
+    Expr,
+    PropertyStatus,
+    PropertySubType,
+    PropertyType,
+    SceneNodeType,
+)
+from .print_tree import print_tree
+from .vector_shape import VectorShape
+
+
+class UsageError(Exception):
+    pass
+
+
+PYDRK_ERRORS = tuple(
+    obj for obj in vars(exc).values() if isinstance(obj, type) and issubclass(obj, Exception)
+)
+
+
+def error_name(err):
+    if isinstance(err, exc.UnknownError):
+        return str(err)
+    name = type(err).__name__
+    name = name.replace("ID", "Id")
+    return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
+
+
+NODE_TYPE_NAMES = {
+    getattr(SceneNodeType, name): name.lower()
+    for name in dir(SceneNodeType)
+    if name.isupper()
+}
+
+
+def resolve_path(cwd, arg):
+    if arg.startswith("/"):
+        tokens = arg.split("/")
+    else:
+        tokens = cwd + arg.split("/")
+    out = []
+    for token in tokens:
+        if token in ("", "."):
+            continue
+        if token == "..":
+            if out:
+                out.pop()
+            continue
+        out.append(token)
+    return "/" + "/".join(out)
+
+
+def format_value(val):
+    if val is None:
+        return "null"
+    if isinstance(val, Expr):
+        return f'"{val}"'
+    if isinstance(val, bool):
+        return "true" if val else "false"
+    if isinstance(val, str):
+        return f'"{val}"'
+    return str(val)
+
+
+def prop_summary(api, path, prop):
+    if prop.type == PropertyType.VECTOR_SHAPE:
+        return "<shape>"
+    vals = api.get_property_value(path, prop.name)
+    formatted = [format_value(v) for v in vals]
+    if len(formatted) == 1:
+        return formatted[0]
+    return "[" + ", ".join(formatted) + "]"
+
+
+def format_status_value(status, val):
+    match status:
+        case PropertyStatus.EXPR:
+            return f'expr "{val}"'
+        case PropertyStatus.NULL:
+            return "null"
+        case PropertyStatus.UNSET:
+            return "unset"
+        case _:
+            return f"value {format_value(val)}"
+
+
+def parse_get_args(tokens, cwd):
+    tokens = list(tokens)
+    idx = None
+    if tokens and tokens[-1].isdigit():
+        idx = int(tokens.pop())
+    if not tokens:
+        raise UsageError("usage: get [path] PROP [idx]")
+    prop_name = tokens.pop()
+    if tokens:
+        path = resolve_path(cwd, "/".join(tokens))
+    else:
+        path = "/" + "/".join(cwd)
+    return (path, prop_name, idx)
+
+
+def parse_show_args(tokens, cwd):
+    tokens = list(tokens)
+    if not tokens:
+        raise UsageError("usage: show [path] PROP")
+    prop_name = tokens.pop()
+    if tokens:
+        path = resolve_path(cwd, "/".join(tokens))
+    else:
+        path = "/" + "/".join(cwd)
+    return (path, prop_name)
+
+
+def parse_set_args(tokens, cwd):
+    tokens = list(tokens)
+    if not tokens:
+        raise UsageError("usage: set [path] PROP [idx] VAL")
+    value = tokens.pop()
+    idx = 0
+    if tokens and tokens[-1].isdigit():
+        idx = int(tokens.pop())
+    if not tokens:
+        raise UsageError("missing property name in: set [path] PROP [idx] VAL")
+    prop_name = tokens.pop()
+    if tokens:
+        path = resolve_path(cwd, "/".join(tokens))
+    else:
+        path = "/" + "/".join(cwd)
+    return (path, prop_name, idx, value)
+
+
+def parse_uint32(token):
+    try:
+        val = int(token, 0)
+    except ValueError:
+        raise UsageError(f"invalid uint32 value: {token}")
+    if not 0 <= val <= 0xFFFFFFFF:
+        raise UsageError(f"uint32 value out of range: {token}")
+    return val
+
+
+def encode_set_value(api, path, prop, token, index):
+    if token == "null":
+        api.set_property_null(path, prop.name, index)
+        return
+    match prop.type:
+        case PropertyType.BOOL:
+            if token == "true":
+                api.set_property_bool(path, prop.name, index, True)
+            elif token == "false":
+                api.set_property_bool(path, prop.name, index, False)
+            else:
+                raise UsageError(f"invalid bool value: {token} (use true/false)")
+        case PropertyType.UINT32:
+            api.set_property_u32(path, prop.name, index, parse_uint32(token))
+        case PropertyType.SCENE_NODE_ID:
+            api.set_property_node_id(path, prop.name, index, parse_uint32(token))
+        case PropertyType.FLOAT32:
+            try:
+                val = float(token)
+            except ValueError:
+                raise UsageError(f"invalid float32 value: {token}")
+            api.set_property_f32(path, prop.name, index, val)
+        case PropertyType.STR:
+            api.set_property_str(path, prop.name, index, token)
+        case PropertyType.ENUM:
+            if prop.enum_items is None or token not in prop.enum_items:
+                raise UsageError(f"invalid enum item: {token} (not in {prop.enum_items})")
+            api.set_property_enum(path, prop.name, index, token)
+        case _:
+            raise UsageError(f"cannot set properties of type {PropertyType.to_str(prop.type)}")
+
+
+def prop_meta_lines(prop):
+    array_len = "unbounded" if prop.array_len == 0 else str(prop.array_len)
+    lines = [
+        f"{prop.name}:",
+        f"  type: {PropertyType.to_str(prop.type)}",
+        f"  subtype: {PropertySubType.to_str(prop.subtype)}",
+        f"  array_len: {array_len}",
+        f"  null_allowed: {'yes' if prop.is_null_allowed else 'no'}",
+        f"  expr_allowed: {'yes' if prop.is_expr_allowed else 'no'}",
+    ]
+    if prop.min_val is not None and prop.max_val is not None:
+        lines.append(f"  range: [{format_value(prop.min_val)}, {format_value(prop.max_val)}]")
+    if prop.enum_items is not None:
+        lines.append(f"  enum_items: [" + ", ".join(prop.enum_items) + "]")
+    if prop.ui_name:
+        lines.append(f"  ui_name: {prop.ui_name}")
+    if prop.desc:
+        lines.append(f"  desc: {prop.desc}")
+    return lines
+
+
+def run_command(api, handler, args, cwd):
+    try:
+        handler(api, args, cwd)
+    except UsageError as err:
+        print(f"error: {err}", file=sys.stderr)
+        sys.exit(1)
+    except PYDRK_ERRORS as err:
+        print(f"error: {error_name(err)}", file=sys.stderr)
+        sys.exit(1)
+    except zmq.error.Again:
+        print(f"error: no reply from {api.addr}:{api.port}", file=sys.stderr)
+        sys.exit(1)
+    except zmq.error.ZMQError as err:
+        print(f"error: {err}", file=sys.stderr)
+        sys.exit(1)
+
+
+COMMAND_PARSERS = {}
+
+MAIN_PARSER = argparse.ArgumentParser()
+
+
+SHELL_BUILTINS = ("cd", "pwd", "exit", "quit")
+
+
+BUILTIN_HELP = {
+    "cd": "cd [path]           change the working node (no arg = /, .. pops one)",
+    "pwd": "pwd                 print the working node path",
+    "exit": "exit | quit         leave the shell (Ctrl-D also works)",
+    "quit": "exit | quit         leave the shell (Ctrl-D also works)",
+    "help": "help [command]      show overall or per-command help",
+}
+
+
+def print_help(command=None):
+    if command is None:
+        MAIN_PARSER.print_help()
+        print()
+        print("shell builtins (interactive mode only):")
+        for name in ("cd", "pwd", "exit", "help"):
+            print(f"  {BUILTIN_HELP[name]}")
+    elif command in COMMAND_PARSERS:
+        COMMAND_PARSERS[command].print_help()
+    elif command in BUILTIN_HELP:
+        print(BUILTIN_HELP[command])
+    else:
+        raise UsageError(f"unknown command: {command}")
+
+
+def cmd_help(api, args, cwd):
+    print_help(getattr(args, "topic", None))
+
+
+class ShellExit(Exception):
+    pass
+
+
+class Shell:
+    def __init__(self, api):
+        self.api = api
+        self.cwd = []
+
+    def prompt(self):
+        return f"pydrk:/{'/'.join(self.cwd)}> "
+
+    def run(self):
+        setup_completion(self)
+        while True:
+            try:
+                line = input(self.prompt())
+            except EOFError:
+                print()
+                return
+            except KeyboardInterrupt:
+                print()
+                continue
+            try:
+                self.execute(line)
+            except ShellExit:
+                return
+            clear_completion_cache()
+
+    def execute(self, line):
+        try:
+            tokens = shlex.split(line)
+        except ValueError as err:
+            print(f"error: {err}", file=sys.stderr)
+            return
+        if not tokens:
+            return
+
+        cmd = tokens[0]
+        if cmd in ("exit", "quit"):
+            raise ShellExit
+        if cmd == "pwd":
+            print("/" + "/".join(self.cwd))
+            return
+        if cmd == "cd":
+            self.cd(tokens[1:])
+            return
+
+        parser = COMMAND_PARSERS.get(cmd)
+        if parser is None:
+            print(f"error: unknown command: {cmd}", file=sys.stderr)
+            return
+        try:
+            args = parser.parse_args(tokens[1:])
+        except SystemExit:
+            return
+        try:
+            args.func(self.api, args, self.cwd)
+        except UsageError as err:
+            print(f"error: {err}", file=sys.stderr)
+        except PYDRK_ERRORS as err:
+            print(f"error: {error_name(err)}", file=sys.stderr)
+        except zmq.error.Again:
+            print(f"error: no reply from {self.api.addr}:{self.api.port}", file=sys.stderr)
+        except zmq.error.ZMQError as err:
+            print(f"error: {err}", file=sys.stderr)
+
+    def cd(self, tokens):
+        path = resolve_path(self.cwd, tokens[0] if tokens else "/")
+        if path != "/":
+            parent, _, name = path.rpartition("/")
+            try:
+                children = self.api.get_children(parent or "/")
+            except UsageError as err:
+                print(f"error: {err}", file=sys.stderr)
+                return
+            except PYDRK_ERRORS as err:
+                print(f"error: {error_name(err)}", file=sys.stderr)
+                return
+            except zmq.error.Again:
+                print(f"error: no reply from {self.api.addr}:{self.api.port}", file=sys.stderr)
+                return
+            except zmq.error.ZMQError as err:
+                print(f"error: {err}", file=sys.stderr)
+                return
+            if not any(child_name == name for (child_name, _, _) in children):
+                print(f"error: node_not_found: {path}", file=sys.stderr)
+                return
+        self.cwd = [token for token in path.split("/") if token]
+
+
+class Completer:
+    def __init__(self, shell):
+        self.shell = shell
+        self.cache = {}
+
+    def clear_cache(self):
+        self.cache.clear()
+
+    def cached_children(self, path):
+        if path not in self.cache:
+            try:
+                self.cache[path] = [name for (name, _, _) in self.shell.api.get_children(path)]
+            except Exception:
+                self.cache[path] = []
+        return self.cache[path]
+
+    def cached_props(self, path):
+        key = "props:" + path
+        if key not in self.cache:
+            try:
+                self.cache[key] = [prop.name for prop in self.shell.api.get_properties(path)]
+            except Exception:
+                self.cache[key] = []
+        return self.cache[key]
+
+    def path_matches(self, token, text):
+        # `token` is the whitespace-delimited word up to the cursor,
+        # including any already-typed path components. `text` is what
+        # readline wants replaced: readline's default completer delimiters
+        # include "/", so text may be only the fragment after the last
+        # slash. Matches are returned with the shared head stripped so
+        # they align with readline's replacement window.
+        strip = len(token) - len(text)
+        idx = token.rfind("/")
+        if idx == -1:
+            head, prefix, prefix_part = "", token, ""
+        else:
+            head, prefix = token[:idx], token[idx + 1:]
+            prefix_part = token[: idx + 1]
+        if token.startswith("/"):
+            base = resolve_path(self.shell.cwd, head if head else "/")
+        else:
+            base = resolve_path(self.shell.cwd, head if head else ".")
+        return [
+            (prefix_part + name + "/")[strip:]
+            for name in self.cached_children(base)
+            if name.startswith(prefix)
+        ]
+
+    def matches(self, text):
+        import readline
+
+        buf = readline.get_line_buffer()
+        begidx = readline.get_begidx()
+
+        if begidx == 0:
+            words = sorted(set(COMMAND_PARSERS) | set(SHELL_BUILTINS))
+            return [word for word in words if word.startswith(text)]
+
+        typed = buf[:begidx]
+        head_tokens = typed.split()
+        cmd = head_tokens[0] if head_tokens else ""
+        token = typed[typed.rfind(" ") + 1 :] + text
+
+        if cmd in ("get", "set", "show") and len(head_tokens) == 1:
+            cwd_path = "/" + "/".join(self.shell.cwd)
+            found = []
+            if "/" not in token:
+                found += [name + "/" for name in self.cached_children(cwd_path)]
+                found += [name + " " for name in self.cached_props(cwd_path)]
+                return sorted(set(m for m in found if m.startswith(text)))
+            return sorted(set(self.path_matches(token, text)))
+
+        return sorted(set(self.path_matches(token, text)))
+
+    def complete(self, text, state):
+        found = self.matches(text)
+        return found[state] if state < len(found) else None
+
+
+_ACTIVE_COMPLETER = None
+
+
+def setup_completion(shell):
+    global _ACTIVE_COMPLETER
+    try:
+        import readline
+    except ImportError:
+        return
+    readline.parse_and_bind("tab: complete")
+    _ACTIVE_COMPLETER = Completer(shell)
+    readline.set_completer(_ACTIVE_COMPLETER.complete)
+
+
+def clear_completion_cache():
+    if _ACTIVE_COMPLETER is not None:
+        _ACTIVE_COMPLETER.clear_cache()
+
+
+def shell_main(args):
+    api = Api(args.addr, args.port)
+    Shell(api).run()
+
+
+def build_parser():
+    global MAIN_PARSER
+    endpoint_args = argparse.ArgumentParser(add_help=False)
+    endpoint_args.add_argument("--addr", default=argparse.SUPPRESS)
+    endpoint_args.add_argument("--port", type=int, default=argparse.SUPPRESS)
+
+    parser = argparse.ArgumentParser(prog="pydrk", description="Drive a running app over the netdebug backend")
+    parser.add_argument("--addr", default="127.0.0.1")
+    parser.add_argument("--port", type=int, default=9484)
+    parser.add_argument("--selftest", action="store_true", help="run built-in checks and exit")
+
+    sub = parser.add_subparsers(dest="command", metavar="<command>")
+
+    p = sub.add_parser("ping", parents=[endpoint_args], help="connectivity check")
+    p.set_defaults(func=cmd_ping)
+
+    p = sub.add_parser("ls", parents=[endpoint_args], help="list a node's children and properties")
+    p.add_argument("path", nargs="?", default=".")
+    p.set_defaults(func=cmd_ls)
+
+    p = sub.add_parser("tree", parents=[endpoint_args], help="recursively print a node's descendants")
+    p.add_argument("path", nargs="?", default=".")
+    p.add_argument("--depth", type=int, default=None)
+    p.set_defaults(func=cmd_tree)
+
+    p = sub.add_parser("props", parents=[endpoint_args], help="list a node's property metadata")
+    p.add_argument("path", nargs="?", default=".")
+    p.set_defaults(func=cmd_props)
+
+    p = sub.add_parser("get", parents=[endpoint_args], help="print a property's values")
+    p.add_argument("positionals", nargs="*", metavar="[path] PROP [idx]")
+    p.set_defaults(func=cmd_get)
+
+    p = sub.add_parser("show", parents=[endpoint_args], help="print everything about one property")
+    p.add_argument("positionals", nargs="*", metavar="[path] PROP")
+    p.set_defaults(func=cmd_show)
+
+    p = sub.add_parser("set", parents=[endpoint_args], help="set a property value")
+    p.add_argument("positionals", nargs="*", metavar="[path] PROP [idx] VAL")
+    p.add_argument("--expr", action="store_true", help="send VAL as expr source to compile server-side")
+    p.set_defaults(func=cmd_set)
+
+    p = sub.add_parser("methods", parents=[endpoint_args], help="list a node's methods")
+    p.add_argument("path")
+    p.set_defaults(func=cmd_methods)
+
+    p = sub.add_parser("signals", parents=[endpoint_args], help="list a node's signals")
+    p.add_argument("path")
+    p.set_defaults(func=cmd_signals)
+
+    p = sub.add_parser("mknode", parents=[endpoint_args], help="create and attach a node")
+    p.add_argument("parent_path")
+    p.add_argument("name")
+    p.add_argument("type")
+    p.set_defaults(func=cmd_mknode)
+
+    p = sub.add_parser(
+        "rmnode",
+        parents=[endpoint_args],
+        help="remove a node subtree (runtime-only, undone by restarting the app)",
+    )
+    p.add_argument("path")
+    p.set_defaults(func=cmd_rmnode)
+
+    p = sub.add_parser("set-shape", parents=[endpoint_args], help="push vector shape data")
+    p.add_argument("path")
+    p.add_argument("--prop", default="shape")
+    p.add_argument("--index", type=int, default=0)
+    p.add_argument(
+        "--box",
+        nargs=8,
+        action=ShapePrimAction,
+        metavar=("X1", "Y1", "X2", "Y2", "R", "G", "B", "A"),
+    )
+    p.add_argument(
+        "--gbox",
+        nargs=12,
+        action=ShapePrimAction,
+        metavar=("X1", "Y1", "X2", "Y2", "R", "G", "B", "A", "R", "G", "B", "A"),
+    )
+    p.add_argument(
+        "--vgradient",
+        nargs=14,
+        action=ShapePrimAction,
+        metavar=(
+            "X1", "Y1", "X2", "Y2", "R", "G", "B", "A", "R", "G", "B", "A", "STRIPS", "GAMMA",
+        ),
+    )
+    p.add_argument(
+        "--outline",
+        nargs=9,
+        action=ShapePrimAction,
+        metavar=("X1", "Y1", "X2", "Y2", "BORDERPX", "R", "G", "B", "A"),
+    )
+    p.add_argument(
+        "--line",
+        nargs=9,
+        action=ShapePrimAction,
+        metavar=("X1", "Y1", "X2", "Y2", "THICKNESS", "R", "G", "B", "A"),
+    )
+    p.add_argument(
+        "--glow",
+        nargs=9,
+        action=ShapePrimAction,
+        metavar=("CX", "CY", "W", "H", "SEGMENTS", "R", "G", "B", "A"),
+    )
+    p.set_defaults(func=cmd_set_shape)
+
+    p = sub.add_parser("call", parents=[endpoint_args], help="call a node method")
+    p.add_argument("path")
+    p.add_argument("method")
+    p.add_argument("args", nargs="*", metavar="ARG")
+    p.set_defaults(func=cmd_call)
+
+    p = sub.add_parser("help", parents=[endpoint_args], help="show overall or per-command help")
+    p.add_argument("topic", nargs="?", default=None)
+    p.set_defaults(func=cmd_help)
+
+    COMMAND_PARSERS.clear()
+    COMMAND_PARSERS.update(sub.choices)
+    MAIN_PARSER = parser
+
+    return parser
+
+
+def cmd_ping(api, args, cwd):
+    print(api.hello())
+
+
+def cmd_ls(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    for (name, node_id, node_type) in api.get_children(path):
+        print(f"{name} {node_id} {NODE_TYPE_NAMES.get(node_type, '?')}")
+    for prop in api.get_properties(path):
+        prop_type = PropertyType.to_str(prop.type)
+        print(f"{prop.name}: {prop_type} = {prop_summary(api, path, prop)}")
+
+
+def cmd_tree(api, args, cwd):
+    print_tree(api, resolve_path(cwd, args.path), args.depth)
+
+
+def cmd_props(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    for prop in api.get_properties(path):
+        for line in prop_meta_lines(prop):
+            print(line)
+
+
+def print_prop_values(api, path, prop_name, idx):
+    vals = api.get_property_value_full(path, prop_name)
+    for i, (status, val) in enumerate(vals):
+        if idx is not None and i != idx:
+            continue
+        print(f"{i}: {format_status_value(status, val)}")
+
+
+def cmd_get(api, args, cwd):
+    path, prop_name, idx = parse_get_args(args.positionals, cwd)
+    print_prop_values(api, path, prop_name, idx)
+
+
+def find_prop(api, path, prop_name):
+    for prop in api.get_properties(path):
+        if prop.name == prop_name:
+            return prop
+    raise exc.PropertyNotFound
+
+
+def cmd_show(api, args, cwd):
+    path, prop_name = parse_show_args(args.positionals, cwd)
+    prop = find_prop(api, path, prop_name)
+    for line in prop_meta_lines(prop):
+        print(line)
+    if prop.depends:
+        depends = ", ".join(f"({i}, {name})" for (i, name) in prop.depends)
+        print(f"  depends: [{depends}]")
+    print_prop_values(api, path, prop_name, None)
+
+
+def cmd_set(api, args, cwd):
+    path, prop_name, idx, value = parse_set_args(args.positionals, cwd)
+    if args.expr:
+        api.set_property_expr(path, prop_name, idx, value)
+        return
+    prop = find_prop(api, path, prop_name)
+    encode_set_value(api, path, prop, value, idx)
+
+
+def format_signature(method_name, args, results):
+    arg_strs = [f"{name}: {CallArgType.to_str(typ)}" for (name, _, typ) in args]
+    result_strs = [f"{name}: {CallArgType.to_str(typ)}" for (name, _, typ) in (results or [])]
+    return f"{method_name}(" + ", ".join(arg_strs) + ") -> (" + ", ".join(result_strs) + ")"
+
+
+def cmd_methods(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    for method_name in api.get_methods(path):
+        method_args, results = api.get_method(path, method_name)
+        print(format_signature(method_name, method_args, results))
+
+
+def cmd_signals(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    for sig_name in api.get_signals(path):
+        print(sig_name)
+
+
+NODE_TYPES = {
+    "layer": SceneNodeType.LAYER,
+    "vector_art": SceneNodeType.VECTOR_ART,
+}
+
+
+def cmd_mknode(api, args, cwd):
+    node_type = NODE_TYPES.get(args.type)
+    if node_type is None:
+        raise UsageError(f"unsupported node type: {args.type} (supported: {', '.join(NODE_TYPES)})")
+    parent_path = resolve_path(cwd, args.parent_path)
+    node_id = api.add_node(parent_path, args.name, node_type)
+    path = parent_path.rstrip("/") + "/" + args.name
+    print(f"id={node_id} path={path}")
+
+
+def cmd_rmnode(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    api.remove_node(path)
+
+
+SHAPE_MAX_VERTS = 65536
+
+
+class ShapePrimAction(argparse.Action):
+    # argparse "append" actions keep one list per flag, losing the order
+    # between different flags. This action records (flag, values) pairs in
+    # true command-line order so primitives join as given.
+    def __call__(self, parser, namespace, values, option_string=None):
+        prims = list(getattr(namespace, "prims", []))
+        prims.append(((option_string or "").lstrip("-"), values))
+        namespace.prims = prims
+
+
+def coord_arg(token):
+    try:
+        return float(token)
+    except ValueError:
+        return token
+
+
+def num_arg(token, what):
+    try:
+        return float(token)
+    except ValueError:
+        raise UsageError(f"invalid {what} value: {token}")
+
+
+def int_arg(token, what):
+    try:
+        return int(token, 0)
+    except ValueError:
+        raise UsageError(f"invalid {what} value: {token}")
+
+
+def parse_shape_color_args(vals, count):
+    if len(vals) != count:
+        raise UsageError(f"expected {count} color values (R G B A), got {' '.join(vals)}")
+    try:
+        return [float(v) for v in vals]
+    except ValueError:
+        raise UsageError(f"invalid color value: {' '.join(vals)}")
+
+
+def build_shape(prims):
+    shape = VectorShape()
+    for (name, vals) in prims:
+        match name:
+            case "box":
+                shape.add_filled_box(
+                    coord_arg(vals[0]),
+                    coord_arg(vals[1]),
+                    coord_arg(vals[2]),
+                    coord_arg(vals[3]),
+                    parse_shape_color_args(vals[4:], 4),
+                )
+            case "gbox":
+                top = parse_shape_color_args(vals[4:8], 4)
+                bottom = parse_shape_color_args(vals[8:], 4)
+                shape.add_gradient_box(
+                    coord_arg(vals[0]),
+                    coord_arg(vals[1]),
+                    coord_arg(vals[2]),
+                    coord_arg(vals[3]),
+                    [top, top, bottom, bottom],
+                )
+            case "vgradient":
+                top = parse_shape_color_args(vals[4:8], 4)
+                bottom = parse_shape_color_args(vals[8:12], 4)
+                strips = int_arg(vals[12], "strips")
+                gamma = num_arg(vals[13], "gamma")
+                if strips <= 0:
+                    raise UsageError(f"strips must be positive, got {strips}")
+                shape.add_smooth_vertical_gradient(
+                    coord_arg(vals[0]),
+                    coord_arg(vals[1]),
+                    coord_arg(vals[2]),
+                    coord_arg(vals[3]),
+                    top,
+                    bottom,
+                    strips,
+                    gamma,
+                )
+            case "outline":
+                shape.add_outline(
+                    coord_arg(vals[0]),
+                    coord_arg(vals[1]),
+                    coord_arg(vals[2]),
+                    coord_arg(vals[3]),
+                    coord_arg(vals[4]),
+                    parse_shape_color_args(vals[5:], 4),
+                )
+            case "line":
+                coords = []
+                for token in vals[:4]:
+                    if not isinstance(coord_arg(token), float):
+                        raise UsageError(f"line coordinates must be plain numbers, got {token}")
+                    coords.append(float(token))
+                thickness = num_arg(vals[4], "thickness")
+                shape.add_line(
+                    coords[0],
+                    coords[1],
+                    coords[2],
+                    coords[3],
+                    thickness,
+                    parse_shape_color_args(vals[5:], 4),
+                )
+            case "glow":
+                segments = int_arg(vals[4], "segments")
+                if segments <= 0:
+                    raise UsageError(f"segments must be positive, got {segments}")
+                shape.add_radial_glow(
+                    coord_arg(vals[0]),
+                    coord_arg(vals[1]),
+                    coord_arg(vals[2]),
+                    coord_arg(vals[3]),
+                    segments,
+                    0.0,
+                    2.0 * math.pi,
+                    parse_shape_color_args(vals[5:], 4),
+                )
+            case _:
+                raise UsageError(f"unknown shape primitive: {name}")
+    if len(shape.verts) >= SHAPE_MAX_VERTS:
+        raise UsageError(
+            f"shape has {len(shape.verts)} vertices, exceeding the 16-bit index limit of {SHAPE_MAX_VERTS - 1}"
+        )
+    return shape
+
+
+def cmd_set_shape(api, args, cwd):
+    prims = getattr(args, "prims", None) or []
+    if not prims:
+        raise UsageError("no shape primitives given (use --box, --gbox, --vgradient, --outline, --line, --glow)")
+    shape = build_shape(prims)
+    path = resolve_path(cwd, args.path)
+    shape.set(api, path, args.prop, args.index)
+
+
+def parse_bool(token):
+    if token == "true":
+        return True
+    if token == "false":
+        return False
+    raise UsageError(f"invalid bool value: {token} (use true/false)")
+
+
+def encode_call_arg(buf, arg_type, token, arg_name):
+    match arg_type:
+        case CallArgType.UINT32:
+            serial.write_u32(buf, parse_uint32(token))
+        case CallArgType.UINT64:
+            try:
+                val = int(token, 0)
+            except ValueError:
+                raise UsageError(f"invalid uint64 value for {arg_name}: {token}")
+            if not 0 <= val <= 0xFFFFFFFFFFFFFFFF:
+                raise UsageError(f"uint64 value out of range for {arg_name}: {token}")
+            serial.write_u64(buf, val)
+        case CallArgType.FLOAT32:
+            serial.write_f32(buf, num_arg(token, arg_name))
+        case CallArgType.BOOL:
+            serial.write_u8(buf, int(parse_bool(token)))
+        case CallArgType.STR:
+            serial.encode_str(buf, token)
+        case CallArgType.HASH:
+            token = token.strip().lower()
+            if len(token) != 64:
+                raise UsageError(f"invalid hash for {arg_name}: expected 64 hex chars, got {token}")
+            try:
+                buf += bytes.fromhex(token)
+            except ValueError:
+                raise UsageError(f"invalid hash hex for {arg_name}: {token}")
+        case _:
+            raise UsageError(f"unsupported argument type: {CallArgType.to_str(arg_type)}")
+
+
+CALL_RESULT_SIZES = {
+    CallArgType.UINT32: 4,
+    CallArgType.UINT64: 8,
+    CallArgType.FLOAT32: 4,
+    CallArgType.BOOL: 1,
+}
+
+
+def decode_call_result(cur, typ):
+    match typ:
+        case CallArgType.STR:
+            return serial.decode_str(cur)
+        case CallArgType.HASH:
+            return cur.read(32).hex()
+        case _:
+            data = cur.read(CALL_RESULT_SIZES[typ])
+            return f"0x{data.hex()}"
+
+
+def cmd_call(api, args, cwd):
+    path = resolve_path(cwd, args.path)
+    method_args, results = api.get_method(path, args.method)
+    if len(args.args) != len(method_args):
+        sig = format_signature(args.method, method_args, results)
+        raise UsageError(f"wrong number of arguments for {sig}, got {len(args.args)}")
+    buf = bytearray()
+    for (name, _, typ), token in zip(method_args, args.args):
+        encode_call_arg(buf, typ, token, name)
+    result = api.call_method(path, args.method, bytes(buf))
+    if result is None or not results:
+        print("void")
+        return
+    cur = serial.Cursor(result)
+    outs = []
+    for (name, _, typ) in results:
+        try:
+            outs.append(decode_call_result(cur, typ))
+        except Exception:
+            outs.append(f"0x{cur.remain_data().hex()}")
+            break
+    print(" ".join(outs))
+
+
+def run_selftests():
+    from .api import Expr, Property, PropertyStatus, PropertyType
+
+    assert format_value(None) == "null"
+    assert format_value(Expr("w/2")) == '"w/2"'
+    assert format_value(True) == "true"
+    assert format_value(False) == "false"
+    assert format_value(1.0) == "1.0"
+    assert format_value(10) == "10"
+    assert format_value("hello world") == '"hello world"'
+
+    assert format_status_value(PropertyStatus.EXPR, Expr("w/2")) == 'expr "w/2"'
+    assert format_status_value(PropertyStatus.NULL, None) == "null"
+    assert format_status_value(PropertyStatus.UNSET, 1.0) == "unset"
+    assert format_status_value(PropertyStatus.OK, 1.0) == "value 1.0"
+
+    assert NODE_TYPE_NAMES[SceneNodeType.LAYER] == "layer"
+    assert NODE_TYPE_NAMES[SceneNodeType.VECTOR_ART] == "vector_art"
+    assert NODE_TYPE_NAMES[SceneNodeType.PLUGIN_ROOT] == "plugin_root"
+
+    assert resolve_path([], "/") == "/"
+    assert resolve_path([], "") == "/"
+    assert resolve_path([], "..") == "/"
+    assert resolve_path(["a", "b"], "../..") == "/"
+    assert resolve_path(["a", "b"], "../../../setting") == "/setting"
+    assert resolve_path([], "//window") == "/window"
+    assert resolve_path([], "/window/content") == "/window/content"
+    assert resolve_path(["window"], "content") == "/window/content"
+    assert resolve_path(["window", "content"], "..") == "/window"
+    assert resolve_path(["window"], "../setting") == "/setting"
+    assert resolve_path(["window"], "./content/.") == "/window/content"
+    assert resolve_path([], "window//content/") == "/window/content"
+
+    assert parse_get_args(["alpha"], []) == ("/", "alpha", None)
+    assert parse_get_args(["/window/content", "alpha"], []) == ("/window/content", "alpha", None)
+    assert parse_get_args(["window", "content", "alpha"], []) == ("/window/content", "alpha", None)
+    assert parse_get_args(["rect", "2"], []) == ("/", "rect", 2)
+    assert parse_get_args(["rect", "2"], ["window", "content"]) == ("/window/content", "rect", 2)
+    for bad in ([], ["2"]):
+        try:
+            parse_get_args(bad, [])
+            raise AssertionError(f"parse_get_args({bad}) should have raised")
+        except UsageError:
+            pass
+
+    assert parse_show_args(["alpha"], []) == ("/", "alpha")
+    assert parse_show_args(["window", "content", "alpha"], []) == ("/window/content", "alpha")
+    assert parse_show_args(["alpha"], ["window", "content"]) == ("/window/content", "alpha")
+
+    assert parse_set_args(["is_visible", "false"], []) == ("/", "is_visible", 0, "false")
+    assert parse_set_args(["rect", "2", "w/2"], []) == ("/", "rect", 2, "w/2")
+    assert parse_set_args(["/window/content", "rect", "2", "w/2"], []) == (
+        "/window/content",
+        "rect",
+        2,
+        "w/2",
+    )
+    assert parse_set_args(["window", "content", "rect", "2", "1.0"], []) == (
+        "/window/content",
+        "rect",
+        2,
+        "1.0",
+    )
+    assert parse_set_args(["rect", "w/2"], ["window"]) == ("/window", "rect", 0, "w/2")
+    for bad in ([], ["false"], ["2", "false"]):
+        try:
+            parse_set_args(bad, [])
+            raise AssertionError(f"parse_set_args({bad}) should have raised")
+        except UsageError:
+            pass
+
+    assert parse_uint32("42") == 42
+    assert parse_uint32("0x10") == 16
+    for bad in ("-1", "x", "4294967296"):
+        try:
+            parse_uint32(bad)
+            raise AssertionError(f"parse_uint32({bad}) should have raised")
+        except UsageError:
+            pass
+
+    shape = build_shape([("box", ["0", "0", "w", "10", "1", "0", "0", "1"])])
+    assert len(shape.verts) == 4 and len(shape.indices) == 6
+    assert shape.verts[1][0] == "w" and shape.verts[3][1] == "10.0"
+
+    shape = build_shape([("gbox", ["0", "0", "w", "h", "1", "1", "1", "1", "0", "0", "0", "1"])])
+    assert len(shape.verts) == 4
+    assert shape.verts[0][2] == [1.0, 1.0, 1.0, 1.0]
+    assert shape.verts[2][2] == [0.0, 0.0, 0.0, 1.0]
+
+    shape = build_shape(
+        [("vgradient", ["0", "0", "w", "h", "1", "1", "1", "1", "0", "0", "0", "1", "8", "0.45"])]
+    )
+    assert len(shape.verts) == 8 * 4 and len(shape.indices) == 8 * 6
+
+    shape = build_shape([("outline", ["0", "0", "w", "h", "2.0", "0", "0", "0", "1"])])
+    assert len(shape.verts) == 16 and len(shape.indices) == 24
+
+    shape = build_shape([("line", ["0", "0", "10", "0", "4", "1", "1", "1", "1"])])
+    assert len(shape.verts) == 4 and len(shape.indices) == 6
+
+    shape = build_shape([("glow", ["w/2", "h/2", "w", "h", "12", "1", "0", "0", "1"])])
+    assert len(shape.verts) == 14 and len(shape.indices) == 36
+    assert shape.verts[1][0] == "(w/2 + (w * 0.5))"
+
+    shape = build_shape(
+        [
+            ("box", ["0", "0", "1", "1", "1", "1", "1", "1"]),
+            ("outline", ["0", "0", "1", "1", "1", "0", "0", "0", "1"]),
+        ]
+    )
+    assert len(shape.verts) == 4 + 16
+
+    parser = build_parser()
+    assert MAIN_PARSER is parser
+    assert "help" in COMMAND_PARSERS
+    for name in ("cd", "pwd", "exit", "quit", "help"):
+        assert name in BUILTIN_HELP
+    assert set(SHELL_BUILTINS) <= set(BUILTIN_HELP)
+    args = parser.parse_args(
+        ["set-shape", "/x", "--box", "0", "0", "w", "10", "1", "0", "0", "1"]
+    )
+    assert args.prims == [("box", ["0", "0", "w", "10", "1", "0", "0", "1"])]
+    args = parser.parse_args(
+        [
+            "set-shape", "/x",
+            "--box", "0", "0", "1", "1", "1", "1", "1", "1",
+            "--outline", "0", "0", "1", "1", "1", "0", "0", "0", "1",
+        ]
+    )
+    assert [name for (name, _) in args.prims] == ["box", "outline"]
+
+    for bad in (
+        ["line", ["0", "0", "w", "0", "4", "1", "1", "1", "1"]],
+        ["vgradient", ["0", "0", "1", "1", "1", "1", "1", "1", "0", "0", "0", "1", "0", "0.45"]],
+        ["glow", ["1", "1", "1", "1", "-3", "1", "0", "0", "1"]],
+        ["box", ["0", "0", "1", "1", "x", "0", "0", "1"]],
+    ):
+        try:
+            build_shape([bad])
+            raise AssertionError(f"build_shape({bad}) should have raised")
+        except UsageError:
+            pass
+
+    try:
+        build_shape([("vgradient", ["0", "0", "1", "1", "1", "1", "1", "1", "0", "0", "0", "1", "20000", "1"])])
+        raise AssertionError("oversized shape should have raised")
+    except UsageError as err:
+        assert "exceeding" in str(err)
+
+    assert coord_arg("w/2") == "w/2"
+    assert coord_arg("3.5") == 3.5
+
+    buf = bytearray()
+    encode_call_arg(buf, CallArgType.UINT32, "0x10", "n")
+    encode_call_arg(buf, CallArgType.STR, "hello", "s")
+    encode_call_arg(buf, CallArgType.HASH, "00" * 32, "h")
+    encode_call_arg(buf, CallArgType.BOOL, "true", "b")
+    encode_call_arg(buf, CallArgType.FLOAT32, "1.5", "f")
+    assert bytes(buf) == bytes.fromhex("10000000") + b"\x05hello" + bytes(32) + b"\x01" + bytes.fromhex(
+        "0000c03f"
+    )
+
+    cur = serial.Cursor(bytes(buf))
+    assert serial.read_u32(cur) == 16
+    assert serial.decode_str(cur) == "hello"
+    assert cur.read(32) == bytes(32)
+
+    for bad in (
+        (CallArgType.BOOL, "yes"),
+        (CallArgType.HASH, "1234"),
+        (CallArgType.UINT64, "-1"),
+    ):
+        try:
+            encode_call_arg(bytearray(), bad[0], bad[1], "x")
+            raise AssertionError(f"encode_call_arg{bad} should have raised")
+        except UsageError:
+            pass
+
+    prop = Property(
+        "alpha",
+        PropertyType.FLOAT32,
+        0,
+        "Alpha",
+        "Layer transparency",
+        False,
+        False,
+        1,
+        0.0,
+        1.0,
+        None,
+        [],
+    )
+    lines = prop_meta_lines(prop)
+    assert lines[0] == "alpha:"
+    assert "  type: float32" in lines
+    assert "  array_len: 1" in lines
+    assert "  range: [0.0, 1.0]" in lines
+
+    prop = prop._replace(array_len=0, min_val=None, max_val=None)
+    assert "  array_len: unbounded" in prop_meta_lines(prop)
+    assert not any(line.startswith("  range:") for line in prop_meta_lines(prop))
+
+
+def main(argv=None):
+    parser = build_parser()
+    args = parser.parse_args(argv)
+
+    if args.selftest:
+        run_selftests()
+        print("cli self-test OK")
+        return
+
+    if args.command is None:
+        shell_main(args)
+        return
+
+    api = Api(args.addr, args.port)
+    run_command(api, args.func, args, [])
+
+
+if __name__ == "__main__":
+    main()

+ 4 - 0
bin/app/pydrk/exc.py

@@ -82,5 +82,9 @@ class SerialErr(Exception):
     pass
 class TursoErr(Exception):
     pass
+class UnsupportedNodeType(Exception):
+    pass
+class NodeNotRemovable(Exception):
+    pass
 class UnknownError(Exception):
     pass

+ 1 - 1
bin/app/pydrk/print_tree.py

@@ -88,7 +88,7 @@ def print_node_info(api, parent_path, depth, indent):
         args, results = api.get_method(parent_path, method_name)
 
         args = [f"{name}: " + CallArgType.to_str(typ) for (name, _, typ) in args]
-        results = [f"{name}: " + CallArgType.to_str(typ) for (name, _, typ) in results]
+        results = [f"{name}: " + CallArgType.to_str(typ) for (name, _, typ) in (results or [])]
 
         method_str = f"{method_name}(" + ", ".join(args) + ") -> (" + ", ".join(results) + ")"
         print(f"{ws}{method_str}")

+ 24 - 4
bin/app/pydrk/vector_shape.py

@@ -13,7 +13,21 @@ import math
 def _coord(x):
     if isinstance(x, str):
         return x
-    return repr(float(x))
+    x = float(x)
+    neg = math.copysign(1.0, x) < 0
+    if neg:
+        x = -x
+    s = repr(x)
+    if "e" in s or "E" in s:
+        # The app-side expr tokenizer does not accept scientific
+        # notation (glow trig produces values like 6.1e-17), so render
+        # tiny/huge floats as plain decimals.
+        s = f"{x:.30f}".rstrip("0")
+        if s.endswith("."):
+            s += "0"
+    # The tokenizer also has no unary minus, so negative constants
+    # (outline borders, glow trig) are rendered as subtraction.
+    return f"(0 - {s})" if neg else s
 
 def _mul(a, b):
     return f"({_coord(a)} * {_coord(b)})"
@@ -141,6 +155,12 @@ class VectorShape:
 
 # python -m pydrk.vector_shape
 if __name__ == "__main__":
+    assert _coord(6.123233995736766e-17) == f"{6.123233995736766e-17:.30f}".rstrip("0")
+    assert _coord(-1.2246467991473532e-16) == f"(0 - {f'{1.2246467991473532e-16:.30f}'.rstrip('0')})"
+    assert _coord(0.0) == "0.0"
+    assert _coord(10) == "10.0"
+    assert _coord(-4.0) == "(0 - 4.0)"
+
     shape = VectorShape()
     shape.add_filled_box("w/2", 0, "w", 10, [1., 0., 0., 1.])
     assert len(shape.verts) == 4 and len(shape.indices) == 6
@@ -157,13 +177,13 @@ if __name__ == "__main__":
     shape.add_outline("x1", "y1", "x2", "y2", 2.0, [0., 0., 0., 1.])
     assert len(shape.verts) == 16 and len(shape.indices) == 24
     assert shape.verts[1][0] == "(x1 + 2.0)"
-    assert shape.verts[8][0] == "(x2 + -2.0)"
-    assert shape.verts[12][1] == "(y2 + -2.0)"
+    assert shape.verts[8][0] == "(x2 + (0 - 2.0))"
+    assert shape.verts[12][1] == "(y2 + (0 - 2.0))"
 
     shape = VectorShape()
     shape.add_line(0., 0., 10., 0., 4., [1., 1., 1., 1.])
     assert len(shape.verts) == 4 and len(shape.indices) == 6
-    assert shape.verts[0][1] == "2.0" and shape.verts[2][1] == "-2.0"
+    assert shape.verts[0][1] == "2.0" and shape.verts[2][1] == "(0 - 2.0)"
 
     shape = VectorShape()
     shape.add_radial_glow("w/2", "h/2", "w", "h", 12, 0., math.pi * 2., [1., 0., 0., 1.])

+ 6 - 0
bin/app/src/error.rs

@@ -146,6 +146,12 @@ pub enum Error {
 
     #[error("SQL database error")]
     TursoErr = 49,
+
+    #[error("Unsupported node type")]
+    UnsupportedNodeType = 50,
+
+    #[error("Node not removable")]
+    NodeNotRemovable = 51,
 }
 
 impl From<kvdb_overlay::Error> for Error {

+ 2 - 1
bin/app/src/main.rs

@@ -197,10 +197,11 @@ impl God {
         {
             let sg_root = sg_root.clone();
             let ex = bg_ex.clone();
+            let renderer = app.renderer.clone();
             let redraw = app.redraw_trigger.clone();
             let zmq_task = bg_ex.spawn(async {
                 i!("Enabled net debugging backend in this build");
-                let zmq_rpc = ZeroMQAdapter::new(sg_root, redraw, ex).await;
+                let zmq_rpc = ZeroMQAdapter::new(sg_root, renderer, redraw, ex).await;
                 zmq_rpc.run().await;
             });
             bg_runtime.push_task(zmq_task);

+ 106 - 16
bin/app/src/net.rs

@@ -22,15 +22,48 @@ use std::{io::Cursor, sync::Arc};
 use zeromq::{Socket, SocketRecv, SocketSend};
 
 use crate::{
+    app::node::{create_layer, create_vector_art},
     error::{Error, Result},
     expr::{decompile, Compiler},
-    gfx::gfxtag,
+    gfx::{gfxtag, Renderer},
     prop::{PropertyType, Role},
-    scene::{SceneNodeId, SceneNodePtr, ScenePath, Slot},
-    ui::{RedrawTrigger, ShapeVertex, VectorShape},
+    scene::{Pimpl, SceneNodeId, SceneNodePtr, SceneNodeType, ScenePath, Slot},
+    ui::{
+        get_ui_object3, get_ui_object_ptr, Layer, RedrawTrigger, ShapeVertex, VectorArt,
+        VectorShape,
+    },
     ExecutorPtr,
 };
 
+/// Stop the UI tasks and clear the buffers of a subtree about to be
+/// removed at runtime, mirroring Window::stop(). Only pimpl types with a
+/// UIObject mapping are touched; others (Window, Setting, plugins, Null)
+/// keep their tasks until process exit.
+fn stop_ui_subtree(node: &SceneNodePtr) {
+    if matches!(
+        node.pimpl(),
+        Pimpl::Layer(_) |
+            Pimpl::ScrollLayer(_) |
+            Pimpl::VectorArt(_) |
+            Pimpl::Text(_) |
+            Pimpl::TextScramble(_) |
+            Pimpl::Edit(_) |
+            Pimpl::ChatView(_) |
+            Pimpl::Image(_) |
+            Pimpl::Video(_) |
+            Pimpl::Button(_) |
+            Pimpl::EmojiPicker(_) |
+            Pimpl::Shortcut(_) |
+            Pimpl::Menu(_) |
+            Pimpl::TokenTable(_)
+    ) {
+        get_ui_object3(node).stop();
+    }
+    for child in node.get_children() {
+        stop_ui_subtree(&child);
+    }
+}
+
 const USE_IPV6: bool = true;
 
 #[derive(Debug, SerialDecodable)]
@@ -79,6 +112,7 @@ pub struct ZeroMQAdapter {
     slot_recvr: Option<mpsc::Receiver<(Vec<u8>, Vec<u8>)>>,
     */
     sg_root: SceneNodePtr,
+    renderer: Renderer,
     redraw: RedrawTrigger,
     ex: ExecutorPtr,
 
@@ -87,7 +121,12 @@ pub struct ZeroMQAdapter {
 }
 
 impl ZeroMQAdapter {
-    pub async fn new(sg_root: SceneNodePtr, redraw: RedrawTrigger, ex: ExecutorPtr) -> Arc<Self> {
+    pub async fn new(
+        sg_root: SceneNodePtr,
+        renderer: Renderer,
+        redraw: RedrawTrigger,
+        ex: ExecutorPtr,
+    ) -> Arc<Self> {
         let mut zmq_rep = zeromq::RepSocket::new();
         if USE_IPV6 {
             zmq_rep.bind("tcp://[::]:9484").await.unwrap();
@@ -104,6 +143,7 @@ impl ZeroMQAdapter {
 
         Arc::new(Self {
             sg_root,
+            renderer,
             redraw,
             ex,
             zmq_rep: Mutex::new(zmq_rep),
@@ -349,21 +389,71 @@ impl ZeroMQAdapter {
                 }
             }
             Command::AddNode => {
-                /*
-                let node_name = String::decode(&mut cur).unwrap();
-                let node_type = SceneNodeType::decode(&mut cur).unwrap();
-                debug!(target: "req", "{:?}({}, {:?})", cmd, node_name, node_type);
+                let parent_path: ScenePath = String::decode(&mut cur)?.parse()?;
+                let node_name = String::decode(&mut cur)?;
+                let node_type = SceneNodeType::decode(&mut cur)?;
+                debug!(target: "req", "{cmd:?}({parent_path}, {node_name}, {node_type:?})");
 
-                let node_id = scene_graph.add_node(&node_name, node_type).id;
-                node_id.encode(&mut reply).unwrap();
-                */
+                let parent = self.sg_root.lookup_node(parent_path).ok_or(Error::NodeNotFound)?;
+
+                if parent.get_children().iter().any(|c| c.name == node_name) {
+                    return Err(Error::NodeSiblingNameConflict)
+                }
+
+                let renderer = self.renderer.clone();
+                let redraw = self.redraw.clone();
+                let node = match node_type {
+                    SceneNodeType::Layer => {
+                        create_layer(&node_name)
+                            .setup(|me| Layer::new(me, renderer.clone(), redraw.clone()))
+                            .await
+                    }
+                    SceneNodeType::VectorArt => {
+                        create_vector_art(&node_name)
+                            .setup(|me| VectorArt::new(me, renderer.clone(), redraw.clone()))
+                            .await
+                    }
+                    _ => return Err(Error::UnsupportedNodeType),
+                };
+
+                // Hold the guard over the link so the triggered pass sees
+                // the attached node.
+                let _atom = self.redraw.make_guard(gfxtag!("ZeroMQAdapter::AddNode"));
+                parent.link(node.clone());
+                node.id.encode(&mut reply).unwrap();
+
+                // Arm the pimpl's OnModify handlers (redraw on property
+                // change) exactly like window-owned nodes. The task keeps a
+                // strong ref so an immediate RemoveNode cannot drop the node
+                // out from under start().
+                let node2 = node.clone();
+                let ex2 = self.ex.clone();
+                self.ex
+                    .spawn(async move {
+                        let obj = get_ui_object_ptr(&node2);
+                        obj.start(ex2).await
+                    })
+                    .detach();
             }
             Command::RemoveNode => {
-                /*
-                let node_id = SceneNodeId::decode(&mut cur).unwrap();
-                debug!(target: "req", "{:?}({})", cmd, node_id);
-                scene_graph.remove_node(node_id)?;
-                */
+                let node_path: ScenePath = String::decode(&mut cur)?.parse()?;
+                debug!(target: "req", "{cmd:?}({node_path})");
+
+                let node = self.sg_root.lookup_node(node_path).ok_or(Error::NodeNotFound)?;
+
+                // The scene root has no parent, removal is meaningless.
+                if Arc::ptr_eq(&node, &self.sg_root) {
+                    return Err(Error::NodeNotRemovable)
+                }
+
+                // Tear down the subtree's UI tasks and buffers before
+                // unlinking, mirroring Window::stop(). Pimpl types without
+                // a UIObject mapping (Window, Setting, plugins, ...) keep
+                // their tasks until process exit.
+                stop_ui_subtree(&node);
+
+                node.unlink();
+                self.redraw.trigger();
             }
             Command::RenameNode => {
                 /*

+ 16 - 3
bin/app/src/ui/mod.rs

@@ -312,14 +312,27 @@ impl<T: Send + Sync + 'static> OnModify<T> {
                 } else {
                     t!(
                         "Property {:?} modified -> triggering {:?} [depend_idx={idx}, role={role:?}]",
-                        prop_weak.upgrade().unwrap(),
+                        prop_weak.upgrade(),
                         prop
                     );
                 }
 
                 let Some(self_) = me.upgrade() else {
-                    // Should not happen
-                    panic!("{:?} self destroyed before modify_task was stopped!", prop);
+                    // Normally unreachable: an owner is dropped only after
+                    // stop() cleared its modify tasks, so an alive task
+                    // implies an alive owner. Runtime node removal
+                    // (netdebug rmnode) breaks that: stop() merely drops
+                    // the Task handles, and a future that is mid-poll on
+                    // a worker thread keeps running until its next yield,
+                    // which can carry it past this upgrade after the last
+                    // Arc is gone (this future holds only weak refs).
+                    // With no owner there is nothing left to notify, so
+                    // exit quietly instead of panicking.
+                    warn!(
+                        target: "scene::on_modify",
+                        "Property {:?} owner destroyed before modify_task was stopped", prop
+                    );
+                    return
                 };
 
                 //debug!(target: "app", "property modified");

+ 25 - 25
openspec/changes/app-pydrk-cli/tasks.md

@@ -4,7 +4,7 @@ Work happens in `bin/app/`. For live tests run `make dev` in a second
 terminal and keep it running; every `python -m pydrk ...` line below is
 run from `bin/app`.
 
-- [ ] 1.1 Create `pydrk/cli.py` (argparse `main()`, global `--addr`/`--port`
+- [x] Create `pydrk/cli.py` (argparse `main()`, global `--addr`/`--port`
   defaulting to `127.0.0.1:9484`, subcommand dispatch, top-level
   try/except printing `error: <name>` and exiting 1) and
   `pydrk/__main__.py` (`from pydrk import cli; cli.main()`). Implement
@@ -12,23 +12,23 @@ run from `bin/app`.
   `hello` against the running app; `python -m pydrk ping --port 9999`
   prints an error naming `127.0.0.1:9999` and exits non-zero. Commit as
   `app: add pydrk CLI skeleton with ping`.
-- [ ] 1.2 Implement `ls [path]`: child rows as `name <id> type` (type via
+- [x] Implement `ls [path]`: child rows as `name <id> type` (type via
   `SceneNodeType` names) followed by property rows `name: type = value`
   (value from `get_property_value`, exprs shown as their source,
   `<shape>` placeholder for shapes). Verify: `python -m pydrk ls /`
   lists `setting` and `window` plus the root's properties;
   `python -m pydrk ls /nope` prints `node_not_found`. Commit as
   `app: pydrk CLI ls command`.
-- [ ] 1.3 Implement `tree [path] [--depth N]` by wiring
+- [x] Implement `tree [path] [--depth N]` by wiring
   `pydrk.print_tree.print_tree` into the CLI. Verify: `python -m pydrk
   tree / --depth 2` prints two levels with properties, signals and
   methods. Commit as `app: pydrk CLI tree command`.
-- [ ] 1.4 Implement `props <path>`: one block per property showing name,
+- [x] Implement `props <path>`: one block per property showing name,
   type, subtype, array_len (mark unbounded when 0), null/expr allowance,
   min/max range when present, enum items when present, ui_name and desc.
   Verify: `python -m pydrk props /window/content` shows `alpha` with its
   `[0.0, 1.0]` range. Commit as `app: pydrk CLI props command`.
-- [ ] 1.5 Implement `get [path] PROP [idx]` with the shared positional
+- [x] Implement `get [path] PROP [idx]` with the shared positional
   grammar from design D8 (right-to-left parse via `parse_get_args`, path
   optional defaulting to `/` in one-shot mode, trailing integer = index):
   one line per index annotated `value`/`expr`/`null`/`unset`, only the
@@ -37,7 +37,7 @@ run from `bin/app`.
   /window/content alpha` prints `0: value 1.0`; `python -m pydrk get
   /window/content rect 2` prints only index 2. Commit as
   `app: pydrk CLI get command`.
-- [ ] 1.6 Implement `show [path] PROP`: the full single-property view
+- [x] Implement `show [path] PROP`: the full single-property view
   from design D8 — metadata block (name, type, subtype, array_len,
   null/expr allowance, min/max range, enum items, ui_name, desc,
   depends) followed by the per-index values with statuses. Verify: `python
@@ -45,7 +45,7 @@ run from `bin/app`.
   `[0.0, 1.0]` range and then `0: value 1.0`; `python -m pydrk show
   /window/content no_such_prop` prints `property_not_found`. Commit as
   `app: pydrk CLI show command`.
-- [ ] 1.7 Fix `Api.get_method()` in `pydrk/api.py`: decode the results as
+- [x] Fix `Api.get_method()` in `pydrk/api.py`: decode the results as
   `Option<Vec<CallArg>>` (read the u8 tag, then the array only when
   some) per design D6. Implement `methods <path>` (name + arg/result
   signature per method) and `signals <path>` (signal names). Verify:
@@ -53,7 +53,7 @@ run from `bin/app`.
   with its `str` result signature, and `python -m pydrk tree /plugin/drk`
   no longer truncates method results. Commit as
   `app: fix pydrk get_method result decoding, add methods/signals commands`.
-- [ ] 1.8 Add `--selftest` handling in `cli.py`: a `run_selftests()`
+- [x] Add `--selftest` handling in `cli.py`: a `run_selftests()`
   function with assert-based checks of the pure helpers introduced so
   far (path/type/value formatting, `parse_get_args`), so `python -m
   pydrk.cli --selftest` prints `cli self-test OK` without a running app.
@@ -61,7 +61,7 @@ run from `bin/app`.
 
 ## 2. Typed property setting (Python only)
 
-- [ ] 2.1 Implement the typed value table from design D8: a pure
+- [x] Implement the typed value table from design D8: a pure
   `encode_set_value(api, path, prop_meta, token, index)` helper that
   looks up the property via `get_properties` and dispatches to the right
   `Api.set_property_*` call (bool/uint32/float32/str/enum/scene_node_id,
@@ -74,7 +74,7 @@ run from `bin/app`.
   restores it; `python -m pydrk get /window/content/chat is_visible`
   round-trips both values. Add `parse_set_args` cases to `--selftest`.
   Commit as `app: pydrk CLI typed set command`.
-- [ ] 2.2 Add `--expr` to `set` (sends via `set_property_expr`). Verify
+- [x] Add `--expr` to `set` (sends via `set_property_expr`). Verify
   live: `python -m pydrk set /window/content rect 2 "w/2" --expr`
   exits 0 and `python -m pydrk get /window/content rect 2` shows
   `2: expr "w/2"`. Commit as `app: pydrk CLI set --expr`.
@@ -89,22 +89,22 @@ run from `bin/app`.
 
 ## 3. netdebug backend: node creation and removal (Rust)
 
-- [ ] 3.1 Add `Error::UnsupportedNodeType = 50` and
+- [x] Add `Error::UnsupportedNodeType = 50` and
   `Error::NodeNotRemovable = 51` (used to reject removing the scene
   root) to `bin/app/src/error.rs` following the existing variant style.
   Verify: `make compile-dev` succeeds. Commit as
   `app: add netdebug error codes for node create/remove`.
-- [ ] 3.2 Mirror the two codes in pydrk: `ErrorCode` constants, `exc.py`
+- [x] Mirror the two codes in pydrk: `ErrorCode` constants, `exc.py`
   exception classes, `_make_request` match arms raising them. Verify:
   `python -m pydrk.cli --selftest` and `python -m pydrk ping` still
   work. Commit as `app: pydrk error codes for node create/remove`.
-- [ ] 3.3 Thread the renderer into the adapter per design D5: add the
+- [x] Thread the renderer into the adapter per design D5: add the
   `renderer: Renderer` field to `ZeroMQAdapter`, change
   `ZeroMQAdapter::new` to take it, update the call site in `main.rs`
   (it already has `app.renderer` in scope). Verify: `make compile-dev`
   succeeds and `python -m pydrk ping` still works. Commit as
   `app/netdebug: pass renderer into ZeroMQAdapter`.
-- [ ] 3.4 Implement the `AddNode` arm per design D2: decode
+- [x] Implement the `AddNode` arm per design D2: decode
   `(parent_path, name, node_type)`; look up the parent; reject duplicate
   sibling names with `NodeSiblingNameConflict`; match `Layer` and
   `VectorArt` through `create_layer`/`create_vector_art` +
@@ -112,7 +112,7 @@ run from `bin/app`.
   types with `UnsupportedNodeType`; reply the id. Verify: `make
   compile-dev` succeeds. Commit as
   `app/netdebug: path-based AddNode for layer and vector_art`.
-- [ ] 3.5 Implement the `RemoveNode` arm per design D3: decode
+- [x] Implement the `RemoveNode` arm per design D3: decode
   `(node_path)`; reject `/` with `NodeNotRemovable`; look up the node;
   `unlink()`; `redraw.trigger()`. No restrictions on which nodes are
   removable — full scene-graph access is intentional for this debugging
@@ -121,7 +121,7 @@ run from `bin/app`.
 
 ## 4. Node lifecycle commands (Python)
 
-- [ ] 4.1 Add `Api.add_node(parent_path, name, node_type)` to `api.py`
+- [x] Add `Api.add_node(parent_path, name, node_type)` to `api.py`
   and the `mknode <parent_path> <name> <type>` subcommand accepting only
   `layer`/`vector_art` (anything else fails locally with
   `unsupported node type`), printing `id=... path=...`. Verify live
@@ -132,7 +132,7 @@ run from `bin/app`.
   factory properties including `shape`; `python -m pydrk mknode
   /window/content dbg layer` again prints `node_sibling_name_conflict`.
   Commit as `app: pydrk CLI mknode command`.
-- [ ] 4.2 Add `Api.remove_node(node_path)` and the `rmnode <path>`
+- [x] Add `Api.remove_node(node_path)` and the `rmnode <path>`
   subcommand (full graph access, documented in `--help` as runtime-only).
   Verify live: create `dbg` as above then `python -m pydrk rmnode
   /window/content/dbg` — `ls /window/content` no longer lists it and the
@@ -141,7 +141,7 @@ run from `bin/app`.
   `python -m pydrk rmnode /` prints `node_not_removable` and `python -m
   pydrk ls /` still lists everything. Commit as
   `app: pydrk CLI rmnode command`.
-- [ ] 4.3 Delete the dead client methods from `api.py` listed in design
+- [x] Delete the dead client methods from `api.py` listed in design
   D6 plus the legacy `vertex()`/`face()` helpers. Verify: `grep -rn
   "link_node\|scan_dangling\|add_property" bin/app/pydrk bin/app/script`
   is empty, `python -m pydrk.cli --selftest` passes, and the commands
@@ -150,7 +150,7 @@ run from `bin/app`.
 
 ## 5. Shape data (Python)
 
-- [ ] 5.1 Implement `set-shape <path> [--prop NAME] [--index N]` with the
+- [x] Implement `set-shape <path> [--prop NAME] [--index N]` with the
   `--box` flag from design D11 (argparse `nargs=8`, `action="append"`),
   building on `pydrk.vector_shape.VectorShape`, with the
   `< 65536` vertex guard. Verify live: `mknode /window/content dbg
@@ -172,7 +172,7 @@ run from `bin/app`.
 
 ## 6. Method calls (Python)
 
-- [ ] 6.1 Implement `call <path> <method> [ARGS...]`: fetch the
+- [x] Implement `call <path> <method> [ARGS...]`: fetch the
   signature with `Api.get_method`, encode each positional arg per its
   declared type (`uint32`/`uint64`/`float32`/`bool`/`str`; `hash` as
   64-char hex → 32 bytes), reject wrong arg counts or unparseable
@@ -186,12 +186,12 @@ run from `bin/app`.
 
 ## 7. Interactive shell
 
-- [ ] 7.1 Implement `resolve_path(cwd_tokens, arg)` exactly per design
+- [x] Implement `resolve_path(cwd_tokens, arg)` exactly per design
   D9 plus its `--selftest` cases (absolute paths, `..`, `.`, empty,
   relative tokens, leading/trailing slashes). Verify: `python -m
   pydrk.cli --selftest` passes with no app running. Commit as
   `app: pydrk CLI path resolution helper`.
-- [ ] 7.2 Implement the shell per design D9: entered when `python -m
+- [x] Implement the shell per design D9: entered when `python -m
   pydrk` runs with no subcommand; prompt `pydrk:/window/content> `;
   `shlex.split` line tokenization; dispatch each line to the same
   per-command handlers as one-shot mode, with the optional-path grammar
@@ -205,7 +205,7 @@ run from `bin/app`.
   prints `false`, `set is_visible true`, `cd ..`, `pwd`, `get
   no_such_prop` prints `property_not_found` and the shell survives,
   `exit`. Commit as `app: pydrk interactive shell mode`.
-- [ ] 7.3 Implement the readline completer per design D10: command-name
+- [x] Implement the readline completer per design D10: command-name
   completion for the first token, live child-path completion (dir part +
   prefix, `api.get_children`, per-prompt cache cleared after each
   executed command), completion of the first positional argument of
@@ -219,7 +219,7 @@ run from `bin/app`.
 
 ## 8. Final verification
 
-- [ ] 8.1 Run the full junior walkthrough end-to-end against a fresh
+- [x] Run the full junior walkthrough end-to-end against a fresh
   `make dev` instance: `ping`; `ls /`; `tree / --depth 2`; enter the
   shell; `cd /window/content`; `mknode dbg layer` style flow for layer +
   vector_art (via subcommand or shell); set `rect` and `is_visible`;
@@ -227,7 +227,7 @@ run from `bin/app`.
   layer and confirm the window redraws clean with no leftover geometry.
   Fix anything broken found during the walkthrough and amend the
   selftest. Commit as `app: pydrk CLI end-to-end walkthrough fixes`.
-- [ ] 8.2 Final gates: `make compile-dev` succeeds with no warnings
+- [x] Final gates: `make compile-dev` succeeds with no warnings
   introduced; `python -m pydrk.cli --selftest` and `python -m
   pydrk.vector_shape` pass; `python -m pydrk ping` works; `git status`
   shows a clean tree after the last commit. Confirm the spec scenarios