فهرست منبع

openspec: archive app-pydrk-cli proposal

darkfi 1 هفته پیش
والد
کامیت
f816644cf7

+ 0 - 2
openspec/changes/app-pydrk-cli/.openspec.yaml

@@ -1,2 +0,0 @@
-schema: spec-driven
-created: 2026-08-31

+ 0 - 381
openspec/changes/app-pydrk-cli/design.md

@@ -1,381 +0,0 @@
-## Context
-
-The netdebug backend (`bin/app/src/net.rs`, feature `enable-netdebug`,
-dev builds only) serves ZeroMQ REQ/REP on `:9484` and PUB on `:9485`.
-Requests are 2 frames `[cmd:1][payload]`, replies `[errc:1][body]`, with
-`darkfi-serial` encoding inside frames. The Python client `pydrk`
-(`bin/app/pydrk/`) mirrors the codec (`serial.py`), the command/error
-tables (`api.py`), and shape builders (`vector_shape.py`).
-
-The scene graph is now a tree of `SceneNode` addressed by `/`-separated
-paths (`ScenePath`), looked up from `sg_root` by walking child names.
-Nodes are built by Rust factories (`create_layer`, `create_vector_art`,
-... in `src/app/node.rs`) that attach the factory's properties, then
-`.setup(|me| Layer::new(me, renderer, redraw)).await` installs the pimpl,
-then `parent.link(node)` attaches it. The old central node registry is
-gone, which is why the id-based `AddNode`/`LinkNode`/... arms in
-`net.rs` are commented out — they reference a `scene_graph` object that
-no longer exists. `SceneNode::link()` asserts the child has no parent
-yet, and the pimpl types clean up GPU draw calls in `Drop`
-(`VectorArt::drop` calls `replace_draw_calls`).
-
-Only a subset of `Command` arms are live today: `Hello`, `GetChildren`,
-`GetProperties`, `GetPropertyValue`, `SetPropertyValue` (incl. expr
-compile and full `VectorShape` push), `GetSignals`, `RegisterSlot`,
-`GetSlots`, `GetMethods`, `GetMethod`, `CallMethod`.
-
-The existing entry points are example scripts (`bin/app/script/`) and the
-`pydrk` library — no CLI exists. `pydrk` has no packaging; it is run with
-cwd `bin/app`. Its sole dependency is pyzmq. Self-testing convention is an
-`if __name__ == "__main__":` block (see `vector_shape.py`).
-
-## Goals / Non-Goals
-
-**Goals:**
-
-- One-shot CLI subcommands and an interactive shell over the same code,
-  usable by a junior dev to explore and drive a running dev-mode app.
-- Wire-level creation/removal of `Layer` and `VectorArt` nodes with full
-  factory properties and live pimpls, so shapes drawn over the wire show
-  up and clean up correctly.
-- Keep both sides of the protocol inside this repo and in lockstep.
-
-**Non-Goals:**
-
-- No packaging, no new Python dependencies (stdlib `readline` +
-  `argparse` + `shlex`; pyzmq stays the only third-party import).
-- No wire support for widget types needing app context (`Text`, `Edit`,
-  `ChatView`, plugins, ...). Only `Layer` and `VectorArt`.
-- No rename/relink of pre-existing nodes; no event subscription commands
-  (the PUB-side `EventLoop` in `pydrk/event.py` stays as is).
-- No changes to release builds; netdebug stays behind the dev feature.
-
-## Decisions
-
-### D1: Node creation is path-based and atomic
-
-`AddNode` carries `(parent_path: String, name: String,
-node_type: SceneNodeType)` and replies the new `node_id: u32`. The server
-resolves the parent, builds the node, links it, and registers it — one
-round trip, no dangling state.
-
-Alternative considered: resurrect the old two-step
-`AddNode(name, type) -> id` + `LinkNode(child_id, parent_id)`. That needs
-a server-side `id -> node` registry to keep dangling nodes addressable,
-which is exactly the machinery that was deleted with the central
-registry. The CLI never wants a dangling node. Atomic attach is simpler
-and matches the path-addressed model.
-
-### D2: Only `Layer` and `VectorArt` factories are exposed
-
-Both pimpls take only `(SceneNodeWeak, Renderer, RedrawTrigger)` — the
-two handles the adapter can carry. The `AddNode` arm (conceptually):
-
-```rust
-Command::AddNode => {
-    let parent_path: ScenePath = String::decode(&mut cur).unwrap().parse()?;
-    let node_name = String::decode(&mut cur).unwrap()?;
-    let node_type = SceneNodeType::decode(&mut cur).unwrap();
-    debug!(target: "req", "{cmd:?}({parent_path}, {node_name}, {node_type:?})");
-
-    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 node = match node_type {
-        SceneNodeType::Layer => {
-            create_layer(&node_name)
-                .setup(|me| Layer::new(me, self.renderer.clone(), self.redraw.clone()))
-                .await
-        }
-        SceneNodeType::VectorArt => {
-            create_vector_art(&node_name)
-                .setup(|me| VectorArt::new(me, self.renderer.clone(), self.redraw.clone()))
-                .await
-        }
-        _ => return Err(Error::UnsupportedNodeType),
-    };
-
-    self.redraw.make_guard(gfxtag!("ZeroMQAdapter::AddNode"));
-    parent.link(node.clone());
-    node.id.encode(&mut reply).unwrap();
-}
-```
-
-Notes: `SceneNode::setup` must run before `link` (it asserts
-`strong_count == 1`), same ordering as every schema call site. After
-link, the pimpl's `start(ex)` is spawned (`self.ex.spawn(...)`) so
-`OnModify` handlers (redraw on property change) are armed exactly like
-window-owned nodes — copy the pattern from `src/ui/win/mod.rs`
-(`obj.start(ex.clone()).await`). Factories and pimpls are imported from
-where the schema uses them (`crate::app::node::{create_layer,
-create_vector_art}`, `crate::ui::{Layer, VectorArt}`).
-
-Other node types fail with a new `Error::UnsupportedNodeType` (D4). The
-client additionally rejects unknown type strings locally before sending
-(friendlier message, no round trip).
-
-### D3: Removal unlinks the subtree, with full graph access
-
-`RemoveNode` carries `(node_path: String)`. Flow: reject `/` (the root
-has no parent, so removal is meaningless) with `Error::NodeNotRemovable`;
-look up the node; `node.unlink()`; `self.redraw.trigger()`. When the
-parent drops its last `Arc`, the pimpl `Drop` impls clear GPU draw calls
-and the `OnModify` tasks die with the node.
-
-This is a debugging tool, so removal is deliberately NOT restricted:
-built-in nodes are removable exactly like wire-created ones, giving the
-tool full access to the scene graph. The safety story is that netdebug
-is dev-only and every change is runtime-only — restarting the app
-restores the schema-built tree. Removing nodes the render pass depends
-on (e.g. `/window`) can leave a blank window or, in the worst case, an
-app panic; that is an accepted, documented trade-off for a debug tool,
-and the app is simply restarted.
-
-Alternative considered: restrict removal to wire-created nodes (a
-`wire_nodes` id set in the adapter). Rejected per requirements — the
-point of the tool is to experiment on the real tree, including taking
-subtrees away.
-
-### D4: Two new error codes, mirrored on both sides
-
-`Error::UnsupportedNodeType = 50` and `Error::NodeNotRemovable = 51` (the
-latter used only to reject removing the scene root) in `src/error.rs`
-(the enum ends at 49). pydrk mirrors them: `ErrorCode` entries + `exc.py`
-classes + `_make_request` match arms. Existing errors cover everything
-else (sibling conflict → `NodeSiblingNameConflict`, missing parent →
-`NodeNotFound`).
-
-### D5: The adapter carries the `Renderer`
-
-`ZeroMQAdapter::new(sg_root, renderer, redraw, ex)` — `main.rs` already
-has `app.renderer` in scope where the adapter is spawned (currently
-`main.rs:196-207`), so this is a one-line call-site change plus the
-struct field.
-
-### D6: pydrk client updates
-
-- New: `add_node(parent_path, name, node_type) -> int` and
-  `remove_node(node_path)` in `api.py`, encoded per D1/D3.
-- Removed: dead client methods whose server arms are gone (`get_info`,
-  `get_parents`, `link_node`, `unlink_node`, `rename_node`,
-  `scan_dangling`, `lookup_node_id`, `add_property`, `unregister_slot`,
-  `lookup_slot_id`) and the `vertex()`/`face()` legacy mesh helpers.
-  They misparse the empty errc=0 replies and only encode the removed
-  registry world.
-- Fixed: `get_method()` result decoding. The server encodes
-  `Option<Vec<CallArg>>` (`net.rs` `method.result.encode(...)`);
-  `api.py` currently decodes a bare array, which silently truncates for
-  `Some(...)` results. Read the option tag first:
-
-```python
-args = serial.decode_arr(cur, read_arg)
-results = serial.decode_opt(cur, lambda cur: serial.decode_arr(cur, read_arg))
-```
-
-### D7: CLI architecture — one module, shared handlers
-
-```
-bin/app/pydrk/cli.py      argparse subcommands + handler functions + REPL + completer
-bin/app/pydrk/__main__.py import cli; cli.main()
-```
-
-- `main()` builds a parser with `--addr`/`--port` and one subparser per
-  command. With a subcommand: run the handler once, print errors as
-  `error: <name>`, `sys.exit(1)`. With no subcommand: enter the shell.
-- Handlers are small functions `cmd_ls(api, args)`, `cmd_set(api, args)`
-  ... shared by both modes. The shell re-parses each input line with
-  `shlex.split` and dispatches to the same per-command arg parsers, so
-  usage/help stays single-sourced in argparse.
-- Every handler receives paths already resolved to absolute (see D9), so
-  handlers never see cwd.
-- Errors: pydrk exceptions are caught at the dispatch boundary; one-shot
-  mode exits non-zero, shell mode prints and continues.
-
-### D8: Typed property get/set/show driven by server metadata
-
-`set`, `get` and `show` share one positional grammar with optional parts:
-`set [path] PROP [idx] VAL`, `get [path] PROP [idx]`, `show [path] PROP`.
-Positionals are parsed right-to-left so no flags are needed for the
-common cases (`VAL` is always last; an integer right before it is the
-index; what is left at the front is the path, joined with `/` when it
-spans several tokens). Flags (`--expr`) are stripped first:
-
-```python
-def parse_set_args(tokens, default_path):
-    value = tokens.pop()
-    idx = 0
-    if tokens and tokens[-1].isdigit():
-        idx = int(tokens.pop())
-    if not tokens:
-        raise UsageError("missing property name")
-    prop = tokens.pop()
-    path = resolve_path(default_path, "/".join(tokens)) if tokens else default_path
-    return (path, prop, idx, value)
-```
-
-`parse_get_args`/`parse_show_args` are the same idea without `VAL`
-(property names are never integers, so the right-to-left split is
-unambiguous; the one-shot mode passes `default_path="/"`). `show` prints
-one property's metadata block (same fields as `props`, plus depends)
-followed by its per-index values — the "everything about this property"
-view. `set` fetches `api.get_properties(path)` once, finds the property,
-and encodes by its declared type:
-
-| declared type | encoding | value parsing |
-|---|---|---|
-| bool | `set_property_bool` | `true`/`false` |
-| uint32 / scene_node_id | `set_property_u32` / `set_property_node_id` | `int(token, 0)` |
-| float32 | `set_property_f32` | `float(token)` |
-| str | `set_property_str` | token as-is (quote in shell for spaces) |
-| enum | `set_property_enum` | must be in `enum_items`, else local error |
-| null literal `null` | `set_property_null` | n/a |
-
-The `--expr` flag switches to `set_property_expr` and sends the value as
-expr source (the server compiles with the const-free compiler; `w`/`h`
-are the available globals). Enum membership and numeric parsing are
-validated client-side so mistakes produce a local usage error instead of
-a wire round trip. This "ask the server for the type" approach means
-juniors never have to know the type — `set alpha 0.5` just works.
-
-### D9: Interactive shell
-
-State: `Shell` class holding the `Api`, `cwd: list[str]` (tokens, `[]` =
-root), and the completion cache. Prompt: `pydrk:/window/content> `
-(rendered from cwd). Builtins: `cd` (no arg → `/`; `..` pops; otherwise
-resolve and verify with `api.get_children(parent)` before committing),
-`pwd`, `exit`/`quit` (and EOF). Everything else dispatches through the
-same handlers as one-shot mode.
-
-Path resolution (pure function, unit-tested):
-
-```python
-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)
-```
-
-Absolute arguments (leading `/`) pass through unchanged; all others are
-taken relative to cwd. The `cd` of a resolvable-but-childless path is
-still valid (leaves can be cwd for `set`/`get`); `cd` into a
-non-resolvable path fails with `node_not_found` and leaves cwd alone —
-verified by looking the path up (`api.get_children` of the parent, or a
-cheap `api.get_properties(path)`).
-
-Line tokenization is `shlex.split` so values containing spaces can be
-quoted: `set nick "hello world"`.
-
-### D10: Tab completion via stdlib readline
-
-A `Completer` class registered with `readline`:
-
-- First token: complete from the command-name table.
-- `get`/`set`/`show` first argument: complete from the union of the cwd
-  node's property names (`api.get_properties`, cached) and its child node
-  paths — both are valid leading tokens under the optional-path grammar
-  of design D8.
-- Any other argument: complete child node names. Split the token into
-  dir part + prefix (last `/`), resolve the dir part against cwd, fetch
-  `api.get_children` (cached), return `name + "/"` matches. Matches come
-  from the live app, so freshly `mknode`-ed nodes complete immediately.
-- The cache is a dict `path -> [child names / prop names]` cleared at
-  every prompt redraw (i.e. after each executed command), so mutations
-  are picked up without staleness bugs. One REQ per uncached directory
-  per line — the REQ/REP socket is lockstep anyway.
-- `import readline` is wrapped in try/except; without it the shell runs
-  without completion (e.g. exotic platforms). Dev target is Linux.
-
-No `rlcompleter`/`prompt_toolkit`: zero new dependencies, and
-`Completer` needs app state (cwd, live children) that the generic
-completer doesn't have.
-
-### D11: `set-shape` composes `vector_shape.VectorShape` from flags
-
-Each primitive flag is `action="append"` and carries its colors inline as
-trailing `R G B A` float args (no hidden color state):
-
-```
---box X1 Y1 X2 Y2 R G B A
---gbox X1 Y1 X2 Y2 R G B A R G B A        (top color, bottom color)
---vgradient X1 Y1 X2 Y2 R G B A R G B A STRIPS GAMMA
---outline X1 Y1 X2 Y2 BORDERPX R G B A
---line X1 Y1 X2 Y2 THICKNESS R G B A
---glow CX CY W H SEGMENTS R G B A
-```
-
-Coordinates are passed through to `vector_shape` as-is: plain numbers
-are normalized to float literals, anything else (e.g. `w/2`, `h - 10`)
-is expr source — exactly what `VectorShape._coord` already does. Flags
-apply in command-line order via `shape.join(...)`. Client-side guard:
-`len(shape.verts) < 65536` before sending (indices are u16 on the wire).
-Then `shape.set(api, path, prop_name)` pushes it. Example:
-
-```
-python -m pydrk set-shape /window/content/dbg/art1 --box 0 0 w 10 1 0 0 1
-```
-
-### D12: Testing strategy
-
-- Rust: `make compile-dev` after every Rust task (per `bin/app/AGENTS.md`).
-- Python pure logic (path resolution, typed-value parsing, color/coord
-  parsing, shape flag composition) is exercised by
-  `python -m pydrk.cli --selftest` — an `if __name__ == "__main__"`-style
-  assert block, same convention as `python -m pydrk.vector_shape`.
-- Live behavior: run `make dev` in one terminal, CLI in another; each
-  task lists the exact commands and expected output. The drawing tasks
-  are verified by looking at the window.
-- Commits after every task, message prefix `app:` or `app/netdebug:`.
-
-## Risks / Trade-offs
-
-- [All mknode/rmnode changes are runtime-only and lost on restart] →
-  Documented behavior and the recovery path for destructive removals;
-  the schema-built tree is rebuilt on every launch.
-- [Removing a node the render pass depends on can blank the window or
-  panic the app] → Accepted for a dev-only debug tool with full graph
-  access; restart recovers. Documented in `rmnode --help`.
-- [Dropping a subtree relies on the parent holding the last strong ref]
-  → Verified live in the removal task (window shows removal + no stale
-  geometry). `OnModify` holds only weak refs; if anything is found
-  holding a strong ref, removal degrades to "hidden but alive", which is
-  still safe — escalate before shipping if observed.
-- [Shape eval errors are silent to the client] → The server logs a warn
-  and draws nothing; the CLI cannot see it. Documented in usage; the
-  invalid-expr rejection path (`sexpr_global_not_found`) is still
-  surfaced because it fails at compile time, before eval.
-- [readline differs on macOS/libedit] → Completion is best-effort and
-  guarded; Linux dev machines are the target.
-- [REQ/REP lockstep] → The shell is strictly one request at a time;
-  completion caches prevent request storms while tabbing.
-- [Protocol change for AddNode/RemoveNode payloads] → Both peers ship in
-  the same repo and the backend is dev-only; no migration needed. Old
-  clients against a new app fail fast on decode, not silently.
-
-## Migration Plan
-
-None. The netdebug backend is feature-gated out of release builds; dev
-workflows rebuild both peers from the same tree.
-
-## Open Questions
-
-- Should a `/debug` parent layer be created at app boot to host wire
-  nodes by default? Deferred: users can `mknode` anywhere under
-  `/window/content` today; adding a fixed parent is cosmetic and can be
-  a follow-up.
-- Should `pydrk/event.py`'s `EventLoop` keyboard path be repointed to a
-  live node path? Deferred: out of scope for this change (PUB-side
-  tooling unchanged).

