mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-20 03:27:35 +00:00
feat(ruview): ruview-live Claude Code mod shipped in the npm package (ADR-377)
- mod/: a Claude Code mod (function hooks). /ruview opens a pane beside the transcript with CSI nodes (rate, loss, RSSI, shape, SYNTHETIC) and an optional ESPHome radar (presence, distance, device-reported vitals), alerts (heartbeat-only boards + buffer-starvation hint), Refresh/Close buttons and a one-line status; refreshed on $.clock.every. - Reads only through the harness CLI beside it (fixed read-only argv, --json) located via $.plugin.root (mods may import nothing else). - Packaged in @ruvnet/ruview (mod/.claude-plugin, mod/hooks), listed in the repo marketplace as ruview-live@ruview; `ruview mod` prints how to load it. - @ruvnet/ruview 0.9.0; umbrella ruview 0.9.0 (still unpublished). Verified: claude plugin validate (mod + marketplace); claude plugin test 2/2 x3 runs; harness 172 tests + 89 security; real Claude Code headless `/ruview refresh` from an installed tarball and with the live radar kit. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
4128661789
commit
093e3bbeae
16 changed files with 620 additions and 9 deletions
|
|
@ -10,6 +10,11 @@
|
|||
"name": "ruview",
|
||||
"source": "./plugins/ruview",
|
||||
"description": "End-to-end RuView toolkit: getting started, ESP32 hardware setup, configuration, sensing applications (presence / vitals / pose / sleep / MAT), camera-free + camera-supervised model training, advanced multistatic sensing, CLI / API / WASM, mmWave radar, and witness verification"
|
||||
},
|
||||
{
|
||||
"name": "ruview-live",
|
||||
"source": "./harness/ruview/mod",
|
||||
"description": "Claude Code mod: /ruview opens a live pane with your CSI nodes (ESP32, Realtek) and ESPHome radar (presence, distance, device-reported vitals), refreshed on a timer, plus a one-line status. Read-only; ships inside @ruvnet/ruview. Mods are early access (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1)."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
101
docs/adr/ADR-377-ruview-live-claude-code-mod.md
Normal file
101
docs/adr/ADR-377-ruview-live-claude-code-mod.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-01
|
||||
- **Deciders**: rUv (requested integration into the npm package); RuView maintainers
|
||||
- **Tags**: claude-code, mods, function-hooks, ui, npm, least-authority
|
||||
- **Related**: ADR-373 (device access), ADR-375 (MCP Apps console, terminal UI),
|
||||
ADR-376 (umbrella package); Claude Code mods (function hooks): the
|
||||
claude-code issue 91870 and the `mods/` built-ins (`diff` is the closest
|
||||
analogue)
|
||||
|
||||
## Context
|
||||
|
||||
Claude Code mods are plugins whose behaviour is a function-hooks module: one
|
||||
`register(on, options)` that hooks engine events as `($, e, next)`. They can:
|
||||
- register slash commands;
|
||||
- open panes beside the transcript and draw them with the surface's elements;
|
||||
- set a status line;
|
||||
- run processes through `$.process.run`.
|
||||
|
||||
That fits RuView's goal of putting the live state of the sensors where an
|
||||
operator or agent already works.
|
||||
|
||||
RuView already has:
|
||||
- a read-only CLI with `--json` results (ADR-373, ADR-375);
|
||||
- an MCP server and the ChatGPT/MCP Apps console;
|
||||
- a plugin marketplace in this repository.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Ship a mod, `ruview-live`, inside `@ruvnet/ruview`** (`mod/`, packaged
|
||||
files `mod/.claude-plugin/`, `mod/hooks/`).
|
||||
- It is listed in the repository marketplace as `ruview-live@ruview`, with
|
||||
source `./harness/ruview/mod`.
|
||||
- One copy serves both installs: the npm package and the marketplace.
|
||||
2. **Behaviour.**
|
||||
- **`/ruview`** toggles a pane (`ruview-live`). While it is open, a
|
||||
`$.clock.every` timer (default 15 s, minimum 5 s) runs one read-only
|
||||
capture.
|
||||
- **`/ruview refresh`** runs one capture and returns the summary.
|
||||
- **`/ruview off`** closes the pane.
|
||||
- **The pane** shows nodes (rate, loss, RSSI, shape, SYNTHETIC flag), the
|
||||
optional ESPHome radar (presence, distance, device-reported vitals), and
|
||||
alerts, including heartbeat-only boards with the CSI-buffer-starvation
|
||||
hint. It has Refresh (`r`) and Close (`c`) buttons.
|
||||
- **The status line** is set on every refresh.
|
||||
3. **Data comes only from the harness CLI beside the mod.**
|
||||
- The mod runs `node <plugin root>/../bin/cli.js esp32 … --json` and
|
||||
`mmwave --source esphome … --json`, as fixed argv arrays, through
|
||||
`$.process.run`. So the pane shows what the tested tools return.
|
||||
- Mods may import nothing but their own files and `claude-code` (the
|
||||
validator refuses `node:url`). The CLI is therefore located through
|
||||
`$.plugin.root`.
|
||||
4. **Least authority.**
|
||||
- The mod never runs a write verb (flash, calibrate, train). A unit test
|
||||
pins the argv.
|
||||
- The radar host is validated before use.
|
||||
- Options are bounded: ports 1024–65535, refresh ≥ 5 s, capture 1–10 s.
|
||||
5. **Discovery.** `ruview mod` (and `ruview mod --json`) prints the installed
|
||||
path, the `claude --plugin-dir` command, the marketplace commands, usage,
|
||||
settings, and the early-access note.
|
||||
|
||||
## Evidence
|
||||
|
||||
All on Claude Code 2.1.286, Windows 11 x64.
|
||||
- **Validation:** `claude plugin validate harness/ruview/mod` passes. It lists
|
||||
hooks `session.start`, `command.run{ruview}`, `ui.render{Pane}`,
|
||||
`ui.close{ruview-live}` and `session.end`, and calls `$.clock.every`,
|
||||
`$.clock.now`, `$.command.register`, `$.process.run` and `$.ui.*`.
|
||||
`claude plugin validate .` passes for the marketplace.
|
||||
- **Engine tests:** `claude plugin test harness/ruview/mod`, with
|
||||
`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`, passed 2/2 in three consecutive
|
||||
runs. They check:
|
||||
- `/ruview` opens the pane and runs the exact read-only argv;
|
||||
- the status reads `RuView · 2 nodes · 1 alert`;
|
||||
- the timer refreshes while open and **never after close**;
|
||||
- an honest `no_packets` status.
|
||||
- **Harness unit tests:** `test/mod.test.mjs`, 6/6, under plain Node:
|
||||
settings bounds, host validation, argv, failure parsing, model/status, pane
|
||||
drawing and button wiring, and the no-imports rule.
|
||||
- **Real engine, headless (MEASURED):**
|
||||
`claude --plugin-dir ./mod -p "/ruview refresh"` answered
|
||||
`RuView · 0 nodes · 1 alert` (no CSI node was streaming). With `radarHost`
|
||||
set to the live Seeed MR60BHA2 kit, it answered
|
||||
`RuView · 0 nodes · radar clear · 1 alert`.
|
||||
- **Note:** under Git Bash a leading `/ruview` is rewritten into a Windows path
|
||||
unless `MSYS_NO_PATHCONV=1` is set. This is not an issue inside the Claude
|
||||
Code prompt.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An operator sees node and radar health beside the conversation without
|
||||
asking for it. Agents see the same facts through MCP.
|
||||
- **Mods are early access.** The API may change between Claude Code releases,
|
||||
and hooks modules load only where function hooks are enabled. The mod is
|
||||
therefore optional, and nothing else in the package depends on it.
|
||||
- **Trust.** Like every mod, it runs inside Claude Code with Claude Code's
|
||||
access. It is read-only by construction, and the README says to install it
|
||||
only from a trusted source.
|
||||
- A refresh binds UDP 5005 for the capture window. While the sensing server
|
||||
holds the port, the pane reports `port_in_use` instead of reading.
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
Latest proposed decisions:
|
||||
|
||||
- [ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`](ADR-377-ruview-live-claude-code-mod.md)
|
||||
- [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md)
|
||||
- [ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`](ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md)
|
||||
- [ADR-374: Remote host access over SSH (read-only)](ADR-374-remote-host-access-over-ssh.md)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ npx ruview kernel selftest
|
|||
|
||||
A terminal gets a formatted view; pipes and `--json` get JSON.
|
||||
|
||||
In **Claude Code**, the bundled `ruview-live` mod puts a live sensing pane beside the conversation (`/ruview`). Run `npx ruview mod` to see how to load it (mods are early access).
|
||||
|
||||
## Agents: MCP, ChatGPT and MCP Apps
|
||||
|
||||
One server exposes the harness tools (`ruview_*`) and the Homecore tools
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ruview",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"description": "RuView in one install: the WiFi-sensing operator harness (ESP32/Realtek CSI, 60/24 GHz radar incl. ESPHome kits, LiDAR, flashing, training, doctor, MCP + ChatGPT/MCP Apps console), the Homecore developer metaharness, and the WASM vitals kernel. One CLI and one MCP server.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ruvnet/ruview": "0.8.0",
|
||||
"@ruvnet/ruview": "0.9.0",
|
||||
"@ruvnet/ruview-kernel": "0.1.0",
|
||||
"homecore": "0.1.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"generator": "RuView metaharness provenance v2",
|
||||
"template": "vertical:ruview",
|
||||
"name": "@ruvnet/ruview",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"hosts": [
|
||||
"claude-code",
|
||||
"codex"
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f",
|
||||
"CLAUDE.md": "25f9306b6caae22078cb9375108b6f6bb813d83e617739f6d9372f116b0ede5d",
|
||||
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
|
||||
"README.md": "cdce362447f71e79667d8293569ba63ec251e15010aa3d01958f66fbe3bb54ec",
|
||||
"README.md": "20df33af2fcd83481321d963b76169b863c6a8acb1d7281e9f40169786169a87",
|
||||
"bin/cli.js": "8ebf6ca4b6d9529a5e338374c19329c1a2fc2f9e52f13b4341928b7168ee461b",
|
||||
"brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
|
||||
"flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572",
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
"flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214",
|
||||
"flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab",
|
||||
"flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0",
|
||||
"package.json": "ab768d4e10a55867c2f68d4fd465cd820fb4bc22e432f4a5e67b21024ceabf2e",
|
||||
"package.json": "c725457bc2d96c7305dcc8f11cd61cb2ef20d3fbe0baa5c9a376bc9897eb8641",
|
||||
"scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565",
|
||||
"scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b",
|
||||
"scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10",
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
|
||||
"src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6",
|
||||
"src/cli-ui.js": "507fbd4444c6639c69354790ed3aed2addcb826929a6b3ad7ddb752f058c1f2f",
|
||||
"src/cli.js": "6c20dc30212bf90c4c3ee21e18a8c1eeacaa1271372f623ff805ba6399676af4",
|
||||
"src/cli.js": "2401106a97509da8dcbf7e002195328868aa2b18f69cbc02ddaaf1cad95b4388",
|
||||
"src/devices/esp32.js": "ae389193430fc85601b46aef9281ad91631a1cb9bf4d2f247dfb02e2a4491c4d",
|
||||
"src/devices/esphome.js": "83b0fbd8c9075e233a401fa9f9dad04fc911e04819e7a0c48eb32d7e35d226af",
|
||||
"src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629",
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
"src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956",
|
||||
"src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462"
|
||||
},
|
||||
"filesDigest": "9298db21d45a44b918e7ef61e1459a6250ec61ad49873ddc8609ede4dc08aa5e",
|
||||
"filesDigest": "d7989b453bdabbff37ef3b2382192c36b9513544ab593ffa9da97ba18dc7f003",
|
||||
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
|
||||
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
|
||||
"developmentPins": {
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
6ad86ea903c6c131f134f11f5a51f38cbe2c352d7682911cd2a5587f92d54e16 manifest.json
|
||||
b7f72c94e8f41c3f2687549972e5ec69cfd0302f16d90ff3aaa511449f5158bd manifest.json
|
||||
|
|
|
|||
|
|
@ -242,6 +242,44 @@ The bundled `.claude/settings.json` registers the `ruview` MCP server
|
|||
(`npx -y @ruvnet/ruview mcp start`). Drop this package's `.claude/` into a repo, or run
|
||||
`npx @ruvnet/ruview install --host claude-code`.
|
||||
|
||||
## Claude Code mod: live sensing pane (ADR-377)
|
||||
|
||||
This package ships **`ruview-live`**, a Claude Code mod: a plugin whose behaviour is a function-hooks module. `/ruview` opens a pane beside the transcript, refreshed on a timer, with:
|
||||
- your CSI nodes (ESP32, Realtek): rate, loss, RSSI and CSI shape;
|
||||
- an optional ESPHome radar kit: presence, distance, and device-reported heart/breathing;
|
||||
- alerts, such as a board that sends heartbeats but no CSI.
|
||||
|
||||
It also keeps a one-line status: `RuView · 2 nodes · radar present · 1 alert`.
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview mod # where the mod is in this install, and how to load it
|
||||
claude --plugin-dir "<path printed above>" # try it for one session
|
||||
# or from the RuView marketplace:
|
||||
# /plugin marketplace add ruvnet/RuView
|
||||
# /plugin install ruview-live@ruview
|
||||
```
|
||||
|
||||
In Claude Code:
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `/ruview` | Open or close the pane |
|
||||
| `/ruview refresh` | Run one capture now; the result goes in the status line |
|
||||
| `/ruview off` | Close the pane |
|
||||
|
||||
**Settings** (`claude plugin configure ruview-live`):
|
||||
- `udpPort` (default 5005);
|
||||
- `radarHost`, an ESPHome kit on your private network;
|
||||
- `refreshSeconds` (default 15, minimum 5);
|
||||
- `captureSeconds` (default 3).
|
||||
|
||||
**How it reads:**
|
||||
- The mod only runs this package's CLI, for read-only `esp32` and `mmwave --source esphome` captures, with `--json`, so the pane shows exactly what those tools return.
|
||||
- It never flashes, provisions or writes to a device.
|
||||
- Like every mod, it runs inside Claude Code with Claude Code's access, so install it only from a source you trust.
|
||||
|
||||
Mods are early access in Claude Code. If `/ruview` is missing, start Claude Code with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`.
|
||||
|
||||
## ChatGPT and MCP Apps (ADR-375)
|
||||
|
||||
`ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor` render in a
|
||||
|
|
|
|||
37
harness/ruview/mod/.claude-plugin/plugin.json
Normal file
37
harness/ruview/mod/.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "ruview-live",
|
||||
"version": "0.1.0",
|
||||
"description": "RuView live sensing in Claude Code: /ruview opens a pane beside the transcript with your CSI nodes (ESP32, Realtek) and 60 GHz radar (raw or ESPHome kits): rates, loss, RSSI, presence, device-reported vitals and alerts such as heartbeat-only boards, refreshed on a timer, plus a one-line status. Reads only; runs the @ruvnet/ruview CLI it ships beside.",
|
||||
"author": {
|
||||
"name": "ruvnet",
|
||||
"url": "https://github.com/ruvnet/RuView"
|
||||
},
|
||||
"homepage": "https://github.com/ruvnet/RuView",
|
||||
"license": "MIT",
|
||||
"userConfig": {
|
||||
"udpPort": {
|
||||
"type": "number",
|
||||
"title": "UDP port",
|
||||
"description": "Port your CSI nodes stream to (the sensing server's default is 5005). The pane listens briefly on each refresh; it reports port_in_use when the sensing server holds it.",
|
||||
"default": 5005
|
||||
},
|
||||
"radarHost": {
|
||||
"type": "string",
|
||||
"title": "Radar kit host",
|
||||
"description": "IP or hostname of an ESPHome radar kit (e.g. a Seeed MR60BHA2) on your private network. Empty skips the radar.",
|
||||
"default": ""
|
||||
},
|
||||
"refreshSeconds": {
|
||||
"type": "number",
|
||||
"title": "Refresh interval (s)",
|
||||
"description": "Seconds between refreshes while the pane is open (minimum 5).",
|
||||
"default": 15
|
||||
},
|
||||
"captureSeconds": {
|
||||
"type": "number",
|
||||
"title": "Capture window (s)",
|
||||
"description": "Seconds each refresh listens for node packets and radar states (1-10).",
|
||||
"default": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
4
harness/ruview/mod/hooks/hooks.json
Normal file
4
harness/ruview/mod/hooks/hooks.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"description": "RuView live pane: /ruview and its Pane drawing, a timed refresh from the @ruvnet/ruview CLI (node capture and ESPHome radar, read-only), and a one-line status",
|
||||
"modules": ["./register.mjs"]
|
||||
}
|
||||
221
harness/ruview/mod/hooks/register.mjs
Normal file
221
harness/ruview/mod/hooks/register.mjs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
// ruview-live — a Claude Code mod (function hooks) shipped inside @ruvnet/ruview.
|
||||
//
|
||||
// `/ruview` opens a pane beside the transcript with the CSI nodes streaming to
|
||||
// this machine and, optionally, an ESPHome radar kit; it refreshes on a timer
|
||||
// and keeps a one-line status. Every reading comes from the @ruvnet/ruview CLI
|
||||
// this mod ships beside (`--json`), so the pane shows exactly what the tested
|
||||
// tools return. Read-only: it never flashes, provisions or writes to a device.
|
||||
|
||||
export const PANE_ID = 'ruview-live';
|
||||
export const COMMAND = 'ruview';
|
||||
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
|
||||
|
||||
/**
|
||||
* The harness CLI beside this mod: the plugin root is <pkg>/mod, so the CLI is
|
||||
* <pkg>/bin/cli.js. Mods import nothing but their own files, so the engine's
|
||||
* `$.plugin.root` locates it.
|
||||
*/
|
||||
export function cliPathOf(pluginRoot) {
|
||||
return `${String(pluginRoot).replace(/[\\/]+$/, '')}/../bin/cli.js`;
|
||||
}
|
||||
|
||||
/** Normalise plugin options (userConfig) into bounded settings. */
|
||||
export function settingsOf(options = {}) {
|
||||
const num = (v, d, lo, hi) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : d;
|
||||
};
|
||||
const host = typeof options.radarHost === 'string' ? options.radarHost.trim() : '';
|
||||
return {
|
||||
udpPort: num(options.udpPort, 5005, 1024, 65535),
|
||||
radarHost: HOST_RE.test(host) ? host : '',
|
||||
refreshMs: num(options.refreshSeconds, 15, 5, 3600) * 1000,
|
||||
captureSeconds: num(options.captureSeconds, 3, 1, 10),
|
||||
};
|
||||
}
|
||||
|
||||
/** argv for one capture and (optionally) one radar read. */
|
||||
export function commandsOf(settings) {
|
||||
const s = String(settings.captureSeconds);
|
||||
return {
|
||||
capture: ['esp32', '--seconds', s, '--udp-port', String(settings.udpPort), '--json'],
|
||||
radar: settings.radarHost ? ['mmwave', '--source', 'esphome', '--host', settings.radarHost, '--seconds', s, '--json'] : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a CLI run into its JSON result, or an honest failure. */
|
||||
export function resultOf(run) {
|
||||
if (!run) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(run.stdout);
|
||||
if (parsed && typeof parsed === 'object') return parsed;
|
||||
} catch { /* fall through */ }
|
||||
return { ok: false, reason: 'cli_error', detail: String(run.stderr || run.stdout || `exit ${run.exitCode}`).trim().slice(0, 300) };
|
||||
}
|
||||
|
||||
const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
|
||||
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—');
|
||||
|
||||
/** The pane's model: plain data, drawn by viewOf and summarised by statusOf. */
|
||||
export function modelOf(capture, radar, at) {
|
||||
const alerts = [];
|
||||
if (capture && capture.ok === false) alerts.push({ level: capture.reason === 'no_packets' ? 'warn' : 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
|
||||
for (const hb of capture?.heartbeatOnlySenders || []) alerts.push({ level: 'warn', text: `${hb.address} sends heartbeats but no CSI (run \`ruview monitor --baud 1500000\` on it: "lack of csi buf" = buffer starvation)` });
|
||||
if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` });
|
||||
const nodes = (capture?.nodes || []).map((n) => ({
|
||||
label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`,
|
||||
rate: `${fixed(n.csiRateHz)} Hz`,
|
||||
loss: pct(n.csiLossFraction),
|
||||
rssi: `${fixed(n.rssiMean)} dBm`,
|
||||
shape: n.csi?.shape || '—',
|
||||
lossy: typeof n.csiLossFraction === 'number' && n.csiLossFraction > 0.05,
|
||||
synthetic: Boolean(n.csi?.synthetic),
|
||||
}));
|
||||
const radarRow = radar && radar.ok !== false ? {
|
||||
name: radar.device?.name || radar.host || 'radar',
|
||||
present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5),
|
||||
distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`,
|
||||
heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`,
|
||||
breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`,
|
||||
} : null;
|
||||
return { at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0, nodes, radar: radarRow, alerts };
|
||||
}
|
||||
|
||||
/** One line for the status bar. */
|
||||
export function statusOf(model) {
|
||||
if (!model) return 'RuView · starting';
|
||||
const parts = [`RuView · ${model.nodes.length} node${model.nodes.length === 1 ? '' : 's'}`];
|
||||
if (model.radar) parts.push(`radar ${model.radar.present == null ? '?' : model.radar.present ? 'present' : 'clear'}`);
|
||||
if (model.alerts.length) parts.push(`${model.alerts.length} alert${model.alerts.length === 1 ? '' : 's'}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** Draw the pane with the surface's own elements ({ Box, Text, Button }). */
|
||||
export function viewOf(ui, model, { refreshMs, busy, onRefresh, onClose }) {
|
||||
const { Box, Text, Button } = ui;
|
||||
const line = (children, props = {}) => Text({ ...props, wrap: 'truncate-end', children });
|
||||
const rows = [];
|
||||
rows.push(line(model ? `updated ${new Date(model.at).toLocaleTimeString()} · every ${Math.round(refreshMs / 1000)}s · ${model.decoded}/${model.packets} packets decoded` : 'waiting for the first capture…', { dimColor: true }));
|
||||
for (const a of model?.alerts || []) rows.push(Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
|
||||
if (model) {
|
||||
rows.push(line('NODES', { bold: true }));
|
||||
if (!model.nodes.length) rows.push(line(' none streaming to this machine', { dimColor: true }));
|
||||
for (const n of model.nodes) {
|
||||
rows.push(Box({ flexDirection: 'row', gap: 2, children: [
|
||||
line(n.label.padEnd(10)), line(n.rate.padStart(9)),
|
||||
line(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : {}), line(n.rssi), line(n.shape, { dimColor: true }),
|
||||
n.synthetic ? line('SYNTHETIC', { color: 'yellow' }) : null,
|
||||
].filter(Boolean) }));
|
||||
}
|
||||
if (model.radar) {
|
||||
const r = model.radar;
|
||||
rows.push(line(`RADAR ${r.name}`, { bold: true }));
|
||||
rows.push(line(` presence ${r.present == null ? '—' : r.present ? '● detected' : '○ none'} · distance ${r.distance} · heart ${r.heart} · breathing ${r.breathing}`, r.present ? { color: 'green' } : {}));
|
||||
rows.push(line(' device-reported values, not validated against a reference', { dimColor: true }));
|
||||
}
|
||||
}
|
||||
rows.push(Box({ flexDirection: 'row', gap: 2, children: [
|
||||
Button({ key: 'refresh', label: busy ? 'Refreshing…' : 'Refresh', hotkey: 'r', onPress: onRefresh }),
|
||||
Button({ key: 'close', label: 'Close', hotkey: 'c', onPress: onClose }),
|
||||
] }));
|
||||
return Box({ flexDirection: 'column', gap: 0, padding: 1, children: rows });
|
||||
}
|
||||
|
||||
/**
|
||||
* The mod entry: `/ruview [off|refresh]`, the pane, the timer, the status.
|
||||
* @param on the engine's registrar
|
||||
* @param options this plugin's userConfig values
|
||||
*/
|
||||
export function register(on, options = {}) {
|
||||
const settings = settingsOf(options);
|
||||
const commands = commandsOf(settings);
|
||||
let host = null;
|
||||
let isOpen = false;
|
||||
let busy = false;
|
||||
let model = null;
|
||||
let stopTimer = null;
|
||||
let cliPath = null;
|
||||
|
||||
const stop = () => { if (stopTimer) { stopTimer(); stopTimer = null; } };
|
||||
const cancelOf = (t) => (typeof t === 'function' ? t : () => t?.cancel?.());
|
||||
|
||||
async function runCli(args) {
|
||||
if (!args) return null;
|
||||
try {
|
||||
return await host.run(['node', cliPath, ...args], { timeoutMs: (settings.captureSeconds + 30) * 1000 });
|
||||
} catch (error) {
|
||||
return { exitCode: -1, stdout: '', stderr: String(error?.message || error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!host || busy) return;
|
||||
busy = true;
|
||||
host.invalidate();
|
||||
try {
|
||||
const [capture, radar] = await Promise.all([runCli(commands.capture), runCli(commands.radar)]);
|
||||
model = modelOf(resultOf(capture), resultOf(radar), host.now());
|
||||
host.status(statusOf(model));
|
||||
} finally {
|
||||
busy = false;
|
||||
host.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
async function open() {
|
||||
await host.open({ id: PANE_ID, title: 'RuView', closeOnEscape: true });
|
||||
isOpen = true;
|
||||
stop();
|
||||
stopTimer = cancelOf(host.every(settings.refreshMs, () => { void refresh(); }));
|
||||
void refresh();
|
||||
}
|
||||
|
||||
async function close() {
|
||||
stop();
|
||||
isOpen = false;
|
||||
await host.close({ id: PANE_ID }).catch(() => undefined);
|
||||
}
|
||||
|
||||
on('session.start', async ($, e, next) => {
|
||||
cliPath = cliPathOf($.plugin.root);
|
||||
host = {
|
||||
run: (argv, init) => $.process.run(argv, init),
|
||||
every: (ms, fn) => $.clock.every(ms, fn),
|
||||
now: () => $.clock.now(),
|
||||
open: (pane) => $.ui.open(pane),
|
||||
close: (pane) => $.ui.close(pane),
|
||||
status: (text) => $.ui.status(text),
|
||||
invalidate: () => $.ui.invalidate('ui.render'),
|
||||
};
|
||||
await $.command.register({ name: COMMAND, description: 'RuView live sensing pane: CSI nodes and radar, refreshed', argumentHint: '[off|refresh]' }).catch(() => undefined);
|
||||
return next(e);
|
||||
});
|
||||
|
||||
on('command.run', { command: COMMAND }, async ($, e, next) => {
|
||||
if (!host) return next(e);
|
||||
const arg = String(e.args || '').trim();
|
||||
if (arg === 'off') { await close(); host.status(undefined); return { text: 'RuView pane closed.' }; }
|
||||
if (arg === 'refresh') { await refresh(); return { text: statusOf(model) }; }
|
||||
if (isOpen) { await close(); return { text: 'RuView pane closed.' }; }
|
||||
await open();
|
||||
return { text: `RuView pane open: nodes on UDP ${settings.udpPort}${settings.radarHost ? `, radar ${settings.radarHost}` : ''}; refreshing every ${Math.round(settings.refreshMs / 1000)}s.` };
|
||||
});
|
||||
|
||||
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
|
||||
if (e.requestId !== PANE_ID) return next(e);
|
||||
const ui = await $.ui.resolve(e);
|
||||
return viewOf(ui, model, { refreshMs: settings.refreshMs, busy, onRefresh: () => { void refresh(); }, onClose: () => { void close(); } });
|
||||
});
|
||||
|
||||
on('ui.close', { id: PANE_ID }, async ($, e, next) => {
|
||||
stop();
|
||||
isOpen = false;
|
||||
return next(e);
|
||||
});
|
||||
|
||||
on('session.end', async ($, e, next) => {
|
||||
stop();
|
||||
return next(e);
|
||||
});
|
||||
}
|
||||
96
harness/ruview/mod/tests/register.test.ts
Normal file
96
harness/ruview/mod/tests/register.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, mock, test, tier } from 'claude-code/testing'
|
||||
|
||||
tier('user')
|
||||
|
||||
const CAPTURE = {
|
||||
ok: true,
|
||||
packets: 120,
|
||||
decodedPackets: 120,
|
||||
heartbeatOnlySenders: [{ address: '192.168.1.67', heartbeats: 30 }],
|
||||
nodes: [
|
||||
{ source: 'esp32', nodeId: 42, csiRateHz: 4.7, csiLossFraction: 0, rssiMean: -63, csi: { shape: '1x64' } },
|
||||
{ source: 'realtek', nodeId: 3, csiRateHz: 348, csiLossFraction: 0.08, rssiMean: -39, csi: { shape: '1x52' } },
|
||||
],
|
||||
}
|
||||
|
||||
describe('register', () => {
|
||||
test('/ruview opens the pane, refreshes from the harness CLI, and stops on close', async ($, on) => {
|
||||
const runs: (readonly string[])[] = []
|
||||
const opened: string[] = []
|
||||
const closed: string[] = []
|
||||
const statuses: (string | undefined)[] = []
|
||||
const clock = mock.clock(on)
|
||||
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
||||
on('command.register', ($, e) => ({ value: { command: e.name } }))
|
||||
on('process.run', ($, e) => {
|
||||
runs.push(e.argv)
|
||||
return { value: { exitCode: 0, stdout: JSON.stringify(CAPTURE), stderr: '' } }
|
||||
})
|
||||
on('ui.open', ($, e) => {
|
||||
opened.push(e.id)
|
||||
return { value: undefined }
|
||||
})
|
||||
on('ui.close', ($, e) => {
|
||||
closed.push(e.id)
|
||||
return { value: undefined }
|
||||
})
|
||||
on('ui.status', ($, e) => {
|
||||
statuses.push(e.text)
|
||||
return { value: undefined }
|
||||
})
|
||||
on('ui.invalidate', () => ({ value: undefined }))
|
||||
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 5; i++) await clock.settle()
|
||||
}
|
||||
|
||||
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
||||
const { text } = await $.command.run({ command: 'ruview', args: '', origin: { kind: 'composer' } })
|
||||
await settle()
|
||||
|
||||
expect(text).toContain('RuView pane open')
|
||||
expect(opened).toEqual(['ruview-live'])
|
||||
expect(runs.length).toBeGreaterThanOrEqual(1)
|
||||
const argv = runs[0] ?? []
|
||||
expect(argv[0]).toBe('node')
|
||||
expect(String(argv[1])).toContain('bin/cli.js')
|
||||
expect(argv.slice(2)).toEqual(['esp32', '--seconds', '3', '--udp-port', '5005', '--json'])
|
||||
expect(statuses.at(-1)).toBe('RuView · 2 nodes · 1 alert')
|
||||
|
||||
const beforeTicks = runs.length
|
||||
for (let i = 0; i < 3; i++) {
|
||||
clock.advance(15_000)
|
||||
await settle()
|
||||
}
|
||||
expect(runs.length).toBeGreaterThan(beforeTicks)
|
||||
|
||||
await $.command.run({ command: 'ruview', args: '', origin: { kind: 'composer' } })
|
||||
await settle()
|
||||
expect(closed).toEqual(['ruview-live'])
|
||||
const afterClose = runs.length
|
||||
for (let i = 0; i < 4; i++) {
|
||||
clock.advance(15_000)
|
||||
await settle()
|
||||
}
|
||||
expect(runs.length).toBe(afterClose)
|
||||
})
|
||||
|
||||
test('/ruview refresh with no nodes reports the honest failure in the status', async ($, on) => {
|
||||
const statuses: (string | undefined)[] = []
|
||||
mock.clock(on)
|
||||
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
||||
on('command.register', ($, e) => ({ value: { command: e.name } }))
|
||||
on('process.run', () => ({ value: { exitCode: 1, stdout: JSON.stringify({ ok: false, reason: 'no_packets', packets: 0 }), stderr: '' } }))
|
||||
on('ui.status', ($, e) => {
|
||||
statuses.push(e.text)
|
||||
return { value: undefined }
|
||||
})
|
||||
on('ui.invalidate', () => ({ value: undefined }))
|
||||
|
||||
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
||||
const { text } = await $.command.run({ command: 'ruview', args: 'refresh', origin: { kind: 'composer' } })
|
||||
|
||||
expect(text).toBe('RuView · 0 nodes · 1 alert')
|
||||
expect(statuses.at(-1)).toBe('RuView · 0 nodes · 1 alert')
|
||||
})
|
||||
})
|
||||
3
harness/ruview/mod/tsconfig.json
Normal file
3
harness/ruview/mod/tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "./.claude-plugin/types/tsconfig.json"
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@ruvnet/ruview",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"description": "RuView WiFi-sensing operator harness \u2014 CLI, MCP server, and SDK for host device access (ESP32 CSI nodes, 60/24 GHz mmWave, LiDAR), SSH remote hosts, diagnostics, verified ESP32 flashing, evidence-gated training, calibration, and Cognitum Spaces.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
@ -39,6 +39,8 @@
|
|||
"brain/",
|
||||
"flywheel/",
|
||||
"scripts/",
|
||||
"mod/.claude-plugin/",
|
||||
"mod/hooks/",
|
||||
"CLAUDE.md",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ Harness:
|
|||
structured diagnostics with fixes (ADR-372)
|
||||
skills list bundled skills
|
||||
skill <name> print a skill playbook
|
||||
mod [--json] where the ruview-live Claude Code mod is, and how to load it (ADR-377)
|
||||
mcp start run the ruview.* MCP server (stdio)
|
||||
mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]
|
||||
MCP over HTTP for ChatGPT and remote clients (ADR-375);
|
||||
|
|
@ -291,6 +292,23 @@ export async function run(args, opts = {}) {
|
|||
return 0;
|
||||
}
|
||||
case 'mcp': return mcp(rest, flags, opts.mcpHandler);
|
||||
case 'mod': {
|
||||
// The ruview-live Claude Code mod ships inside this package (ADR-377).
|
||||
const dir = join(ROOT, 'mod');
|
||||
const info = {
|
||||
ok: existsSync(join(dir, '.claude-plugin', 'plugin.json')),
|
||||
plugin: 'ruview-live',
|
||||
path: dir,
|
||||
run: `claude --plugin-dir "${dir}"`,
|
||||
marketplace: ['/plugin marketplace add ruvnet/RuView', '/plugin install ruview-live@ruview'],
|
||||
usage: ['/ruview open or close the live pane', '/ruview refresh one capture now, result in the status line', '/ruview off close the pane'],
|
||||
configure: 'claude plugin configure ruview-live (udpPort, radarHost, refreshSeconds, captureSeconds)',
|
||||
note: 'Mods are early access in Claude Code. If /ruview is missing, start Claude Code with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.',
|
||||
};
|
||||
if (flags.json === true || !process.stdout.isTTY) pjson(info);
|
||||
else console.log([`ruview-live mod: ${info.path}`, ` try it: ${info.run}`, ` install: ${info.marketplace.join(' then ')}`, ...info.usage.map((u) => ` ${u}`), ` ${info.configure}`, ` ${info.note}`].join('\n'));
|
||||
return info.ok ? 0 : 1;
|
||||
}
|
||||
case 'agent': {
|
||||
if (rest[0] !== 'run') { console.error('Usage: ruview agent run --host claude-code|codex --prompt "..." [--repo <dir>]'); return 2; }
|
||||
const [{ findRepoRoot }, { getHost }] = await Promise.all([tools(), import('./hosts/index.js')]);
|
||||
|
|
|
|||
83
harness/ruview/test/mod.test.mjs
Normal file
83
harness/ruview/test/mod.test.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// The ruview-live Claude Code mod (ADR-377): its pure parts run under plain
|
||||
// Node; the engine-level behaviour is covered by `claude plugin test mod`.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { cliPathOf, commandsOf, modelOf, resultOf, settingsOf, statusOf, viewOf } from '../mod/hooks/register.mjs';
|
||||
|
||||
const capture = {
|
||||
ok: true, packets: 120, decodedPackets: 118,
|
||||
heartbeatOnlySenders: [{ address: '192.168.1.67', heartbeats: 30 }],
|
||||
nodes: [
|
||||
{ source: 'esp32', nodeId: 42, csiRateHz: 4.73, csiLossFraction: 0, rssiMean: -63.1, csi: { shape: '1x64' } },
|
||||
{ source: 'realtek', nodeId: 3, csiRateHz: 348, csiLossFraction: 0.08, rssiMean: -39, csi: { shape: '1x52', synthetic: true } },
|
||||
],
|
||||
};
|
||||
const radar = { ok: true, host: '192.168.1.102', device: { name: 'mr60-kit' }, presentNow: true, distanceCmMean: 40.2, heartBpmMean: 75.3, breathingBpmMean: 10.7 };
|
||||
|
||||
test('settings are bounded and radar hosts are validated', () => {
|
||||
assert.deepEqual(settingsOf({}), { udpPort: 5005, radarHost: '', refreshMs: 15000, captureSeconds: 3 });
|
||||
const s = settingsOf({ udpPort: 80, radarHost: ' 192.168.1.102 ', refreshSeconds: 1, captureSeconds: 99 });
|
||||
assert.deepEqual(s, { udpPort: 1024, radarHost: '192.168.1.102', refreshMs: 5000, captureSeconds: 10 });
|
||||
assert.equal(settingsOf({ radarHost: 'evil host; rm -rf /' }).radarHost, '');
|
||||
});
|
||||
|
||||
test('commands are fixed argv arrays for read-only CLI verbs', () => {
|
||||
const c = commandsOf(settingsOf({ radarHost: 'kit.local' }));
|
||||
assert.deepEqual(c.capture, ['esp32', '--seconds', '3', '--udp-port', '5005', '--json']);
|
||||
assert.deepEqual(c.radar, ['mmwave', '--source', 'esphome', '--host', 'kit.local', '--seconds', '3', '--json']);
|
||||
assert.equal(commandsOf(settingsOf({})).radar, null);
|
||||
for (const argv of [c.capture, c.radar]) assert.ok(!argv.some((a) => /flash|calibrate|train|confirm/.test(a)), 'never a write verb');
|
||||
assert.equal(cliPathOf('C:\\pkg\\mod\\'), 'C:\\pkg\\mod/../bin/cli.js');
|
||||
assert.equal(cliPathOf('/pkg/mod'), '/pkg/mod/../bin/cli.js');
|
||||
});
|
||||
|
||||
test('CLI output parses to its result or an honest failure', () => {
|
||||
assert.deepEqual(resultOf({ exitCode: 0, stdout: '{"ok":true}', stderr: '' }), { ok: true });
|
||||
assert.deepEqual(resultOf({ exitCode: 1, stdout: '{"ok":false,"reason":"no_packets"}', stderr: '' }), { ok: false, reason: 'no_packets' });
|
||||
assert.equal(resultOf({ exitCode: 127, stdout: '', stderr: 'node: not found' }).reason, 'cli_error');
|
||||
assert.equal(resultOf(null), null);
|
||||
});
|
||||
|
||||
test('model and status summarise nodes, radar and alerts', () => {
|
||||
const m = modelOf(capture, radar, Date.UTC(2026, 9, 1, 12));
|
||||
assert.deepEqual(m.nodes.map((n) => [n.label, n.rate, n.loss, n.lossy, n.synthetic]), [['esp32 42', '4.7 Hz', '0.0%', false, false], ['realtek 3', '348.0 Hz', '8.0%', true, true]]);
|
||||
assert.match(m.alerts[0].text, /192\.168\.1\.67 sends heartbeats but no CSI/);
|
||||
assert.deepEqual([m.radar.present, m.radar.distance, m.radar.heart], [true, '40.2 cm', '75.3 bpm']);
|
||||
assert.equal(statusOf(m), 'RuView · 2 nodes · radar present · 1 alert');
|
||||
const failed = modelOf({ ok: false, reason: 'port_in_use', remedy: 'stop the sensing server' }, { ok: false, reason: 'host_not_private', detail: '8.8.8.8' }, 0);
|
||||
assert.equal(failed.alerts.length, 2);
|
||||
assert.equal(statusOf(failed), 'RuView · 0 nodes · 2 alerts');
|
||||
assert.equal(statusOf(null), 'RuView · starting');
|
||||
});
|
||||
|
||||
test('the pane draws with the surface elements and wires both buttons', () => {
|
||||
const el = (type) => (props) => ({ type, props });
|
||||
const presses = [];
|
||||
const tree = viewOf({ Box: el('Box'), Text: el('Text'), Button: el('Button') }, modelOf(capture, radar, 0), {
|
||||
refreshMs: 15000, busy: false, onRefresh: () => presses.push('refresh'), onClose: () => presses.push('close'),
|
||||
});
|
||||
const flat = [];
|
||||
const walk = (n) => { if (!n || typeof n !== 'object') return; flat.push(n); [].concat(n.props?.children ?? []).forEach(walk); };
|
||||
walk(tree);
|
||||
const texts = flat.filter((n) => n.type === 'Text').map((n) => String(n.props.children));
|
||||
assert.ok(texts.some((t) => t.includes('heartbeats but no CSI')));
|
||||
assert.ok(texts.some((t) => t.startsWith('RADAR')));
|
||||
assert.ok(texts.some((t) => t.includes('not validated')));
|
||||
assert.ok(texts.some((t) => t === 'SYNTHETIC'));
|
||||
const buttons = flat.filter((n) => n.type === 'Button');
|
||||
assert.deepEqual(buttons.map((b) => [b.props.key, b.props.hotkey]), [['refresh', 'r'], ['close', 'c']]);
|
||||
buttons.forEach((b) => b.props.onPress());
|
||||
assert.deepEqual(presses, ['refresh', 'close']);
|
||||
const waiting = viewOf({ Box: el('Box'), Text: el('Text'), Button: el('Button') }, null, { refreshMs: 15000, busy: true, onRefresh() {}, onClose() {} });
|
||||
assert.match(JSON.stringify(waiting), /waiting for the first capture/);
|
||||
});
|
||||
|
||||
test('the mod manifest and hooks module are a valid plugin shape', () => {
|
||||
const plugin = JSON.parse(readFileSync(new URL('../mod/.claude-plugin/plugin.json', import.meta.url), 'utf8'));
|
||||
const hooks = JSON.parse(readFileSync(new URL('../mod/hooks/hooks.json', import.meta.url), 'utf8'));
|
||||
assert.equal(plugin.name, 'ruview-live');
|
||||
assert.deepEqual(hooks.modules, ['./register.mjs']);
|
||||
const src = readFileSync(new URL('../mod/hooks/register.mjs', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(src, /^\s*import\s/m, 'a mod imports nothing but its own files');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue