← Commit history
.gitignore+1
@@ -1,2 +1,3 @@ target/ *.log+bin/
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-usb"-version = "0.1.0"+version = "0.2.0" dependencies = [  "base64",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-usb"-version = "0.1.0"+version = "0.2.0" edition = "2021" description = "Adom USB device mesh — see every USB device across the Adom ecosystem (laptop, WSL2, cloud container, Azure VM, workcells) and mount/unmount/reset/test/sniff it from one dashboard" license = "MIT"
LICENSEadded+21
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2026 Adom Industries, Inc.++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
README.md+529−39
@@ -2,68 +2,558 @@  **See every USB device across the entire Adom ecosystem — and mount it anywhere.** -`adom-usb` is a Hydrogen webview app (+ CLI) that unifies USB across the whole-mesh: your **laptop**, its **WSL2 / Hydrogen Desktop**, this **cloud container**,-the **Azure VM** and its **WSL2**, and **workcells**. One table, Local/Cloud-filters, and per-device **mount / unmount / reset / test / force-class / sniff**.+`adom-usb` turns USB from a per-machine accident of geography into a **mesh**.+Plug a dev board into your laptop and it shows up in one dashboard alongside the+USB on your cloud container, your Azure VM, and the workcells on the bench — and+from that one table you can **mount** any device onto any host, then **reset /+test / force-class / sniff** it. It's the same Linux USB/IP "slingshot" Adom+already uses to pull workcell USB into cloud containers, generalized so your own+machines are first-class members of the mesh. -It extends the same Linux USB/IP "slingshot" that already moves workcell USB into-cloud containers — now with the user's own machines as first-class sources and-targets.+This project is **open source (MIT)**. All source — the Rust CLI, the embedded+webview, the inventory/routing engine, the plugin system — is in this repo and+published to the Adom Wiki. Contributions, and especially **sniffer/decoder+plugins**, are welcome; see [Contributing](#contributing) and+[Writing a plugin](#writing-a-packet-sniffer-plugin).  ![Dashboard](/blob/app/adom-usb/screenshots/hero.png) -## Run+---++## Table of contents++- [Install](#install)+- [Quick start](#quick-start)+- [How it works](#how-it-works)+  - [The mental model: a USB mesh](#the-mental-model-a-usb-mesh)+  - [USB/IP in 90 seconds](#usbip-in-90-seconds)+  - [Hosts, sources, and targets](#hosts-sources-and-targets)+  - [The routing matrix](#the-routing-matrix)+  - [Transport: how a device's port reaches a target](#transport-how-a-devices-port-reaches-a-target)+  - [The agent model](#the-agent-model)+  - [The broker / registry](#the-broker--registry)+  - [The dashboard: server-as-truth + AI-drivable](#the-dashboard-server-as-truth--ai-drivable)+  - [The device data model](#the-device-data-model)+  - [Capabilities: why buttons grey out](#capabilities-why-buttons-grey-out)+  - [Security model](#security-model)+- [HTTP API reference](#http-api-reference)+- [CLI reference](#cli-reference)+- [Building from source](#building-from-source)+- [Repo layout](#repo-layout)+- [Contributing](#contributing)+- [Writing a packet-sniffer plugin](#writing-a-packet-sniffer-plugin)+- [Publishing your plugin to the wiki](#publishing-your-plugin-to-the-wiki)+- [How the app discovers plugins](#how-the-app-discovers-plugins)+- [Roadmap](#roadmap)+- [License](#license)++---++## Install++```bash+adompkg install adom-usb          # from the Adom Wiki (recommended)+```++or grab the binary directly:  ```bash-adom-usb app                 # live: real lsusb on this container + registered agents+curl -fsSL https://wiki.adom.inc/download/john/adom-usb/0.2.0/adom-usb \+  -o /tmp/adom-usb && chmod +x /tmp/adom-usb \+  && sudo install -m 0755 /tmp/adom-usb /usr/local/bin/adom-usb && adom-usb install+```++## Quick start++```bash+adom-usb app                 # live: real lsusb here + any registered agents adom-usb app --demo          # seed a representative cross-ecosystem set for a tour-adom-usb list --demo         # unified inventory as JSON (no server)+adom-usb list --demo         # the unified inventory as JSON, no server adom-usb health              # is a dashboard server up?+adom-usb plugins list        # sniffer/decoder plugins installed on this host ``` -Opens a Hydrogen tab named **USB Devices**. Columns: device · VID:PID · serial ·-class · source/location · current mount · target picker + actions.+`adom-usb app` opens a Hydrogen webview tab named **USB Devices**. Columns:+device · VID:PID · serial · class · source/location · current mount · target+picker + actions. Filter chips: **All / Local / Cloud**. -## Drive it (every button is also an HTTP endpoint — AI-drivable)+Drive it from a terminal or from Claude — every button is also an HTTP endpoint:  ```bash adom-usb mount   laptop:2e8a:0003 laptop-wsl2     # RP2040 → laptop WSL2 (usbipd attach --wsl) adom-usb mount   laptop:2e8a:0003 this-container  # RP2040 → cloud container (reverse tunnel) adom-usb unmount laptop:2e8a:0003-curl -s localhost:8896/state | jq                 # hosts, devices, targets, mount state, action log-curl -sX POST localhost:8896/sniff -d '{"device_id":"..."}'+curl -s localhost:8896/state | jq                 # hosts, devices, targets, mounts, plugins, log+```++---++## How it works++### The mental model: a USB mesh++Normally a USB device is usable only by the machine it's physically plugged+into. `adom-usb` treats the fleet as a single fabric with two roles:++- A **source** is a host with physical USB ports that can *export* a device+  (your laptop, an Azure VM, a workcell).+- A **target** is a host that can *consume* a device as if it were locally+  attached (a cloud container, a WSL2 distro, a workcell).++A **mount** is a directed edge `(source, device) → target`. The dashboard is the+control plane over the whole graph; the actual bytes travel as standard Linux+USB/IP, tunneled over transports the Adom platform already has.++> The unifying idea: **"your laptop is a workcell that travels with you."** The+> same device model, association flow, and hot-plug notifications that already+> move workcell USB into cloud containers are reused verbatim — your personal+> machines just become additional sources/targets on the same fabric.++### USB/IP in 90 seconds++USB/IP is a Linux kernel facility that encapsulates **USB Request Blocks (URBs)+over TCP**. There are two halves:++- **Server side** (`usbipd` / `usbip bind`) exports a physical device on TCP+  port **3240**. On Windows the equivalent is **`usbipd-win`** (the+  Microsoft-documented project).+- **Client side** (`vhci-hcd` kernel module + `usbip attach`) creates a+  *virtual* USB host controller on the consuming machine. The remote device+  enumerates there exactly as if it were plugged in locally — `lsusb` sees it,+  drivers bind to it, `/dev/ttyUSB0` appears, etc.++`adom-usb` does not reinvent any of this. It **orchestrates** the standard tools+across the mesh and gives you one place to see and drive them. Every mount the+dashboard performs is recorded with the **exact shell command it runs**, visible+in the action log — nothing is hidden behind magic.++### Hosts, sources, and targets++The mesh's hosts (see [`src/devices.rs`](src/devices.rs) → `hosts()`):++| Host | Kind | OS | Exports USB? | Consumes USB? | Bucket |+|---|---|---|---|---|---|+| Your laptop | `laptop` | Windows | ✅ `usbipd-win` | native only (v1) | local |+| Laptop WSL2 / Hydrogen Desktop | `wsl2` | Linux | — | ✅ `vhci-hcd` | local |+| This cloud container | `container` | Linux | — | ✅ `vhci-hcd` | cloud |+| Azure VM desktop | `azure-vm` | Windows | ✅ `usbipd-win` | native only (v1) | cloud |+| Azure VM WSL2 / HD-on-VM | `wsl2` | Linux | — | ✅ `vhci-hcd` | cloud |+| Workcell (rpi, …) | `workcell` | Linux | ✅ `usbip` | ✅ | cloud |++Only **laptop / azure-vm / workcell** can export (they have physical ports ++an export agent). A device whose source is a container or a WSL2 distro is a+*consumer's* view of a device — it stays native on its host and is not+re-exportable. The dashboard reflects this: such rows show **"native — on its+host"** instead of a mount picker.++### The routing matrix++For a device on a given source, the set of legal targets (and the mechanism for+each) is computed in [`src/devices.rs`](src/devices.rs) → `targets_for()`:++| Source → Target | Mechanism |+|---|---|+| laptop board → **laptop Windows (native)** | re-bind to the host driver — trivial |+| laptop board → **laptop WSL2 / HD** | `usbipd bind` + `usbipd attach --wsl` (local, no relay) |+| laptop board → **cloud container** | `usbipd bind` → **reverse tunnel** → `usbip attach -r 127.0.0.1` |+| laptop board → **Azure-VM WSL2** | `usbipd bind` → tunnel → `usbip attach` in the VM's WSL2 |+| laptop board → **Azure-VM Windows desktop** | **deferred** — needs the test-signed usbip-win *client* driver |+| workcell device → any Linux target | existing direct USB/IP TCP path |++**v1 rule — Windows is source + local-only.** A Windows machine can export its+USB and natively use devices physically plugged into it, but mounting a *remote*+device *into* a native Windows desktop is deferred (it requires the test-signed+`usbip-win` client driver and Secure-Boot-off / test-signing). Those target+cells render as blocked, with the reason shown on hover.++### Transport: how a device's port reaches a target++Three cases, by physical topology:++1. **Same machine** (laptop USB → laptop WSL2): `usbipd attach --wsl`. No relay,+   no network — this is the Microsoft-supported path. The cleanest mount.+2. **Cross-machine** (laptop USB → cloud container, or → Azure-VM WSL2): the+   source `usbipd bind`s the device, then a **reverse tunnel** carries the+   source's local `:3240` to the target, which runs `usbip attach -r 127.0.0.1`.+   This reuses the adom-desktop relay's existing `TUNL` byte-pump — the same one+   that already tunnels RDP — but in the *opposite* listen-direction (the target+   listens; the source's agent dials the laptop's local `usbipd`). The+   implementation spec lives in the adom-desktop repo at `skills/USB_MESH.md`.+3. **Workcell → cloud**: the workcell's `usbipd` is reachable on the facility+   network; the container dials it directly on `:3240`. Unchanged from today.++Latency note: bulk/CDC/serial/JTAG/DFU/bootsel devices tunnel fine over a WAN;+**isochronous** devices (webcams, audio, some streaming logic analyzers) are+marginal over the relay and are best kept host-local. The UI labels this.++### The agent model++Every host exposes the same small verb set — `list / export / unexport / attach+/ detach / reset / class-override / sniff` — reached over whatever transport that+host already has:++- **Windows hosts** (laptop, Azure VM): the agent is a new `adom-desktop usb …`+  subcommand wrapping `usbipd-win`, reached over the adom-desktop relay.+- **Linux / WSL2 / container hosts**: a thin wrapper over `usbip` ++  `usbmon`/`driver_override`. The container side already exists from the workcell+  feature.+- **Workcells**: the existing agent, unchanged.++The **dashboard backend is the orchestrator**: on a mount it tells the source+agent to bind, sets up the tunnel if cross-machine, tells the target agent to+attach, and records state. It reaches the laptop + its WSL2 via the adom-desktop+relay and the Azure VM + its WSL2 via `azure-winvm`.++### The broker / registry++A generalized **Curium** broker (`source_type` / `source_id`, extending the+workcell `UsbipServer` model) is the durable registry: every agent registers its+live inventory and hot-plug events there, so all surfaces see one unified list.+To stay unblocked while that generalization lands, the dashboard can also+aggregate inventory by polling agents directly through their transports.++### The dashboard: server-as-truth + AI-drivable++The webview is a thin client. **The server is the single source of truth.** The+UI never holds its own copy of "what's mounted" — it polls `GET /state` every+~600 ms and re-renders on change ([`src/app/ui.html`](src/app/ui.html)). Every+button is a thin wrapper that `POST`s to an endpoint; the same endpoint is+curl-able. That's the defining property of an Adom app: the AI drives it exactly+like a human, and the UI's poller turns either one's state change into the same+visual update. Console logs forward to `/console`; in `--dev` mode a `/eval`+channel lets the AI hot-patch the running UI.++### The device data model++Each device is the `BoundDevice` shape the workcell USB/IP feature already uses+(see [`src/devices.rs`](src/devices.rs) → `Device`):++```jsonc+{+  "id": "laptop:10c4:ea60",          // stable: <source>:<vid>:<pid>+  "name": "CP2102 USB-to-UART Bridge",+  "vid": 4292, "pid": 60000,          // numeric; vid_hex/pid_hex added in /state+  "serial": "0001",+  "class": "CDC serial",+  "bus_id": "2-3",                    // USB/IP bus id on the source+  "source_id": "laptop",+  "source_label": "John's laptop (Windows)",+  "location": "local",                // local | cloud (filter bucket)+  "mounted_on": null,                 // host id, or null+  "exportable": true,                 // can it be slingshotted off its source?+  "targets": [ /* computed routing matrix, with mechanism + blocked_reason */ ],+  "capabilities": { "mount": true, "unmount": false, "reset": false,+                    "test": false, "class_override": false, "sniff": false },+  "sniffer_plugin": "adom-usb-sniffer-cdc"  // matched plugin, if any+}+```++### Capabilities: why buttons grey out++`reset` / `test` / `force-class` / `sniff` operate on the **virtual device in a+Linux target** — they only make sense once a device is attached to a Linux host.+So they are disabled (greyed, with a hover reason) until you mount the device on+a Linux target; for Windows-native devices they're unavailable entirely. `mount`+is offered only for exportable, not-yet-remote devices; `unmount` only when a+device is currently slingshotted to a host other than its source. See+`capabilities_for()` in [`src/devices.rs`](src/devices.rs).++### Security model++Slingshotting raw USB into a cloud container is **real device exposure**. The+design keeps mounts **explicit, per-device, per-session, and revocable** — never+auto-attach. Binding a device on Windows removes it from the host while it's+exported; the UI makes that state obvious and tears it down cleanly on unplug.+`usbipd-win` install needs a one-time elevation, folded into adom-desktop's+first-run.++---++## HTTP API reference++The app server (default port **8896**) is fully scriptable. All POST bodies are+JSON; all responses are JSON (`Content-Security-Policy: frame-ancestors *` so the+webview can embed it).++| Method | Path | Body | Purpose |+|---|---|---|---|+| GET | `/` | — | the dashboard HTML |+| GET | `/state` | — | full snapshot: `hosts`, `devices` (+ targets/capabilities/sniffer_plugin), `counts`, `plugins`, `actions` |+| GET | `/health` | — | `{ok, demo, devices, version}` |+| POST | `/refresh` | — | re-enumerate + bump `state_ts` |+| POST | `/mount` | `{device_id, target_id}` | validate against the routing matrix, set the mount, return the agent command |+| POST | `/unmount` | `{device_id}` | detach from its current target |+| POST | `/reset` | `{device_id}` | power-cycle / re-enumerate (Linux target only) |+| POST | `/test` | `{device_id}` | `lsusb -v` + enumerate/echo health check |+| POST | `/class-override` | `{device_id}` | bind a forced driver class (HID / storage / serial) |+| POST | `/sniff` | `{device_id}` | capture USB packets — **dispatches to a matching plugin** if one is installed, else built-in usbmon |+| GET | `/plugins` | — | `{installed, available, tag, api_version}` |+| POST | `/console` | log entry | UI console forwarding |+| GET | `/console` | — | ring buffer of UI logs (AI debugging) |+| POST | `/shutdown` | — | remove the tab and exit |++Mount/op responses always include the **exact `command`** that the live agent+runs, and (for sniff) the `plugin` that handled it — so the orchestration is+auditable and diffable against the real implementation as agents land.++---++## CLI reference++| Command | Purpose |+|---|---|+| `adom-usb app [--demo] [--no-tab] [--port N] [--dev]` | open the webview dashboard |+| `adom-usb list [--demo]` | print the unified inventory as JSON |+| `adom-usb mount <device_id> <target_id>` | mount (POSTs to a running server) |+| `adom-usb unmount <device_id>` | unmount |+| `adom-usb health [--port N]` | check a running server |+| `adom-usb plugins list` | installed sniffer/decoder plugins |+| `adom-usb plugins search` | publishable plugins on the wiki |+| `adom-usb install` | deploy the SKILL.md so Claude discovers the app |++Output follows the Adom CLI convention (`OK:` / `ERROR:` / `Hint:`, isatty-gated+color).++---++## Building from source++Requires Rust (stable). No system deps beyond `lsusb` for live enumeration.++```bash+git clone https://github.com/adom-inc/adom-usb && cd adom-usb+cargo build --release          # → target/release/adom-usb+./target/release/adom-usb app --demo --no-tab --port 8899+# open http://127.0.0.1:8899 (or the Coder proxy URL inside a container)+```++The UI is a single embedded file (`src/app/ui.html`, compiled in via+`include_str!`) — there is no JS build step. Edit it and rebuild.++## Repo layout++```+adom-usb/+  src/+    main.rs        # clap CLI surface+    cli.rs         # list / mount / unmount / health / plugins+    devices.rs     # the mesh model: hosts, routing matrix, capabilities, lsusb + demo+    plugins.rs     # plugin discovery, matching, dispatch  ← the extension seam+    install.rs     # deploy SKILL.md+    app/+      mod.rs       # tiny_http server: /state, /mount, /sniff, /plugins, …+      tab.rs       # Hydrogen tab registration (adom-cli)+      ui.html      # the entire dashboard (embedded)+  examples/+    adom-usb-sniffer-cdc/   # a reference sniffer plugin — copy this to start+  skills/adom-usb/SKILL.md  # the Claude-facing skill+  page.json / package.json  # wiki page + adompkg manifest+  screenshots/hero.png      # the billboard hero+```++---++## Contributing++`adom-usb` is MIT and developed in the open. Two equally valid front doors:++- **GitHub** — `github.com/adom-inc/adom-usb`. Fork, branch, PR. Keep changes+  focused; match the surrounding Rust style; run `cargo build --release` and+  `cargo clippy` clean.+- **The Adom Wiki** — the wiki page *is* a git repo with its own PR flow+  (`POST /api/v1/pages/adom-usb/prs`). Edits to docs, the README, or the page+  land there and mirror to GitHub.++Good first contributions:++- **A sniffer/decoder plugin** for a device class you care about (see below) —+  the highest-leverage way to grow the ecosystem.+- A new **host kind** or transport mechanism in `devices.rs`.+- Filling in a live **agent** (the `adom-desktop usb` verb, the reverse tunnel —+  spec in adom-desktop `skills/USB_MESH.md`).+- UI polish in `ui.html` (it's plain HTML/CSS/JS, brand tokens at the top).++Please don't commit the compiled binary to git (it ships in the adompkg+tarball); `bin/` and `target/` are gitignored.++---++## Writing a packet-sniffer plugin++A **plugin** extends the mesh with a new capture/decode backend — most commonly a+sniffer for a particular device class (CDC serial, HID, mass storage, a vendor+protocol). A plugin is nothing more than:++1. an **executable** that implements a tiny CLI contract, and+2. a **`plugin.json`** manifest describing what it sniffs and how to match it.++The complete reference plugin lives in+[`examples/adom-usb-sniffer-cdc/`](examples/adom-usb-sniffer-cdc/) — copy that+directory and edit it.++### 1. The manifest (`plugin.json`)++```jsonc+{+  "slug": "adom-usb-sniffer-cdc",      // unique; also the wiki package slug+  "name": "CDC / ACM serial sniffer",+  "version": "0.1.0",+  "api_version": "1",                   // plugin API version (current: 1)+  "kind": "sniffer",                    // sniffer | decoder+  "entrypoint": "adom-usb-sniffer-cdc", // executable on PATH (or in the plugin dir)+  "matches": {                          // which devices this applies to (globs; "*" = any)+    "class": ["CDC serial"],            // matched against the device's class string+    "vid":   ["*"],                     // 4-hex-digit vendor id, or "*"+    "pid":   ["*"]                      // 4-hex-digit product id, or "*"+  },+  "capture": "usbmon",                  // capture backend you use+  "decoders": ["cdc-acm", "at-commands"],+  "description": "Capture + decode USB CDC/ACM serial traffic to pcap."+} ``` -Each mount/op records the **exact agent command** it runs, visible in the-dashboard's action log.+**Matching & specificity.** When the user clicks **Sniff** on a device,+`adom-usb` picks the *most specific* installed plugin whose `matches` apply+(exact `class` beats `*`, a concrete `vid` beats `*`, etc. — see `match_for()`+in [`src/plugins.rs`](src/plugins.rs)). A plugin with `"class": ["*"]` is a+universal fallback; a plugin pinned to a `vid:pid` always wins for that device.++### 2. The executable contract (plugin API v1)++Your `entrypoint` must implement exactly two subcommands:++```bash+# Print your manifest (the app uses this to introspect installed plugins):+<entrypoint> manifest          # -> prints plugin.json to stdout++# Capture traffic for one device on the (Linux) host where it's mounted:+<entrypoint> sniff \+  --vid <hex> --pid <hex> --busid <bus-id> --target <host> \+  [--seconds <n>] [--out <file.pcap>]+# -> writes a pcap and prints a JSON result:+#    {"ok":true,"pcap":"/tmp/x.pcap","packets":128,"decoders":["cdc-acm"],"summary":"…"}+```++That's the whole interface. Inside `sniff` you typically `modprobe usbmon` and+run `tshark`/`tcpdump` filtered to the device's bus + vid/pid, then (optionally)+post-process into a human-readable decode. The reference plugin's ~40-line bash+shows the full pattern; a real decoder might be Rust/Python and emit a richer+summary. Plugins run **on the Linux target where the device is mounted**, so they+have the virtual device + `usbmon` available.++### 3. Test it locally++```bash+cd examples/adom-usb-sniffer-cdc && ./install.sh   # drops it in ~/.adom/usb-plugins/+adom-usb plugins list                              # should list your plugin+adom-usb app --demo --no-tab --port 8899 &+curl -s localhost:8899/plugins | jq                # installed: [ your plugin ]+curl -s localhost:8899/state | jq '.devices[]|{id,class,sniffer_plugin}'  # matches?+```++When a matching device is mounted on a Linux target and you hit Sniff, the app+calls your `sniff` subcommand and shows the result + the exact command in the+action log.++---++## Publishing your plugin to the wiki++Plugins are ordinary Adom Wiki packages — published with `adompkg`, exactly like+any other package, plus **two conventions** that make `adom-usb` find them:++1. Put the **`adom_usb_plugin`** block in your `package.json` (so an installed+   package carries the manifest), and+2. add **`adom-usb-plugin`** to the package's `keywords` (so the registry can+   surface it).++```jsonc+// package.json+{+  "slug": "adom-usb-sniffer-cdc",+  "version": "0.1.0",+  "type": "app",+  "description": "adom-usb plugin: capture + decode USB CDC/ACM serial to pcap.",+  "keywords": ["adom-usb-plugin", "sniffer", "usb", "cdc"],+  "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" },+  "files": ["adom-usb-sniffer-cdc", "plugin.json", "install.sh", "uninstall.sh", "package.json", "README.md"],+  "adom_usb_plugin": {+    "kind": "sniffer",+    "entrypoint": "adom-usb-sniffer-cdc",+    "matches": { "class": ["CDC serial"], "vid": ["*"], "pid": ["*"] },+    "capture": "usbmon",+    "decoders": ["cdc-acm"],+    "api_version": "1"+  }+}+```++Your `install.sh` should drop `plugin.json` + the entrypoint into+`~/.adom/usb-plugins/<slug>/` and symlink the entrypoint onto `PATH` (the+reference plugin does exactly this). Then publish like any Adom package (see the+`adom-wiki-v2` skill for the full flow):++```bash+WIKI=https://wiki.adom.inc; TOKEN=$(cat /var/run/adom/api-key)+# 1. create the page + git repo (first publish only)+curl -s -X POST "$WIKI/api/v1/pages" -H "Authorization: Bearer $TOKEN" \+  -H "Content-Type: application/json" -H "User-Agent: adompkg/2" \+  -d '{"type":"app","slug":"adom-usb-sniffer-cdc","title":"adom-usb CDC sniffer","brief":"…"}'+# 2. push the source files to the git layer (POST /pages/<slug>/files)+# 3. publish the tarball to the registry+export ADOMPKG_REGISTRY=$WIKI && adompkg publish+```++Now anyone can `adompkg install adom-usb-sniffer-cdc`, and every `adom-usb`+running on a host where it's installed will route the Sniff action for matching+devices to it.++---++## How the app discovers plugins++Discovery has two sources (both in [`src/plugins.rs`](src/plugins.rs)):++1. **Installed (local, cheap, every `/state`)** — `discover_installed()` scans:+   - `~/.adom/usb-plugins/<slug>/plugin.json` (where a plugin's `install.sh`+     drops its manifest + entrypoint), and+   - any `adompkg`-installed module under `~/project/adom_modules/<slug>/` whose+     `package.json` carries an `adom_usb_plugin` block.++   For each device, `/state` attaches `sniffer_plugin` = the slug of the+   best-matching installed plugin (or `null`), so the UI can badge it and the+   Sniff button knows where to route. -## Routing matrix (v1)+2. **Registry (network, on demand)** — `discover_registry()` queries the wiki's+   discovery API for publishable plugins not yet installed:+   `GET https://wiki.adom.inc/api/v1/discover?triggers=adom-usb-plugin`. Surfaced+   via `GET /plugins` (`available`) and `adom-usb plugins search`, so the+   dashboard can show "plugins you could install" with a one-click+   `adompkg install`. -Windows is **source + local-only**: a Windows box can export its USB and natively-use devices plugged into it, but a *remote* device cannot yet be mounted *into* a-native Windows desktop (needs the test-signed usbip-win client driver — those-targets show blocked). Linux targets (cloud container, laptop WSL2/HD, Azure-WSL2, workcells) accept remote mounts via `usbipd attach --wsl` (local) or the-adom-desktop **reverse tunnel** (cross-machine).+When you click **Sniff** on a mounted device, the server calls+`plugins::match_for(class, vid, pid, installed)`; if a plugin matches, the action+dispatches to its `sniff` entrypoint and the response/action-log record the+plugin slug. If none matches, it falls back to the built-in `usbmon` capture.+That single `match_for` seam is the entire contract between the app and the+ecosystem — add a plugin, and the mesh grows. -`reset` / `test` / `force-class` / `sniff` operate on the virtual device in a-**Linux target** — greyed out until the device is mounted on one.+--- -## Architecture+## Roadmap -- **Agents** (per host) expose `list / export / attach / detach / reset / class / sniff`,-  reached over the transport each host already has (adom-desktop relay for the-  laptop + its WSL2; `azure-winvm` for the Azure VM + its WSL2).-- **Transport**: same-machine = `usbipd attach --wsl`; cross-machine = the-  adom-desktop reverse tunnel; workcell→cloud = existing direct TCP.-- **Registry**: a generalized Curium broker (source_type/source_id) gives one-  unified inventory with live hot-plug.+- **M1 — Unified inventory** ✅ (this release): one cross-ecosystem table,+  Local/Cloud filters, the plugin discovery layer.+- **M2 — Mount/route matrix**: local `usbipd attach --wsl` → reverse-tunnel to+  cloud → cross-machine to Azure WSL2. Gated on the WSL2 custom kernel+  (`CONFIG_USBIP_VHCI_HCD=m`) baked into the Hydrogen Desktop golden image.+- **M3 — Reset + test/enumerate**.+- **M4 — Force-class-as** (HID / mass-storage / serial via `driver_override`).+- **M5 — Sniff/debug** built-in + the plugin ecosystem documented here. -See the project plan for the M1→M5 build sequence.+The live agents (the `adom-desktop usb` verb wrapping `usbipd-win`, and the+reverse tunnel) are specced in the adom-desktop repo at `skills/USB_MESH.md`. -## Status+## License -M1: unified inventory + mount overlay; each action emits the precise agent-command and wires to the live agents (usbipd-win over the relay, `usbip attach`-in the target, usbmon) as those land.+[MIT](LICENSE) © Adom Industries, Inc. All source is open; contributions welcome.
VERSION+1−1
@@ -1 +1 @@-0.1.0+0.2.0
examples/adom-usb-sniffer-cdc/README.mdadded+13
@@ -0,0 +1,13 @@+# adom-usb-sniffer-cdc++Reference **adom-usb sniffer plugin** — captures and decodes USB CDC/ACM serial+traffic to a pcap. Copy this directory as the starting point for your own plugin.++```bash+adompkg install adom-usb-sniffer-cdc     # or: ./install.sh from a clone+```++Once installed, `adom-usb` discovers it automatically (it scans+`~/.adom/usb-plugins/*/plugin.json`) and routes the **Sniff** action for any+CDC-serial device to this plugin. See the adom-usb wiki page → "Writing a+packet-sniffer plugin" for the full plugin API.
examples/adom-usb-sniffer-cdc/adom-usb-sniffer-cdcadded+54
@@ -0,0 +1,54 @@+#!/usr/bin/env bash+# adom-usb-sniffer-cdc — reference adom-usb sniffer plugin (plugin API v1).+#+# A plugin is just an executable that implements two subcommands:+#   manifest               -> print plugin.json to stdout+#   sniff --vid <hex> --pid <hex> --busid <b> --target <host> [--seconds N] [--out FILE]+#                          -> capture traffic for that device on that (Linux) target,+#                             write a pcap, and print a JSON result to stdout.+#+# adom-usb finds this plugin via its plugin.json (matches CDC-serial devices),+# and calls `sniff` when the user hits the Sniff button on a matching device+# that is mounted on a Linux target. Everything below is standard usbmon ++# tshark; swap in your own decode for a custom protocol.+set -euo pipefail+HERE="$(cd "$(dirname "$0")" && pwd)"++cmd="${1:-}"; shift || true+case "$cmd" in+  manifest)+    cat "$HERE/plugin.json"+    ;;+  sniff)+    VID="" PID="" BUSID="" TARGET="" SECONDS_N=10 OUT=""+    while [ $# -gt 0 ]; do+      case "$1" in+        --vid) VID="$2"; shift 2;;+        --pid) PID="$2"; shift 2;;+        --busid) BUSID="$2"; shift 2;;+        --target) TARGET="$2"; shift 2;;+        --seconds) SECONDS_N="$2"; shift 2;;+        --out) OUT="$2"; shift 2;;+        *) shift;;+      esac+    done+    OUT="${OUT:-/tmp/adom-usb-cdc-${VID}-${PID}.pcap}"+    # usbmon must be loaded on the target; tshark captures the device's bus.+    # (adom-usb runs this on the Linux host where the device is mounted.)+    BUS_NUM="${BUSID%%-*}"+    sudo modprobe usbmon 2>/dev/null || true+    if command -v tshark >/dev/null; then+      timeout "$SECONDS_N" tshark -i "usbmon${BUS_NUM}" -w "$OUT" \+        -f "usb.idVendor==0x${VID} && usb.idProduct==0x${PID}" 2>/dev/null || true+      PKTS=$(tshark -r "$OUT" 2>/dev/null | wc -l | tr -d ' ')+    else+      PKTS=0+    fi+    printf '{"ok":true,"pcap":"%s","packets":%s,"decoders":["cdc-acm"],"summary":"captured %s CDC packets for %s:%s on %s"}\n' \+      "$OUT" "${PKTS:-0}" "${PKTS:-0}" "$VID" "$PID" "$TARGET"+    ;;+  *)+    echo 'usage: adom-usb-sniffer-cdc manifest | sniff --vid <hex> --pid <hex> --busid <b> --target <host> [--seconds N] [--out FILE]' >&2+    exit 2+    ;;+esac
examples/adom-usb-sniffer-cdc/install.shadded+10
@@ -0,0 +1,10 @@+#!/bin/bash+# Installs the plugin so adom-usb discovers it: drops plugin.json + entrypoint+# into ~/.adom/usb-plugins/<slug>/ and puts the entrypoint on PATH.+set -euo pipefail+DIR="$(cd "$(dirname "$0")" && pwd)"; SLUG="adom-usb-sniffer-cdc"+PDIR="${HOME}/.adom/usb-plugins/${SLUG}"; mkdir -p "$PDIR" "${HOME}/.local/bin"+install -m 0644 "$DIR/plugin.json" "$PDIR/plugin.json"+install -m 0755 "$DIR/$SLUG" "$PDIR/$SLUG"+ln -sf "$PDIR/$SLUG" "${HOME}/.local/bin/$SLUG"+echo "Installed plugin -> $PDIR (adom-usb will discover it on next refresh)"
examples/adom-usb-sniffer-cdc/package.jsonadded+20
@@ -0,0 +1,20 @@+{+  "slug": "adom-usb-sniffer-cdc",+  "version": "0.1.0",+  "type": "app",+  "description": "adom-usb plugin: capture + decode USB CDC/ACM serial traffic to pcap.",+  "dependencies": {},+  "keywords": ["adom-usb-plugin", "sniffer", "usb", "cdc", "serial", "pcap"],+  "license": "MIT",+  "needs_sudo": false,+  "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" },+  "files": ["adom-usb-sniffer-cdc", "plugin.json", "install.sh", "uninstall.sh", "package.json", "README.md"],+  "adom_usb_plugin": {+    "kind": "sniffer",+    "entrypoint": "adom-usb-sniffer-cdc",+    "matches": { "class": ["CDC serial"], "vid": ["*"], "pid": ["*"] },+    "capture": "usbmon",+    "decoders": ["cdc-acm", "at-commands"],+    "api_version": "1"+  }+}
examples/adom-usb-sniffer-cdc/plugin.jsonadded+13
@@ -0,0 +1,13 @@+{+  "slug": "adom-usb-sniffer-cdc",+  "name": "CDC / ACM serial sniffer",+  "version": "0.1.0",+  "api_version": "1",+  "kind": "sniffer",+  "entrypoint": "adom-usb-sniffer-cdc",+  "matches": { "class": ["CDC serial"], "vid": ["*"], "pid": ["*"] },+  "capture": "usbmon",+  "decoders": ["cdc-acm", "at-commands"],+  "description": "Capture and decode USB CDC/ACM serial traffic (virtual COM ports, modems, many dev boards) to a pcap + a human-readable line decode.",+  "homepage": "https://wiki.adom.inc/<your-user>/adom-usb-sniffer-cdc"+}
examples/adom-usb-sniffer-cdc/uninstall.shadded+5
@@ -0,0 +1,5 @@+#!/bin/bash+set -euo pipefail+SLUG="adom-usb-sniffer-cdc"+rm -rf "${HOME}/.adom/usb-plugins/${SLUG}" "${HOME}/.local/bin/${SLUG}"+echo "Removed plugin ${SLUG}"
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-usb",-  "version": "0.1.0",+  "version": "0.2.0",   "type": "app",   "description": "See every USB device across the entire Adom ecosystem — laptop, WSL2 / Hydrogen Desktop, cloud container, Azure VM, workcells — in one dashboard, and mount / unmount / reset / test / force-class / sniff any of them. USB/IP slingshot for the whole mesh.",   "dependencies": {},
page.json+9−28
@@ -4,32 +4,16 @@   "title": "Adom USB",   "brief": "See every USB device across the entire Adom ecosystem — laptop, WSL2, cloud container, Azure VM, workcells — in one dashboard, and mount / unmount / reset / test / force-class / sniff any of them. USB/IP slingshot for the whole mesh.",   "visibility": "public",-  "hero": {-    "type": "image",-    "path": "screenshots/hero.png"-  },-  "install": {-    "binary_name": "adom-usb"-  },+  "hero": { "type": "image", "path": "screenshots/hero.png" },+  "install": { "binary_name": "adom-usb" },   "metadata": {     "repo": "adom-inc/adom-usb",-    "version": "0.1.0",+    "version": "0.2.0",     "discovery_triggers": [-      "usb devices",-      "usb dashboard",-      "list usb",-      "mount usb",-      "attach usb",-      "usbip",-      "slingshot usb",-      "mount my dev board",-      "rp2040 to wsl2",-      "mount device to container",-      "usb mesh",-      "where is my usb device",-      "force hid",-      "sniff usb",-      "usb passthrough"+      "usb devices", "usb dashboard", "list usb", "mount usb", "attach usb",+      "usbip", "slingshot usb", "mount my dev board", "rp2040 to wsl2",+      "mount device to container", "usb mesh", "where is my usb device",+      "force hid", "sniff usb", "usb passthrough"     ],     "discovery_pitch": "One dashboard for every USB device across your laptop, WSL2 / Hydrogen Desktop, cloud container, Azure VM, and workcells — mount any device onto any host, then reset / test / force-class / sniff it.",     "releases": {@@ -39,8 +23,5 @@         "install_hint": "adompkg install adom-usb"       }     }-  },-  "hero_path": "screenshots/hero.png",-  "hero_type": "image",-  "updated_at": "2026-06-20T02:32:29.598Z"-}\ No newline at end of file+  }+}
skills/adom-usb/SKILL.md+17
@@ -52,6 +52,23 @@ laptop WSL2/HD, Azure WSL2, workcells) accept remote mounts via `usbipd attach reset / test / force-class / sniff operate on the virtual device in a **Linux target** — they are greyed out until the device is mounted on one. +## Plugins (sniffers / decoders)++adom-usb is extensible: sniffer/decoder plugins are ordinary `adompkg` packages+tagged `adom-usb-plugin` that drop a `plugin.json` + entrypoint into+`~/.adom/usb-plugins/`. The app discovers them automatically and routes the+**Sniff** action for a matching device class to the best-matching plugin.++```bash+adom-usb plugins list        # installed plugins on this host+adom-usb plugins search      # publishable plugins on the wiki+curl -s localhost:8896/plugins | jq+```++To author one: copy `examples/adom-usb-sniffer-cdc/`, edit `plugin.json`+(`matches` by class/vid/pid) + the `manifest`/`sniff` entrypoint, then publish+with `adompkg`. Full guide: the wiki page → "Writing a packet-sniffer plugin".+ ## Status  M1 ships the unified inventory + the mount overlay; each action emits the exact
src/app/mod.rs+42−3
@@ -171,8 +171,29 @@ pub fn run(args: AppArgs) -> Result<(), String> {                 snap["dev"] = json!(st.dev);                 snap["state_ts"] = json!(*st.state_ts.lock().unwrap() as u64);                 snap["actions"] = json!(st.actions.lock().unwrap().iter().rev().take(20).cloned().collect::<Vec<_>>());+                // Installed plugins + which sniffer matches each device.+                let installed = crate::plugins::discover_installed();+                snap["plugins"] = crate::plugins::to_json(&installed);+                if let Some(devs) = snap.get_mut("devices").and_then(|d| d.as_array_mut()) {+                    for d in devs.iter_mut() {+                        let class = d.get("class").and_then(|v| v.as_str()).unwrap_or("");+                        let vid = d.get("vid").and_then(|v| v.as_u64()).unwrap_or(0) as u16;+                        let pid = d.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u16;+                        d["sniffer_plugin"] = json!(crate::plugins::match_for(class, vid, pid, &installed).map(|p| p.slug.clone()));+                    }+                }                 (200, snap)             }+            (Method::Get, "/plugins") => {+                let installed = crate::plugins::discover_installed();+                let available = crate::plugins::discover_registry(&installed);+                (200, json!({+                    "installed": crate::plugins::to_json(&installed),+                    "available": crate::plugins::to_json(&available),+                    "tag": crate::plugins::PLUGIN_TAG,+                    "api_version": crate::plugins::PLUGIN_API_VERSION,+                }))+            }             (Method::Post, "/refresh") => {                 *st.state_ts.lock().unwrap() = now_ms();                 (200, snapshot(&st))@@ -323,11 +344,29 @@ fn handle_device_op(st: &AppState, req: &mut tiny_http::Request, op: &str) -> (u         return (409, json!({"error": reason, "blocked": true}));     }     let target = dev.get("mounted_on").and_then(|v| v.as_str()).unwrap_or("");-    let cmd = agent_command(op, &dev, target, "");+    // For sniff, prefer a matching installed plugin's entrypoint; otherwise the+    // built-in usbmon capture. This is the extension seam the ecosystem hooks.+    let (cmd, plugin) = if op == "sniff" {+        let class = dev.get("class").and_then(|v| v.as_str()).unwrap_or("");+        let vid = dev.get("vid").and_then(|v| v.as_u64()).unwrap_or(0) as u16;+        let pid = dev.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u16;+        let bus = dev.get("bus_id").and_then(|v| v.as_str()).unwrap_or("?");+        let installed = crate::plugins::discover_installed();+        match crate::plugins::match_for(class, vid, pid, &installed) {+            Some(p) => (+                format!("[{target}] {} sniff --vid {:04x} --pid {:04x} --busid {} --target {} --out <pcap>",+                    p.entrypoint, vid, pid, bus, target),+                Some(p.slug.clone()),+            ),+            None => (agent_command(op, &dev, target, ""), None),+        }+    } else {+        (agent_command(op, &dev, target, ""), None)+    };     log_action(st, json!({         "ts": now_ms() as u64, "op": op, "device_id": device_id,-        "device": dev.get("name"), "target": target, "command": cmd,+        "device": dev.get("name"), "target": target, "command": cmd, "plugin": plugin,         "status": "queued"  // executes once the live agent for `target` is wired     }));-    (200, json!({"ok": true, "op": op, "device_id": device_id, "command": cmd, "status": "queued"}))+    (200, json!({"ok": true, "op": op, "device_id": device_id, "command": cmd, "plugin": plugin, "status": "queued"})) }
src/cli.rs+28
@@ -33,6 +33,34 @@ pub fn run_mount(a: MountArgs) -> Result<(), String> {     Ok(()) } +pub fn run_plugins(sub: crate::PluginsCmd) -> Result<(), String> {+    match sub {+        crate::PluginsCmd::List => {+            let ps = crate::plugins::discover_installed();+            if ps.is_empty() {+                note!("no plugins installed. Search the wiki: adom-usb plugins search");+            } else {+                for p in &ps {+                    note!("{}  v{}  [{}]  matches {}", p.slug, p.version, p.kind,+                        serde_json::to_string(&p.matches).unwrap_or_default());+                }+            }+            ok(format!("{} plugin(s) installed", ps.len()));+        }+        crate::PluginsCmd::Search => {+            let installed = crate::plugins::discover_installed();+            let avail = crate::plugins::discover_registry(&installed);+            if avail.is_empty() {+                note!("no installable plugins found on the wiki (tag: {})", crate::plugins::PLUGIN_TAG);+            } else {+                for p in &avail { note!("{}  {}  — adompkg install {}", p.slug, p.description, p.slug); }+            }+            ok(format!("{} plugin(s) available to install", avail.len()));+        }+    }+    Ok(())+}+ pub fn run_health(port: u16) -> Result<(), String> {     let url = format!("http://127.0.0.1:{}/health", port);     match ureq::get(&url).call() {
src/main.rs+15
@@ -11,6 +11,7 @@ mod app; mod cli; mod devices; mod install;+mod plugins;  use clap::{CommandFactory, Parser, Subcommand}; use std::io::IsTerminal;@@ -88,6 +89,11 @@ enum Cmd {         #[arg(long, default_value_t = 8896)]         port: u16,     },+    /// List installed sniffer/decoder plugins, or search the wiki for more.+    Plugins {+        #[command(subcommand)]+        sub: PluginsCmd,+    },     /// Install SKILL.md + bash completions.     Install,     /// Print shell completion script.@@ -105,6 +111,14 @@ pub struct AppArgs {     #[arg(long, env = "ADOM_USB_DEV")] pub dev: bool, } +#[derive(Subcommand)]+pub enum PluginsCmd {+    /// List plugins installed on this host.+    List,+    /// Search the Adom Wiki for publishable adom-usb plugins.+    Search,+}+ #[derive(clap::Args)] pub struct MountArgs {     pub device_id: String,@@ -126,6 +140,7 @@ fn main() -> ExitCode {         Cmd::Mount(a) => cli::run_mount(a),         Cmd::Unmount(a) => cli::run_unmount(a),         Cmd::Health { port } => cli::run_health(port),+        Cmd::Plugins { sub } => cli::run_plugins(sub),         Cmd::Install => install::install(),         Cmd::Completions { shell } => {             let mut cmd = Cli::command();
src/plugins.rsadded+182
@@ -0,0 +1,182 @@+//! Plugin discovery + matching for adom-usb.+//!+//! A plugin extends the mesh with new capture/decode backends — most commonly a+//! **packet sniffer** for a particular device class (CDC serial, HID, mass+//! storage, a vendor protocol). Plugins are ordinary Adom Wiki packages+//! (`adompkg`), tagged so the app can find them, that drop a `plugin.json`+//! manifest + an executable entrypoint on the host.+//!+//! Discovery has two sources:+//!   1. **Installed** — local filesystem scan of `~/.adom/usb-plugins/<slug>/plugin.json`+//!      and any `adompkg`-installed module whose package.json carries an+//!      `adom_usb_plugin` block. Cheap; runs on every /state.+//!   2. **Registry** — a best-effort query to the Adom Wiki discovery API for+//!      packages tagged `adom-usb-plugin` that are NOT yet installed. Network;+//!      only on demand (`/plugins/registry`, `adom-usb plugins search`).+//!+//! The plugin CLI contract (what an entrypoint must implement):+//!   <entrypoint> manifest+//!       -> prints its plugin.json to stdout+//!   <entrypoint> sniff --vid <hex> --pid <hex> --busid <b> --target <host> \+//!                      [--seconds <n>] [--out <file.pcap>]+//!       -> captures traffic for that device on that (Linux) target and prints+//!          {"ok":true,"pcap":"<path>","packets":<n>,"summary":"..."}++use serde::Serialize;+use serde_json::{json, Value};+use std::path::PathBuf;++pub const PLUGIN_TAG: &str = "adom-usb-plugin";+pub const PLUGIN_API_VERSION: &str = "1";++#[derive(Serialize, Clone)]+pub struct Plugin {+    pub slug: String,+    pub name: String,+    pub version: String,+    pub api_version: String,+    pub kind: String,+    pub entrypoint: String,+    /// {"class": ["CDC serial","*"], "vid": ["*"], "pid": ["*"]}+    pub matches: Value,+    pub capture: String,+    pub decoders: Vec<String>,+    pub description: String,+    /// "installed" | "registry"+    pub source: String,+    pub installed: bool,+}++fn plugins_dir() -> PathBuf {+    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());+    PathBuf::from(home).join(".adom/usb-plugins")+}++fn from_manifest(v: &Value, source: &str, installed: bool) -> Option<Plugin> {+    let slug = v.get("slug").and_then(|x| x.as_str())?.to_string();+    Some(Plugin {+        slug: slug.clone(),+        name: v.get("name").and_then(|x| x.as_str()).unwrap_or(&slug).to_string(),+        version: v.get("version").and_then(|x| x.as_str()).unwrap_or("0.0.0").to_string(),+        api_version: v.get("api_version").and_then(|x| x.as_str()).unwrap_or("1").to_string(),+        kind: v.get("kind").and_then(|x| x.as_str()).unwrap_or("sniffer").to_string(),+        entrypoint: v.get("entrypoint").and_then(|x| x.as_str()).unwrap_or(&slug).to_string(),+        matches: v.get("matches").cloned().unwrap_or(json!({"class":["*"],"vid":["*"],"pid":["*"]})),+        capture: v.get("capture").and_then(|x| x.as_str()).unwrap_or("usbmon").to_string(),+        decoders: v.get("decoders").and_then(|x| x.as_array())+            .map(|a| a.iter().filter_map(|d| d.as_str().map(String::from)).collect())+            .unwrap_or_default(),+        description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),+        source: source.to_string(),+        installed,+    })+}++/// Scan the local filesystem for installed plugins.+pub fn discover_installed() -> Vec<Plugin> {+    let mut out = Vec::new();++    // 1. The app's own plugin dir: ~/.adom/usb-plugins/<slug>/plugin.json+    if let Ok(rd) = std::fs::read_dir(plugins_dir()) {+        for e in rd.flatten() {+            if let Ok(s) = std::fs::read_to_string(e.path().join("plugin.json")) {+                if let Ok(v) = serde_json::from_str::<Value>(&s) {+                    if let Some(p) = from_manifest(&v, "installed", true) { out.push(p); }+                }+            }+        }+    }++    // 2. adompkg-installed modules whose package.json carries an adom_usb_plugin block.+    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());+    if let Ok(rd) = std::fs::read_dir(PathBuf::from(&home).join("project/adom_modules")) {+        for e in rd.flatten() {+            if let Ok(s) = std::fs::read_to_string(e.path().join("package.json")) {+                if let Ok(v) = serde_json::from_str::<Value>(&s) {+                    if let Some(block) = v.get("adom_usb_plugin") {+                        let mut m = block.clone();+                        for k in ["slug", "version", "description"] {+                            if m.get(k).is_none() {+                                if let Some(val) = v.get(k) { m[k] = val.clone(); }+                            }+                        }+                        if let Some(p) = from_manifest(&m, "installed", true) { out.push(p); }+                    }+                }+            }+        }+    }+    out.sort_by(|a, b| a.slug.cmp(&b.slug));+    out.dedup_by(|a, b| a.slug == b.slug);+    out+}++/// Best-effort registry query for publishable (not-yet-installed) plugins.+pub fn discover_registry(installed: &[Plugin]) -> Vec<Plugin> {+    let url = format!("https://wiki.adom.inc/api/v1/discover?triggers={}", PLUGIN_TAG);+    let resp = match ureq::get(&url)+        .timeout(std::time::Duration::from_millis(2500))+        .set("User-Agent", "adom-usb/plugin-discovery")+        .call()+    {+        Ok(r) => r,+        Err(_) => return Vec::new(),+    };+    let v: Value = match resp.into_json() { Ok(v) => v, Err(_) => return Vec::new() };+    let arr = if v.is_array() { v.as_array().cloned().unwrap_or_default() }+        else { v.get("results").or_else(|| v.get("packages")).and_then(|x| x.as_array()).cloned().unwrap_or_default() };+    let have: std::collections::HashSet<&str> = installed.iter().map(|p| p.slug.as_str()).collect();+    arr.iter().filter_map(|item| {+        let slug = item.get("slug").and_then(|s| s.as_str())?;+        if have.contains(slug) || slug == "adom-usb" { return None; }+        Some(Plugin {+            slug: slug.to_string(),+            name: item.get("title").and_then(|s| s.as_str()).unwrap_or(slug).to_string(),+            version: item.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string(),+            api_version: PLUGIN_API_VERSION.to_string(),+            kind: "sniffer".to_string(),+            entrypoint: slug.to_string(),+            matches: json!({"class":["*"]}),+            capture: "".to_string(),+            decoders: vec![],+            description: item.get("brief").or_else(|| item.get("description")).and_then(|s| s.as_str()).unwrap_or("").to_string(),+            source: "registry".to_string(),+            installed: false,+        })+    }).collect()+}++fn glob_match(pat: &str, val: &str) -> bool {+    pat == "*" || pat.eq_ignore_ascii_case(val) || val.to_lowercase().contains(&pat.to_lowercase())+}++/// Pick the best installed plugin for a device by class/vid/pid. A more+/// specific match (exact class, non-"*" vid) wins over a wildcard.+pub fn match_for<'a>(class: &str, vid: u16, pid: u16, plugins: &'a [Plugin]) -> Option<&'a Plugin> {+    let vid_s = format!("{:04x}", vid);+    let pid_s = format!("{:04x}", pid);+    let mut best: Option<(&Plugin, i32)> = None;+    for p in plugins.iter().filter(|p| p.kind == "sniffer") {+        let m = &p.matches;+        let list = |k: &str| m.get(k).and_then(|x| x.as_array()).map(|a|+            a.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>())+            .unwrap_or_else(|| vec!["*".into()]);+        let classes = list("class");+        let vids = list("vid");+        let pids = list("pid");+        let class_ok = classes.iter().any(|p| glob_match(p, class));+        let vid_ok = vids.iter().any(|p| glob_match(p, &vid_s));+        let pid_ok = pids.iter().any(|p| glob_match(p, &pid_s));+        if class_ok && vid_ok && pid_ok {+            let score = classes.iter().any(|p| p != "*") as i32 * 2+                + vids.iter().any(|p| p != "*") as i32+                + pids.iter().any(|p| p != "*") as i32;+            if best.map(|(_, s)| score > s).unwrap_or(true) { best = Some((p, score)); }+        }+    }+    best.map(|(p, _)| p)+}++pub fn to_json(plugins: &[Plugin]) -> Value {+    serde_json::to_value(plugins).unwrap_or(json!([]))+}
wiki/page.json+1−1
@@ -5,7 +5,7 @@   "brief": "See every USB device across the entire Adom ecosystem \u2014 laptop, WSL2, cloud container, Azure VM, workcells \u2014 in one dashboard, and mount / unmount / reset / test / force-class / sniff any of them. USB/IP slingshot for the whole mesh.",   "metadata": {     "repo": "adom-inc/adom-usb",-    "version": "0.1.0",+    "version": "0.2.0",     "releases": {       "adom_docker": {         "asset_name": "adom-usb",