+ 0 - 94
openspec/changes/app-pydrk-cli/proposal.md

@@ -1,94 +0,0 @@
-## Why
-
-The `enable-netdebug` backend (ZeroMQ REQ/REP on 9484, PUB on 9485) is the
-supported way to inspect and drive a running `app` GUI, but the only clients
-are ad-hoc scripts (`bin/app/script/`) and the `pydrk` library — there is no
-CLI. Worse, the node-mutation commands (`AddNode`, `LinkNode`, `RemoveNode`,
-...) in `bin/app/src/net.rs` were commented out when the central scene-graph
-registry was removed, so the `pydrk` client methods for them are dead: the
-server replies `errc=0` with an empty body and the client misparses it.
-Inspecting and experimenting with the live scene graph currently requires
-writing a custom Python script for every question. A junior dev learning the
-wallet UI has no safe, incremental tool to explore nodes, tweak properties,
-or draw shapes without recompiling the app.
-
-## What Changes
-
-- Re-enable wire-level node creation in `bin/app/src/net.rs` against the
-  current path-addressed tree model:
-  - `AddNode` becomes atomic and path-based: payload
-    `(parent_path, name, node_type)` → replies the new `node_id`; the node
-    is created via the existing Rust factories and linked immediately.
-    Supported types for v1: `Layer` and `VectorArt` (the two factories whose
-    pimpls only need `Renderer` + `RedrawTrigger`); other types fail with
-    `PropertyWrongType`-style rejection (see design for error choice).
-  - `RemoveNode` becomes path-based: payload `(node_path)`; unlinks the
-    subtree from its parent and triggers a redraw (existing `Drop` impls
-    clear GPU draw calls).
-  - `ZeroMQAdapter` gains the `Renderer` handle so `Layer::new` /
-    `VectorArt::new` pimpls can be wired for wire-created nodes.
-- Update `pydrk/api.py` to match: new `add_node(parent_path, name, node_type)`
-  and `remove_node(node_path)` methods; remove the dead id-based client
-  methods that no longer have server arms (`get_info`, `get_parents`,
-  `link_node`, `unlink_node`, `rename_node`, `scan_dangling`,
-  `lookup_node_id`, `add_property`, `unregister_slot`, `lookup_slot_id`).
-- Fix `pydrk` `get_method()` result parsing: the server sends
-  `Option<Vec<CallArg>>` but the client decodes a bare array, which
-  misparses any method that declares a result.
-- Add a `pydrk` CLI (`python -m pydrk ...` via a new `__main__.py` +
-  `cli.py`, argparse-based, only dependency stays pyzmq) with subcommands
-  for the whole scene-graph workflow: connectivity check, tree navigation
-  (`ls`, `tree`), display (`props`, `get`), property setting (`set`,
-  type-driven by server metadata, incl. exprs), node creation/removal
-  (`mknode`, `rmnode`), shape data (`set-shape` composing the existing
-  `pydrk.vector_shape` builders), and introspection of signals/methods
-  (`signals`, `methods`, `call`).
-- Add an interactive shell mode: running `python -m pydrk` with no
-  subcommand drops into a REPL over the same command set, maintaining a
-  current working node path (`cd`, `pwd`) so `ls` lists the current node's
-  children and properties and `set foo XXX` writes a property of the
-  current node. Tab completion (stdlib `readline`) completes command
-  names, node paths (children fetched live from the app), and property
-  names.
-
-Non-goals (recorded so they are not silently assumed): no packaging
-(pyproject/console script) — the CLI runs from `bin/app` like the existing
-scripts; no new prompt/REPL dependency (prompt_toolkit & co.) — stdlib
-`readline` only; no rename/link/unlink of pre-existing nodes; no new
-event/PUB commands; no changes to release builds (`enable-netdebug` stays
-dev-only).
-
-## Capabilities
-
-### New Capabilities
-- `pydrk-cli`: Command-line access to a running app's scene graph over the
-  netdebug backend, in one-shot and interactive forms — tree navigation and
-  display, property get/set (typed values and exprs), node creation/removal
-  for Layer and VectorArt, shape data composition, method introspection,
-  and a shell mode with `cd` and tab completion.
-
-### Modified Capabilities
-
-(none — `openspec/specs/` is empty; there are no existing main specs to
-modify.)
-
-## Impact
-
-- Rust: `bin/app/src/net.rs` (re-enable + redesign `AddNode`/`RemoveNode`,
-  carry `Renderer`), `bin/app/src/main.rs` (pass renderer into
-  `ZeroMQAdapter::new`), possibly `bin/app/src/error.rs` (only if a new
-  error code is needed — design prefers reusing existing ones).
-- Python: `bin/app/pydrk/api.py` (new/removed methods, `get_method` fix),
-  new `bin/app/pydrk/cli.py` and `bin/app/pydrk/__main__.py`; existing
-  modules (`serial.py`, `print_tree.py`, `vector_shape.py`, `exc.py`) are
-  reused as-is.
-- Wire protocol: payloads of commands 1 (`AddNode`) and 9 (`RemoveNode`)
-  change shape. Both sides live in this repo and ship together; the
-  netdebug backend is dev-only (feature-gated out of release builds), so
-  there are no compatibility constraints.
-- Docs: usage examples live in this change's design; `doc/src/arch/wallet.md`
-  still references the pre-rename `bin/darkwallet/pydrk` path — fixing that
-  is left to a docs change.
-- No workspace-level `make` targets are affected; Rust verification is
-  `make compile-dev` (and `make compile-apk` for android), Python is run
-  from `bin/app`.

+ 0 - 308
openspec/changes/app-pydrk-cli/specs/pydrk-cli/spec.md

@@ -1,308 +0,0 @@
-## Purpose
-
-Command-line access to a running `app` GUI's live scene graph over the
-netdebug ZeroMQ backend: navigate and display nodes and properties, set
-typed values and exprs, create and remove `Layer`/`VectorArt` nodes, and
-push shape data — all without recompiling the app.
-
-## ADDED Requirements
-
-### Requirement: CLI entrypoint and connection
-
-The system SHALL provide a `python -m pydrk` command (runnable from
-`bin/app`, argparse-based) whose commands talk to a running app's netdebug
-REQ/REP endpoint. The endpoint SHALL default to `127.0.0.1:9484` and be
-overridable with `--addr`/`--port` on every subcommand. When no app is
-listening, commands SHALL print an error naming the endpoint they tried and
-exit non-zero.
-
-#### Scenario: connectivity check
-
-- **WHEN** `python -m pydrk ping` runs against a running dev-mode app
-- **THEN** the command prints `hello` and exits 0
-
-#### Scenario: app not running
-
-- **WHEN** a command runs with `--port 9999` and nothing is listening there
-- **THEN** the command prints an error containing `127.0.0.1:9999` and exits non-zero
-
-### Requirement: Tree navigation and listing
-
-The `ls [path]` subcommand SHALL list the contents of a scene node: first
-its child nodes as one row per child showing the child name, numeric node
-id, and lowercase type name (e.g. `content 1234567890 layer`), then its
-properties as one row each showing name, type, and current value summary
-(exprs as source, shapes as a placeholder). Paths that do not resolve SHALL
-produce a readable `node_not_found` error and non-zero exit.
-
-#### Scenario: list the scene root
-
-- **WHEN** `python -m pydrk ls /`
-- **THEN** the built-in top-level nodes are listed (at least `setting` and `window`) followed by the root's properties
-
-#### Scenario: unknown path
-
-- **WHEN** `python -m pydrk ls /nope`
-- **THEN** the command reports `node_not_found` for `/nope` and exits non-zero
-
-### Requirement: Recursive tree display
-
-The `tree` subcommand SHALL recursively print a node's descendants with a
-`--depth N` limit, showing for every node: its name, id, type, properties
-with current values, signals with registered slots, and methods with full
-signatures. Property values SHALL be rendered distinctly per status: plain
-values as literals, exprs as their decompiled source (e.g. `w/2`), null as
-`null`, unset-with-default as the default, and vector shapes as a
-placeholder (shapes are write-only over the wire). Methods that declare a
-result SHALL show the result argument types (not be silently truncated).
-
-#### Scenario: shallow dump
-
-- **WHEN** `python -m pydrk tree / --depth 2`
-- **THEN** two levels of the tree print, each property line showing name,
-  type and value, and the command exits 0
-
-#### Scenario: method with result renders its signature
-
-- **WHEN** `python -m pydrk tree /plugin/drk`
-- **THEN** the `get_default_address` method line includes its result
-  signature (a `str` result), demonstrating result decoding
-
-### Requirement: Property metadata and value display
-
-The `props [path]` subcommand SHALL list a node's properties with their
-metadata: name, type, subtype, array length (marking unbounded), null/expr
-allowance, ranges when bounded, enum items when present, and UI text. The
-`show [path] PROP` subcommand SHALL print all info for one property: its
-full metadata (as listed for `props`, plus its depends list) followed by
-the current per-index values with their statuses. The `get [path] PROP
-[idx]` subcommand SHALL print the property's per-index values (only index
-`idx` when given), each on its own line annotated with its status
-(`value`, `expr`, `null`, or `unset`). For all three, a leading path
-argument is optional and defaults to the shell cwd (or `/` in one-shot
-mode); in `get`, when the final argument is an integer it is taken as the
-index.
-
-#### Scenario: metadata shows bounds and enums
-
-- **WHEN** `python -m pydrk props /window/content`
-- **THEN** the `alpha` property (or another bounded one) shows its `[0.0, 1.0]` range
-
-#### Scenario: show a single property
-
-- **WHEN** `python -m pydrk show /window/content alpha`
-- **THEN** the output contains the property's metadata (type `float32`,
-  the `[0.0, 1.0]` range, its UI text) followed by `0: value 1.0`
-
-#### Scenario: expr value is distinguishable
-
-- **WHEN** a property index holds an expr and `python -m pydrk get` is run for it
-- **THEN** the output line is annotated `expr` and shows the expr source string
-
-### Requirement: Typed property setting
-
-The `set` subcommand SHALL set values using the property's server-declared
-type for encoding (bool, uint32, float32, str, enum, scene_node_id as
-decimal). Its grammar is `set [path] PROP [idx] VAL`: the last argument
-is always the value; when the argument before the value is an integer it
-is taken as the array index (default 0); any remaining leading arguments
-(joined with `/`) are the node path, optional and defaulting to the shell
-cwd (or `/` in one-shot mode). The `--expr` flag sends the value as expr
-source to be compiled server-side. After a successful set the app SHALL
-redraw. Server rejections (wrong type, out-of-range, invalid enum item,
-invalid expr syntax, unknown expr global) SHALL be printed readably with
-the error name and exit non-zero, and a usage error SHALL be reported
-when the arguments cannot be parsed into the grammar (e.g. a property
-name is missing).
-
-#### Scenario: set a boolean with an explicit path
-
-- **WHEN** `python -m pydrk set /window/content/chat is_visible false`
-- **THEN** the command exits 0, a subsequent `get` shows `false`, and the app window updates
-
-#### Scenario: index and path in one command
-
-- **WHEN** `python -m pydrk set /window/content rect 2 "w/2" --expr`
-- **THEN** the command exits 0 and `get /window/content rect 2` shows `expr "w/2"`
-
-#### Scenario: out-of-range rejection
-
-- **WHEN** setting a bounded float32 property to `5.0` when its range is `[0.0, 1.0]`
-- **THEN** the command prints `property_out_of_range` and exits non-zero
-
-#### Scenario: expr set round-trip
-
-- **WHEN** `python -m pydrk set <path> rect 2 "w/2" --expr`
-- **THEN** the command exits 0 and `get` for that index shows `expr "w/2"`
-
-### Requirement: Node creation
-
-The `mknode` subcommand SHALL create and attach a node in one step:
-`mknode <parent_path> <name> <type>` where `<type>` is `layer` or
-`vector_art`. On success it SHALL print the new node's id and full path,
-and the node SHALL immediately appear in `ls <parent_path>` with all its
-factory properties (queryable via `props`). Creating a node whose parent
-path does not resolve SHALL fail with `node_not_found`; a name colliding
-with an existing sibling SHALL fail with a name-conflict error; any other
-type string SHALL fail with a readable `unsupported node type` message
-without touching the tree.
-
-#### Scenario: create a debug layer with vector art
-
-- **WHEN** `python -m pydrk mknode /window/content debug_layer layer` then
-  `python -m pydrk mknode /window/content/debug_layer art1 vector_art`
-- **THEN** both commands print ids and paths, and
-  `props /window/content/debug_layer/art1` lists the factory properties
-  including `shape`
-
-#### Scenario: unsupported type
-
-- **WHEN** `python -m pydrk mknode /window/content debug_layer chatview`
-- **THEN** the command reports the type as unsupported and exits non-zero
-
-### Requirement: Node removal
-
-The `rmnode <path>` subcommand SHALL remove any node subtree from its
-parent (the node and its descendants disappear from listings) and trigger
-an app redraw; GPU resources owned by removed nodes SHALL be released by
-the app. This is a debugging tool with full scene-graph access: built-in
-nodes are removable the same way as wire-created ones. Removing the scene
-root `/` SHALL fail with a readable error. All removals are runtime-only
-and undone by restarting the app.
-
-#### Scenario: remove a wire-created layer
-
-- **WHEN** a layer was created with `mknode` and `python -m pydrk rmnode /window/content/debug_layer` runs
-- **THEN** `ls /window/content` no longer lists `debug_layer` and the app redraws
-
-#### Scenario: remove a built-in node
-
-- **WHEN** `python -m pydrk rmnode <path-to-a-built-in-layer>` runs against a running dev app
-- **THEN** the subtree disappears from listings and rendering, and restarting the app restores it
-
-#### Scenario: the scene root is not removable
-
-- **WHEN** `python -m pydrk rmnode /`
-- **THEN** the command fails with `node_not_removable` and the tree is unchanged
-
-### Requirement: Shape data creation
-
-The `set-shape <path> [--prop NAME] [--index N]` subcommand SHALL build a
-vector shape from repeatable primitive flags and push it as the property's
-value: `--box X1 Y1 X2 Y2`, `--gbox X1 Y1 X2 Y2` (top and bottom colors),
-`--vgradient X1 Y1 X2 Y2 TOPCOLOR BOTCOLOR STRIPS GAMMA`, `--outline X1 Y1
-X2 Y2 BORDERPX`, `--line X1 Y1 X2 Y2 THICKNESS`, and `--glow CX CY W H
-SEGMENTS COLOR`, each taking colors as `R G B A` float groups. Coordinates
-SHALL accept both plain numbers and expr source strings (e.g. `w/2`), and
-primitives SHALL join into a single shape in flag order. Shape indices are
-16-bit; vertex counts beyond that SHALL be rejected client-side with a
-readable message. After a successful set the shape SHALL be visible in the
-app window at the next frame (given a non-empty `rect` and `is_visible`).
-
-#### Scenario: draw a red bar over the wire
-
-- **WHEN** a `vector_art` node exists with `rect` set, and
-  `python -m pydrk set-shape /window/content/debug_layer/art1 --box 0 0 w 10 --color 1 0 0 1`
-  runs (with `w` passed as an expr coordinate)
-- **THEN** the command exits 0 and a red bar renders along the top of the node's rect in the app
-
-#### Scenario: invalid expr in shape coordinates
-
-- **WHEN** a coordinate references an unknown global (e.g. `q/3`)
-- **THEN** the server rejects the shape and the CLI prints the error name (e.g. `sexpr_global_not_found`) and exits non-zero
-
-### Requirement: Method and signal introspection
-
-The `methods <path>` subcommand SHALL list each method with its argument
-signatures and, when declared, result signatures; `signals <path>` SHALL
-list signal names. The `call <path> <method> [ARGS...]` subcommand SHALL
-encode positional ARGS according to the method's declared argument types
-(uint32/uint64/float32/bool/str; hash as 64-char hex), print the decoded
-result when the method returns one, and `void` when it does not.
-
-#### Scenario: call a no-result method
-
-- **WHEN** `python -m pydrk call /window/content/chat/view copy_select`
-- **THEN** the command prints `void` and exits 0
-
-#### Scenario: argument type mismatch
-
-- **WHEN** calling a method whose first declared argument is `str` with a non-string token where coercion is impossible, or supplying the wrong number of arguments
-- **THEN** the CLI rejects it locally with a readable usage error before sending anything
-
-### Requirement: Server error reporting
-
-Every subcommand SHALL map netdebug error frames to the human-readable
-error name from the netdebug error table (e.g. `property_not_found`)
-together with command context (path, property, method as applicable), and
-exit non-zero. Unknown error codes SHALL be printed with their numeric
-value.
-
-#### Scenario: unknown property
-
-- **WHEN** `python -m pydrk get /window no_such_prop`
-- **THEN** the output contains `property_not_found` and the exit code is non-zero
-
-### Requirement: Interactive shell mode
-
-Running `python -m pydrk` with no subcommand SHALL start an interactive
-shell connected to the same endpoint, maintaining a current working node
-path (cwd, initially `/`) shown in the prompt (e.g. `pydrk:/window> `).
-Shell commands SHALL reuse the one-shot command set with path arguments
-resolved against cwd; absolute paths starting with `/` SHALL be honored as
-absolute. `pwd` SHALL print the cwd; `cd <path>` SHALL change it, `cd`
-with no argument SHALL go to `/`, `..` SHALL pop one node, and `cd` into a
-path that has children but is not itself resolvable SHALL fail with
-`node_not_found` leaving cwd unchanged; `exit` (or EOF) SHALL quit the
-shell. In the shell, the optional-path grammar of `set`, `get` and
-`show` SHALL default to the cwd node, so the workflow `cd` into a node,
-`ls` its contents, `show foo` for full property info, `set foo XXX` (or
-`set foo 2 XXX` for an array index) works without repeating paths.
-Server errors in the shell SHALL print the readable error name and return
-to the prompt (the shell SHALL NOT exit on a failed command).
-
-#### Scenario: cd, ls, show, set workflow
-
-- **WHEN** in the shell the user runs `cd /window`, then `cd content`,
-  then `ls`, then `show is_visible`, then `set is_visible false`
-- **THEN** `ls` lists the content node's children and properties, `show`
-  prints the `is_visible` metadata and current value, `set` reports
-  success, the app redraws, and a subsequent `get is_visible` prints
-  `false`
-
-#### Scenario: shell survives errors
-
-- **WHEN** a shell command fails (e.g. `get no_such_prop`)
-- **THEN** the error name is printed and the prompt returns; the shell is
-  still usable and cwd is unchanged
-
-### Requirement: Shell tab completion
-
-The interactive shell SHALL provide tab completion (stdlib `readline`):
-completing the first word yields command names; completing a later word
-that looks like a path yields child node names of the referenced parent,
-fetched live from the running app (so newly created `mknode` nodes
-complete after creation); completing the first positional argument of
-`get`, `set` and `show` yields the cwd node's property names together
-with its child node paths (both are valid leading arguments under the
-optional-path grammar). Completion SHALL NOT
-print duplicates, and completing a partial token SHALL offer all matches
-when ambiguous. If `readline` is unavailable the shell SHALL still work
-without completion.
-
-#### Scenario: complete a node path
-
-- **WHEN** the user types `cd /win` and presses Tab
-- **THEN** the token completes to `/window/`
-
-#### Scenario: complete a property name
-
-- **WHEN** the cwd is a node with an `is_visible` property and the user
-  types `set is_v` and presses Tab
-- **THEN** the token completes to `is_visible `
-
-#### Scenario: complete a freshly created node
-
-- **WHEN** `mknode /window/content debug_layer layer` was run and the user
-  types `cd /window/content/debug` and presses Tab
-- **THEN** the token completes to `/window/content/debug_layer/`

+ 0 - 235
openspec/changes/app-pydrk-cli/tasks.md

@@ -1,235 +0,0 @@
-## 1. CLI scaffold and inspection commands (Python only)
-
-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`.
-
-- [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
-  only `ping` using `Api.hello()`. Verify: `python -m pydrk ping` prints
-  `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`.
-- [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`.
-- [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`.
-- [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`.
-- [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
-  given index when `idx` is present. Add `parse_get_args` cases to the
-  selftest. Verify against the running app: `python -m pydrk get
-  /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`.
-- [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
-  -m pydrk show /window/content alpha` prints the metadata including the
-  `[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`.
-- [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:
-  `python -m pydrk methods /plugin/drk` lists `get_default_address`
-  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`.
-- [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.
-  Commit as `app: pydrk CLI selftest harness`.
-
-## 2. Typed property setting (Python only)
-
-- [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,
-  `null` literal → `set_property_null`, enum membership validated
-  locally). Wire it into `set [path] PROP [idx] VAL` with the
-  right-to-left `parse_set_args` (path optional, trailing-integer index,
-  leading path tokens joined with `/`, usage error when the property
-  name is missing). Verify live: `python -m pydrk set
-  /window/content/chat is_visible false` hides the chat UI, then `true`
-  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`.
-- [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`.
-- [x] 2.3 Verify the failure paths end-to-end: `python -m pydrk set
-  /window/content alpha 5.0` prints `property_out_of_range`;
-  `python -m pydrk set /window no_such_prop 1` prints
-  `property_not_found`; `set --expr "q/3"` on a rect index prints
-  `sexpr_global_not_found`; `python -m pydrk set` alone prints a usage
-  error; all exit non-zero and none change app state.
-  Fix anything that prints a raw traceback instead of `error: <name>`.
-  Commit as `app: pydrk CLI set error reporting`.
-
-## 3. netdebug backend: node creation and removal (Rust)
-
-- [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`.
-- [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`.
-- [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`.
-- [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` +
-  `.setup(...)` + spawn pimpl `start(ex)` after `link`; reject other
-  types with `UnsupportedNodeType`; reply the id. Verify: `make
-  compile-dev` succeeds. Commit as
-  `app/netdebug: path-based AddNode for layer and vector_art`.
-- [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
-  tool. Verify: `make compile-dev` succeeds. Commit as
-  `app/netdebug: path-based RemoveNode`.
-
-## 4. Node lifecycle commands (Python)
-
-- [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
-  against the rebuilt app: `python -m pydrk mknode /window/content dbg
-  layer` prints the id; `python -m pydrk ls /window/content` lists
-  `dbg`; `python -m pydrk mknode /window/content/dbg art1 vector_art`
-  works and `python -m pydrk props /window/content/dbg/art1` lists the
-  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`.
-- [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
-  window redraws; `python -m pydrk rmnode /window/content/chat` removes
-  the built-in chat layer (restart the app afterwards to restore it);
-  `python -m pydrk rmnode /` prints `node_not_removable` and `python -m
-  pydrk ls /` still lists everything. Commit as
-  `app: pydrk CLI rmnode command`.
-- [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
-  from tasks 1-2 still work live. Commit as
-  `app: drop dead pydrk client methods`.
-
-## 5. Shape data (Python)
-
-- [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
-  layer`, `mknode /window/content/dbg art1 vector_art`, set the art
-  node's `rect` (e.g. `--expr "w"` at index 2 and `--expr "h"` at
-  index 3), `set is_visible true`, then `python -m pydrk set-shape
-  /window/content/dbg/art1 --box 0 0 w 10 1 0 0 1` — a red bar renders
-  along the top of the window. Commit as
-  `app: pydrk CLI set-shape with box primitive`.
-- [x] 5.2 Add the remaining primitives from design D11: `--gbox`,
-  `--vgradient`, `--outline`, `--line`, `--glow` (colors inline as
-  trailing R G B A float args; coordinates may be expr strings). Verify
-  live by composing one shape using at least `--vgradient`, `--outline`
-  and `--glow` in a single command and seeing all three render; verify
-  `set-shape ... --box 0 0 q/3 10 1 0 0 1` prints
-  `sexpr_global_not_found`; verify a >65535-vertex construction is
-  rejected client-side. Extend `--selftest` with flag-parsing checks.
-  Commit as `app: pydrk CLI set-shape gradient/outline/line/glow`.
-
-## 6. Method calls (Python)
-
-- [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
-  tokens locally before sending, print decoded results for `str`/`hash`
-  result types and a short hex dump otherwise, `void` when none. Verify
-  live: find the chatview node with `python -m pydrk methods
-  /window/content/chat` (or `tree`) and run `call <chatview-path>
-  copy_select` → prints `void`; `python -m pydrk call /plugin/drk
-  get_default_address` prints an address string. Commit as
-  `app: pydrk CLI call command`.
-
-## 7. Interactive shell
-
-- [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`.
-- [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
-  of `set`/`get`/`show` defaulting to cwd (so `set is_visible false`,
-  `set rect 2 "w/2" --expr` and `show alpha` all target the cwd node);
-  builtins `cd` (no arg → `/`, `..` pops, target existence
-  verified, cwd unchanged on failure), `pwd`, `exit`/`quit` + EOF;
-  failed commands print `error: <name>` and return to the prompt.
-  Verify live session: `cd /window`, `cd content`, `ls`, `show
-  is_visible`, `set is_visible false` (chat hides), `get is_visible`
-  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`.
-- [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
-  `get`/`set`/`show` from the union of the cwd's property names and
-  child paths, guarded `import readline`. Verify live: `cd /win<TAB>`
-  completes to `/window/`; `set is_v<TAB>` completes to `is_visible `;
-  `show alp<TAB>` completes to `alpha ` on `/window/content`; `mknode
-  /window/content dbg layer` then `cd /window/content/db<TAB>`
-  completes to `dbg/`; ambiguous prefixes list all matches. Commit as
-  `app: pydrk shell tab completion`.
-
-## 8. Final verification
-
-- [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`;
-  `set-shape` a box; `ls` and `get` to confirm state; `rmnode` the debug
-  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`.
-- [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
-  in `openspec/changes/app-pydrk-cli/specs/pydrk-cli/spec.md` have each
-  been exercised at least once during tasks 1-7.