refactor: simplify cli commands

This commit is contained in:
Peter Steinberger
2026-01-08 07:16:05 +01:00
parent 79ac0af719
commit 19595a8f99
33 changed files with 359 additions and 1427 deletions

View File

@@ -16,6 +16,7 @@
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context. - Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. - Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides). - Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
- CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops.
### Fixes ### Fixes
- macOS: harden Voice Wake tester/runtime (pause trigger, mic persistence, local-only tester) and keep transcript logs private. Thanks @xadenryan for PR #438. - macOS: harden Voice Wake tester/runtime (pause trigger, mic persistence, local-only tester) and keep transcript logs private. Thanks @xadenryan for PR #438.

View File

@@ -42,7 +42,6 @@ clawdbot [--dev] [--profile <name>] <command>
setup setup
onboard onboard
configure (alias: config) configure (alias: config)
update
doctor doctor
login login
logout logout
@@ -65,12 +64,6 @@ clawdbot [--dev] [--profile <name>] <command>
call call
health health
status status
wake
send
agent
stop
restart
gateway-daemon
models models
list list
status status
@@ -106,13 +99,6 @@ clawdbot [--dev] [--profile <name>] <command>
canvas snapshot canvas snapshot
screen record screen record
location get location get
canvas
snapshot
present
hide
navigate
eval
a2ui push|reset
browser browser
status status
start start
@@ -198,9 +184,6 @@ Options:
### `configure` / `config` ### `configure` / `config`
Interactive configuration wizard (models, providers, skills, gateway). Interactive configuration wizard (models, providers, skills, gateway).
### `update`
Audit and modernize the local configuration.
### `doctor` ### `doctor`
Health checks + quick fixes (config + gateway + legacy services). Health checks + quick fixes (config + gateway + legacy services).
@@ -261,13 +244,6 @@ Subcommands:
- `pairing list --provider <telegram|signal|imessage|discord|slack|whatsapp> [--json]` - `pairing list --provider <telegram|signal|imessage|discord|slack|whatsapp> [--json]`
- `pairing approve --provider <...> <code> [--notify]` - `pairing approve --provider <...> <code> [--notify]`
### `telegram pairing`
Telegram-only pairing helper.
Subcommands:
- `telegram pairing list [--json]`
- `telegram pairing approve <code> [--no-notify]`
### `hooks gmail` ### `hooks gmail`
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub). Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
@@ -415,9 +391,6 @@ Options:
- `--ws-log <auto|full|compact>` - `--ws-log <auto|full|compact>`
- `--compact` (alias for `--ws-log compact`) - `--compact` (alias for `--ws-log compact`)
### `gateway-daemon`
Run the Gateway as a long-lived daemon (same options as `gateway`, minus `--allow-unconfigured` and `--force`).
### `daemon` ### `daemon`
Manage the Gateway service (launchd/systemd/schtasks). Manage the Gateway service (launchd/systemd/schtasks).
@@ -435,7 +408,6 @@ Notes:
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). - `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans).
- `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled. - `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled.
- `daemon install` options: `--port`, `--runtime`, `--token`. - `daemon install` options: `--port`, `--runtime`, `--token`.
- `gateway install|uninstall|start|stop|restart` remain as service aliases; `daemon` is the dedicated manager.
### `gateway <subcommand>` ### `gateway <subcommand>`
Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each). Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each).
@@ -444,15 +416,6 @@ Subcommands:
- `gateway call <method> [--params <json>]` - `gateway call <method> [--params <json>]`
- `gateway health` - `gateway health`
- `gateway status` - `gateway status`
- `gateway wake --text <text> [--mode now|next-heartbeat]`
- `gateway send --to <jidOrPhone> --message <text> [--media-url <url>] [--gif-playback] [--idempotency-key <key>]`
- `gateway agent --message <text> [--to <jidOrPhone>] [--session-id <id>] [--thinking <level>] [--deliver] [--timeout-seconds <n>] [--idempotency-key <key>]`
- `gateway install`
- `gateway uninstall`
- `gateway start`
- `gateway stop`
- `gateway restart`
- `gateway daemon status` (alias for `clawdbot daemon status`)
Common RPCs: Common RPCs:
- `config.apply` (validate + write config + restart + wake) - `config.apply` (validate + write config + restart + wake)
@@ -573,27 +536,17 @@ Camera:
Canvas + screen: Canvas + screen:
- `nodes canvas snapshot --node <id|name|ip> [--format png|jpg|jpeg] [--max-width <px>] [--quality <0-1>] [--invoke-timeout <ms>]` - `nodes canvas snapshot --node <id|name|ip> [--format png|jpg|jpeg] [--max-width <px>] [--quality <0-1>] [--invoke-timeout <ms>]`
- `nodes canvas present --node <id|name|ip> [--target <urlOrPath>] [--x <px>] [--y <px>] [--width <px>] [--height <px>] [--invoke-timeout <ms>]`
- `nodes canvas hide --node <id|name|ip> [--invoke-timeout <ms>]`
- `nodes canvas navigate <url> --node <id|name|ip> [--invoke-timeout <ms>]`
- `nodes canvas eval [<js>] --node <id|name|ip> [--js <code>] [--invoke-timeout <ms>]`
- `nodes canvas a2ui push --node <id|name|ip> (--jsonl <path> | --text <text>) [--invoke-timeout <ms>]`
- `nodes canvas a2ui reset --node <id|name|ip> [--invoke-timeout <ms>]`
- `nodes screen record --node <id|name|ip> [--screen <index>] [--duration <ms|10s>] [--fps <n>] [--no-audio] [--out <path>] [--invoke-timeout <ms>]` - `nodes screen record --node <id|name|ip> [--screen <index>] [--duration <ms|10s>] [--fps <n>] [--no-audio] [--out <path>] [--invoke-timeout <ms>]`
Location: Location:
- `nodes location get --node <id|name|ip> [--max-age <ms>] [--accuracy <coarse|balanced|precise>] [--location-timeout <ms>] [--invoke-timeout <ms>]` - `nodes location get --node <id|name|ip> [--max-age <ms>] [--accuracy <coarse|balanced|precise>] [--location-timeout <ms>] [--invoke-timeout <ms>]`
## Canvas
Canvas RPC helper (top-level wrapper for `node.invoke`). See [/platforms/mac/canvas](/platforms/mac/canvas).
Common options:
- `--url`, `--token`, `--timeout`, `--json`
Subcommands:
- `canvas snapshot [--node <id|name|ip>] [--format png|jpg] [--max-width <px>] [--quality <0-1>]`
- `canvas present [--node <id|name|ip>] [--target <urlOrPath>] [--x <px>] [--y <px>] [--width <px>] [--height <px>]`
- `canvas hide [--node <id|name|ip>]`
- `canvas navigate <url> [--node <id|name|ip>]`
- `canvas eval [<js>] [--js <code>] [--node <id|name|ip>]`
- `canvas a2ui push (--jsonl <path> | --text <text>) [--node <id|name|ip>]`
- `canvas a2ui reset [--node <id|name|ip>]`
## Browser ## Browser
Browser control CLI (dedicated Chrome/Chromium). See [/tools/browser](/tools/browser). Browser control CLI (dedicated Chrome/Chromium). See [/tools/browser](/tools/browser).

View File

@@ -167,7 +167,7 @@ If set, CLAWDBOT derives defaults (only when you havent set them explicitly):
### `wizard` ### `wizard`
Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`). Metadata written by CLI wizards (`onboard`, `configure`, `doctor`).
```json5 ```json5
{ {

View File

@@ -172,15 +172,13 @@ Notes:
- `daemon status` probes the Gateway RPC by default (same URL/token defaults as `gateway status`). - `daemon status` probes the Gateway RPC by default (same URL/token defaults as `gateway status`).
- `daemon status --deep` adds system-level scans (LaunchDaemons/system units). - `daemon status --deep` adds system-level scans (LaunchDaemons/system units).
- `daemon status` now reports runtime state (PID/exit status) and port collisions when the gateway isnt reachable. - `daemon status` now reports runtime state (PID/exit status) and port collisions when the gateway isnt reachable.
- `gateway install|uninstall|start|stop|restart` remain supported as aliases; `daemon` is the dedicated manager.
- `gateway daemon status` is an alias for `clawdbot daemon status`.
- If other gateway-like services are detected, the CLI warns. We recommend **one gateway per machine**; one gateway can host multiple agents. - If other gateway-like services are detected, the CLI warns. We recommend **one gateway per machine**; one gateway can host multiple agents.
- Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations). - Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations).
Bundled mac app: Bundled mac app:
- Clawdbot.app can bundle a bun-compiled gateway binary and install a per-user LaunchAgent labeled `com.clawdbot.gateway`. - Clawdbot.app can bundle a bun-compiled gateway binary and install a per-user LaunchAgent labeled `com.clawdbot.gateway`.
- To stop it cleanly, use `clawdbot gateway stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`). - To stop it cleanly, use `clawdbot daemon stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`).
- To restart, use `clawdbot gateway restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`). - To restart, use `clawdbot daemon restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`).
## Supervision (systemd user unit) ## Supervision (systemd user unit)
Create `~/.config/systemd/user/clawdbot-gateway.service`: Create `~/.config/systemd/user/clawdbot-gateway.service`:
@@ -236,10 +234,10 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
## CLI helpers ## CLI helpers
- `clawdbot gateway health|status` — request health/status over the Gateway WS. - `clawdbot gateway health|status` — request health/status over the Gateway WS.
- `clawdbot gateway send --to <num> --message "hi" [--media-url ...]` — send via Gateway (idempotent). - `clawdbot send --to <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `clawdbot gateway agent --message "hi" [--to ...]` — run an agent turn (waits for final by default). - `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging. - `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
- `clawdbot gateway stop|restart` — stop/restart the supervised gateway service (launchd/systemd). - `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
- Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one. - Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one.
## Migration guidance ## Migration guidance

View File

@@ -202,7 +202,7 @@ kill -9 <PID>
If the gateway is supervised by launchd, killing the PID will just respawn it. If the gateway is supervised by launchd, killing the PID will just respawn it.
Stop the supervisor instead: Stop the supervisor instead:
```bash ```bash
clawdbot gateway stop clawdbot daemon stop
# Or: launchctl bootout gui/$UID/com.clawdbot.gateway # Or: launchctl bootout gui/$UID/com.clawdbot.gateway
``` ```

View File

@@ -34,12 +34,12 @@ Then:
```bash ```bash
clawdbot doctor clawdbot doctor
clawdbot gateway restart clawdbot daemon restart
clawdbot health clawdbot health
``` ```
Notes: Notes:
- If your Gateway runs as a service, `clawdbot gateway restart` is preferred over killing PIDs. - If your Gateway runs as a service, `clawdbot daemon restart` is preferred over killing PIDs.
- If youre pinned to a specific version, see “Rollback / pinning” below. - If youre pinned to a specific version, see “Rollback / pinning” below.
## Update (Control UI / RPC) ## Update (Control UI / RPC)
@@ -87,8 +87,8 @@ Details: [Doctor](/gateway/doctor)
CLI (works regardless of OS): CLI (works regardless of OS):
```bash ```bash
clawdbot gateway stop clawdbot daemon stop
clawdbot gateway restart clawdbot daemon restart
clawdbot gateway --port 18789 clawdbot gateway --port 18789
``` ```
@@ -113,7 +113,7 @@ Then restart + re-run doctor:
```bash ```bash
clawdbot doctor clawdbot doctor
clawdbot gateway restart clawdbot daemon restart
``` ```
### Pin (source) by date ### Pin (source) by date
@@ -130,7 +130,7 @@ Then reinstall deps + restart:
```bash ```bash
pnpm install pnpm install
pnpm build pnpm build
clawdbot gateway restart clawdbot daemon restart
``` ```
If you want to go back to latest later: If you want to go back to latest later:

View File

@@ -51,13 +51,6 @@ clawdbot nodes canvas snapshot --node <idOrNameOrIp> --format png
clawdbot nodes canvas snapshot --node <idOrNameOrIp> --format jpg --max-width 1200 --quality 0.9 clawdbot nodes canvas snapshot --node <idOrNameOrIp> --format jpg --max-width 1200 --quality 0.9
``` ```
Simple shortcut (auto-picks a single connected node if possible):
```bash
clawdbot canvas snapshot --format png
clawdbot canvas snapshot --format jpg --max-width 1200 --quality 0.9
```
## Photos + videos (node camera) ## Photos + videos (node camera)
Photos (`jpg`): Photos (`jpg`):

View File

@@ -167,7 +167,7 @@ More: [Linux](/platforms/linux)
```bash ```bash
npm i -g clawdbot@latest npm i -g clawdbot@latest
clawdbot doctor clawdbot doctor
clawdbot gateway restart clawdbot daemon restart
clawdbot health clawdbot health
``` ```

View File

@@ -31,7 +31,7 @@ Linux companion apps are planned, but the core Gateway is fully supported today.
Use one of these (all supported): Use one of these (all supported):
- Wizard (recommended): `clawdbot onboard --install-daemon` - Wizard (recommended): `clawdbot onboard --install-daemon`
- Direct: `clawdbot daemon install` (alias: `clawdbot gateway install`) - Direct: `clawdbot daemon install`
- Configure flow: `clawdbot configure` → select **Gateway daemon** - Configure flow: `clawdbot configure` → select **Gateway daemon**
- Repair/migrate: `clawdbot doctor` (offers to install or fix the service) - Repair/migrate: `clawdbot doctor` (offers to install or fix the service)

View File

@@ -36,7 +36,7 @@ clawdbot daemon install
Or: Or:
``` ```
clawdbot gateway install clawdbot daemon install
``` ```
Or: Or:

View File

@@ -18,7 +18,7 @@ App bundle layout:
- bun `--compile` relay executable built from [`dist/macos/relay.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/macos/relay.js) - bun `--compile` relay executable built from [`dist/macos/relay.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/macos/relay.js)
- Supports: - Supports:
- `clawdbot …` (CLI) - `clawdbot …` (CLI)
- `clawdbot gateway-daemon …` (LaunchAgent daemon) - `clawdbot gateway …` (LaunchAgent daemon)
- `Clawdbot.app/Contents/Resources/Relay/package.json` - `Clawdbot.app/Contents/Resources/Relay/package.json`
- tiny “p runtime compatibility” file (see below) - tiny “p runtime compatibility” file (see below)
- `Clawdbot.app/Contents/Resources/Relay/theme/` - `Clawdbot.app/Contents/Resources/Relay/theme/`
@@ -109,7 +109,7 @@ dist/Clawdbot.app/Contents/Resources/Relay/clawdbot --version
CLAWDBOT_SKIP_PROVIDERS=1 \ CLAWDBOT_SKIP_PROVIDERS=1 \
CLAWDBOT_SKIP_CANVAS_HOST=1 \ CLAWDBOT_SKIP_CANVAS_HOST=1 \
dist/Clawdbot.app/Contents/Resources/Relay/clawdbot gateway-daemon --port 18999 --bind loopback dist/Clawdbot.app/Contents/Resources/Relay/clawdbot gateway --port 18999 --bind loopback
``` ```
Then, in another shell: Then, in another shell:

View File

@@ -87,12 +87,12 @@ Related:
Use the main `clawdbot` CLI; it invokes canvas commands via `node.invoke`. Use the main `clawdbot` CLI; it invokes canvas commands via `node.invoke`.
- `clawdbot canvas present [--node <id>] [--target <...>] [--x/--y/--width/--height]` - `clawdbot nodes canvas present --node <id> [--target <...>] [--x/--y/--width/--height]`
- Local targets map into the session directory via the custom scheme (directory targets resolve `index.html|index.htm`). - Local targets map into the session directory via the custom scheme (directory targets resolve `index.html|index.htm`).
- If `/` has no index file, Canvas shows the built-in scaffold page and returns `status: "welcome"`. - If `/` has no index file, Canvas shows the built-in scaffold page and returns `status: "welcome"`.
- `clawdbot canvas hide [--node <id>]` - `clawdbot nodes canvas hide --node <id>`
- `clawdbot canvas eval --js <code> [--node <id>]` - `clawdbot nodes canvas eval --js <code> --node <id>`
- `clawdbot canvas snapshot [--node <id>]` - `clawdbot nodes canvas snapshot --node <id>`
### Canvas A2UI ### Canvas A2UI
@@ -104,8 +104,8 @@ http://<gateway-host>:18793/__clawdbot__/a2ui/
The macOS app simply renders that page in the Canvas panel. The agent can drive it with JSONL **server→client protocol messages** (one JSON object per line): The macOS app simply renders that page in the Canvas panel. The agent can drive it with JSONL **server→client protocol messages** (one JSON object per line):
- `clawdbot canvas a2ui push --jsonl <path> [--node <id>]` - `clawdbot nodes canvas a2ui push --jsonl <path> --node <id>`
- `clawdbot canvas a2ui reset [--node <id>]` - `clawdbot nodes canvas a2ui reset --node <id>`
`push` expects a JSONL file where **each line is a single JSON object** (parsed and forwarded to the in-page A2UI renderer). `push` expects a JSONL file where **each line is a single JSON object** (parsed and forwarded to the in-page A2UI renderer).
@@ -113,18 +113,18 @@ Minimal example (v0.8):
```bash ```bash
cat > /tmp/a2ui-v0.8.jsonl <<'EOF' cat > /tmp/a2ui-v0.8.jsonl <<'EOF'
{"surfaceUpdate":{"surfaceId":"main","components":[{"id":"root","component":{"Column":{"children":{"explicitList":["title","content"]}}}},{"id":"title","component":{"Text":{"text":{"literalString":"Canvas (A2UI v0.8)"},"usageHint":"h1"}}},{"id":"content","component":{"Text":{"text":{"literalString":"If you can read this, `canvas a2ui push` works."},"usageHint":"body"}}}]}} {"surfaceUpdate":{"surfaceId":"main","components":[{"id":"root","component":{"Column":{"children":{"explicitList":["title","content"]}}}},{"id":"title","component":{"Text":{"text":{"literalString":"Canvas (A2UI v0.8)"},"usageHint":"h1"}}},{"id":"content","component":{"Text":{"text":{"literalString":"If you can read this, `nodes canvas a2ui push` works."},"usageHint":"body"}}}]}}
{"beginRendering":{"surfaceId":"main","root":"root"}} {"beginRendering":{"surfaceId":"main","root":"root"}}
EOF EOF
clawdbot canvas a2ui push --jsonl /tmp/a2ui-v0.8.jsonl --node <id> clawdbot nodes canvas a2ui push --jsonl /tmp/a2ui-v0.8.jsonl --node <id>
``` ```
Notes: Notes:
- This does **not** support the A2UI v0.9 examples using `createSurface`. - This does **not** support the A2UI v0.9 examples using `createSurface`.
- A2UI **fails** if the Gateway canvas host is unreachable (no local fallback). - A2UI **fails** if the Gateway canvas host is unreachable (no local fallback).
- `canvas a2ui push` validates JSONL (line numbers on errors) and rejects v0.9 payloads. - `nodes canvas a2ui push` validates JSONL (line numbers on errors) and rejects v0.9 payloads.
- Quick smoke: `clawdbot canvas a2ui push --text "Hello from A2UI"` renders a minimal v0.8 view. - Quick smoke: `clawdbot nodes canvas a2ui push --node <id> --text "Hello from A2UI"` renders a minimal v0.8 view.
## Triggering agent runs from Canvas (deep links) ## Triggering agent runs from Canvas (deep links)

View File

@@ -40,7 +40,7 @@ Details: [Gateway runbook](/gateway) and [Bundled bun Gateway](/platforms/mac/bu
- `Clawdbot` (LSUIElement MenuBarExtra app; hosts Gateway + node bridge + PeekabooBridgeHost). - `Clawdbot` (LSUIElement MenuBarExtra app; hosts Gateway + node bridge + PeekabooBridgeHost).
- Bundle ID: `com.clawdbot.mac`. - Bundle ID: `com.clawdbot.mac`.
- Bundled runtime binaries live under `Contents/Resources/Relay/`: - Bundled runtime binaries live under `Contents/Resources/Relay/`:
- `clawdbot` (buncompiled relay: CLI + gateway-daemon) - `clawdbot` (buncompiled relay: CLI + gateway)
- The app symlinks `clawdbot` into `/usr/local/bin` and `/opt/homebrew/bin`. - The app symlinks `clawdbot` into `/usr/local/bin` and `/opt/homebrew/bin`.
## Gateway + node bridge ## Gateway + node bridge
@@ -65,7 +65,7 @@ Details: [Gateway runbook](/gateway) and [Bundled bun Gateway](/platforms/mac/bu
## CLI (`clawdbot`) ## CLI (`clawdbot`)
- The **only** CLI is `clawdbot` (TS/bun). There is no `clawdbot-mac` helper. - The **only** CLI is `clawdbot` (TS/bun). There is no `clawdbot-mac` helper.
- For macspecific actions, the CLI uses `node.invoke`: - For macspecific actions, the CLI uses `node.invoke`:
- `clawdbot canvas present|navigate|eval|snapshot|a2ui push|a2ui reset` - `clawdbot nodes canvas present|navigate|eval|snapshot|a2ui push|a2ui reset`
- `clawdbot nodes run --node <id> -- <command...>` - `clawdbot nodes run --node <id> -- <command...>`
- `clawdbot nodes notify --node <id> --title ...` - `clawdbot nodes notify --node <id> --title ...`

View File

@@ -37,7 +37,7 @@ clawdbot daemon install
Or: Or:
``` ```
clawdbot gateway install clawdbot daemon install
``` ```
Or: Or:

View File

@@ -100,7 +100,7 @@ Notes:
- Uses gateway `node.invoke` under the hood. - Uses gateway `node.invoke` under the hood.
- If no `node` is provided, the tool picks a default (single connected node or local mac node). - If no `node` is provided, the tool picks a default (single connected node or local mac node).
- A2UI is v0.8 only (no `createSurface`); the CLI rejects v0.9 JSONL with line errors. - A2UI is v0.8 only (no `createSurface`); the CLI rejects v0.9 JSONL with line errors.
- Quick smoke: `clawdbot canvas a2ui push --text "Hello from A2UI"`. - Quick smoke: `clawdbot nodes canvas a2ui push --node <id> --text "Hello from A2UI"`.
### `nodes` ### `nodes`
Discover and target paired nodes; send notifications; capture camera/screen. Discover and target paired nodes; send notifications; capture camera/screen.
@@ -162,7 +162,7 @@ Notes:
Restart or apply updates to the running Gateway process (in-place). Restart or apply updates to the running Gateway process (in-place).
Core actions: Core actions:
- `restart` (sends `SIGUSR1` to the current process; `clawdbot gateway`/`gateway-daemon` restart in-place) - `restart` (sends `SIGUSR1` to the current process; `clawdbot gateway` restart in-place)
- `config.get` / `config.schema` - `config.get` / `config.schema`
- `config.apply` (validate + write config + restart + wake) - `config.apply` (validate + write config + restart + wake)
- `update.run` (run update + restart + wake) - `update.run` (run update + restart + wake)

View File

@@ -42,7 +42,7 @@ TRASH
} }
start_gateway() { start_gateway() {
node dist/index.js gateway-daemon --port 18789 --bind loopback > /tmp/gateway-e2e.log 2>&1 & node dist/index.js gateway --port 18789 --bind loopback > /tmp/gateway-e2e.log 2>&1 &
GATEWAY_PID="$!" GATEWAY_PID="$!"
} }
@@ -268,7 +268,7 @@ if (errors.length > 0) {
} }
NODE NODE
node dist/index.js gateway-daemon --port 18789 --bind loopback > /tmp/gateway.log 2>&1 & node dist/index.js gateway --port 18789 --bind loopback > /tmp/gateway.log 2>&1 &
GW_PID=$! GW_PID=$!
# Gate on gateway readiness, then run health. # Gate on gateway readiness, then run health.
for _ in $(seq 1 10); do for _ in $(seq 1 10); do

View File

@@ -1,166 +0,0 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const callGateway = vi.fn(
async (opts: { method?: string; params?: { command?: string } }) => {
if (opts.method === "node.list") {
return {
nodes: [
{
nodeId: "mac-1",
displayName: "Mac",
platform: "macos",
caps: ["canvas"],
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
if (opts.params?.command === "canvas.eval") {
return { payload: { result: "ok" } };
}
return { ok: true };
}
return { ok: true };
},
);
const randomIdempotencyKey = vi.fn(() => "rk_test");
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const defaultRuntime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGateway(opts as { method?: string }),
randomIdempotencyKey: () => randomIdempotencyKey(),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
describe("canvas-cli coverage", () => {
it("invokes canvas.present with placement and target", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
randomIdempotencyKey.mockClear();
const { registerCanvasCli } = await import("./canvas-cli.js");
const program = new Command();
program.exitOverride();
registerCanvasCli(program);
await program.parseAsync(
[
"canvas",
"present",
"--node",
"mac-1",
"--target",
"https://example.com",
"--x",
"10",
"--y",
"20",
"--width",
"800",
"--height",
"600",
],
{ from: "user" },
);
const invoke = callGateway.mock.calls.find(
(call) => call[0]?.method === "node.invoke",
)?.[0];
expect(invoke).toBeTruthy();
expect(invoke?.params?.command).toBe("canvas.present");
expect(invoke?.params?.idempotencyKey).toBe("rk_test");
expect(invoke?.params?.params).toEqual({
url: "https://example.com",
placement: { x: 10, y: 20, width: 800, height: 600 },
});
});
it("prints canvas.eval result", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
const { registerCanvasCli } = await import("./canvas-cli.js");
const program = new Command();
program.exitOverride();
registerCanvasCli(program);
await program.parseAsync(["canvas", "eval", "1+1"], { from: "user" });
expect(runtimeErrors).toHaveLength(0);
expect(runtimeLogs.join("\n")).toContain("ok");
});
it("pushes A2UI text payload", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
const { registerCanvasCli } = await import("./canvas-cli.js");
const program = new Command();
program.exitOverride();
registerCanvasCli(program);
await program.parseAsync(
["canvas", "a2ui", "push", "--node", "mac-1", "--text", "Hello A2UI"],
{ from: "user" },
);
const invoke = callGateway.mock.calls.find(
(call) => call[0]?.method === "node.invoke",
)?.[0];
expect(invoke?.params?.command).toBe("canvas.a2ui.pushJSONL");
expect(invoke?.params?.params?.jsonl).toContain("Hello A2UI");
});
it("rejects invalid A2UI JSONL", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
vi.resetModules();
vi.doMock("node:fs/promises", () => ({
default: { readFile: vi.fn(async () => "{broken") },
}));
const { registerCanvasCli } = await import("./canvas-cli.js");
const program = new Command();
program.exitOverride();
registerCanvasCli(program);
await expect(
program.parseAsync(
[
"canvas",
"a2ui",
"push",
"--node",
"mac-1",
"--jsonl",
"/tmp/a2ui.jsonl",
],
{ from: "user" },
),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.join("\n")).toContain("Invalid A2UI JSONL");
});
});

View File

@@ -1,544 +0,0 @@
import fs from "node:fs/promises";
import type { Command } from "commander";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { defaultRuntime } from "../runtime.js";
import { writeBase64ToFile } from "./nodes-camera.js";
import {
canvasSnapshotTempPath,
parseCanvasSnapshotPayload,
} from "./nodes-canvas.js";
import { withProgress } from "./progress.js";
type CanvasOpts = {
url?: string;
token?: string;
timeout?: string;
json?: boolean;
node?: string;
target?: string;
x?: string;
y?: string;
width?: string;
height?: string;
js?: string;
jsonl?: string;
text?: string;
format?: string;
maxWidth?: string;
quality?: string;
};
type NodeListNode = {
nodeId: string;
displayName?: string;
platform?: string;
remoteIp?: string;
caps?: string[];
connected?: boolean;
};
type PendingRequest = {
requestId: string;
nodeId: string;
displayName?: string;
remoteIp?: string;
};
type PairedNode = {
nodeId: string;
displayName?: string;
remoteIp?: string;
};
type PairingList = {
pending: PendingRequest[];
paired: PairedNode[];
};
const A2UI_ACTION_KEYS = [
"beginRendering",
"surfaceUpdate",
"dataModelUpdate",
"deleteSurface",
"createSurface",
] as const;
type A2UIVersion = "v0.8" | "v0.9";
const canvasCallOpts = (cmd: Command) =>
cmd
.option(
"--url <url>",
"Gateway WebSocket URL (defaults to gateway.remote.url when configured)",
)
.option("--token <token>", "Gateway token (if required)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--json", "Output JSON", false);
const callGatewayCli = async (
method: string,
opts: CanvasOpts,
params?: unknown,
) =>
withProgress(
{
label: `Canvas ${method}`,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
url: opts.url,
token: opts.token,
method,
params,
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: "cli",
mode: "cli",
}),
);
function parseNodeList(value: unknown): NodeListNode[] {
const obj =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : [];
}
function parsePairingList(value: unknown): PairingList {
const obj =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
const pending = Array.isArray(obj.pending)
? (obj.pending as PendingRequest[])
: [];
const paired = Array.isArray(obj.paired) ? (obj.paired as PairedNode[]) : [];
return { pending, paired };
}
function normalizeNodeKey(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
}
function buildA2UITextJsonl(text: string) {
const surfaceId = "main";
const rootId = "root";
const textId = "text";
const payloads = [
{
surfaceUpdate: {
surfaceId,
components: [
{
id: rootId,
component: { Column: { children: { explicitList: [textId] } } },
},
{
id: textId,
component: {
Text: { text: { literalString: text }, usageHint: "body" },
},
},
],
},
},
{ beginRendering: { surfaceId, root: rootId } },
];
return payloads.map((payload) => JSON.stringify(payload)).join("\n");
}
function validateA2UIJsonl(jsonl: string) {
const lines = jsonl.split(/\r?\n/);
const errors: string[] = [];
let sawV08 = false;
let sawV09 = false;
let messageCount = 0;
lines.forEach((line, idx) => {
const trimmed = line.trim();
if (!trimmed) return;
messageCount += 1;
let obj: unknown;
try {
obj = JSON.parse(trimmed) as unknown;
} catch (err) {
errors.push(`line ${idx + 1}: ${String(err)}`);
return;
}
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
errors.push(`line ${idx + 1}: expected JSON object`);
return;
}
const record = obj as Record<string, unknown>;
const actionKeys = A2UI_ACTION_KEYS.filter((key) => key in record);
if (actionKeys.length !== 1) {
errors.push(
`line ${idx + 1}: expected exactly one action key (${A2UI_ACTION_KEYS.join(
", ",
)})`,
);
return;
}
if (actionKeys[0] === "createSurface") {
sawV09 = true;
} else {
sawV08 = true;
}
});
if (messageCount === 0) {
errors.push("no JSONL messages found");
}
if (sawV08 && sawV09) {
errors.push("mixed A2UI v0.8 and v0.9 messages in one file");
}
if (errors.length > 0) {
throw new Error(`Invalid A2UI JSONL:\n- ${errors.join("\n- ")}`);
}
const version: A2UIVersion = sawV09 ? "v0.9" : "v0.8";
return { version, messageCount };
}
async function loadNodes(opts: CanvasOpts): Promise<NodeListNode[]> {
try {
const res = (await callGatewayCli("node.list", opts, {})) as unknown;
return parseNodeList(res);
} catch {
const res = (await callGatewayCli("node.pair.list", opts, {})) as unknown;
const { paired } = parsePairingList(res);
return paired.map((n) => ({
nodeId: n.nodeId,
displayName: n.displayName,
remoteIp: n.remoteIp,
}));
}
}
function pickDefaultNode(nodes: NodeListNode[]): NodeListNode | null {
const withCanvas = nodes.filter((n) =>
Array.isArray(n.caps) ? n.caps.includes("canvas") : true,
);
if (withCanvas.length === 0) return null;
const connected = withCanvas.filter((n) => n.connected);
const candidates = connected.length > 0 ? connected : withCanvas;
if (candidates.length === 1) return candidates[0];
const local = candidates.filter(
(n) =>
n.platform?.toLowerCase().startsWith("mac") &&
typeof n.nodeId === "string" &&
n.nodeId.startsWith("mac-"),
);
if (local.length === 1) return local[0];
return null;
}
async function resolveNodeId(opts: CanvasOpts, query?: string) {
const nodes = await loadNodes(opts);
const q = String(query ?? "").trim();
if (!q) {
const picked = pickDefaultNode(nodes);
if (picked) return picked.nodeId;
throw new Error(
"node required (use --node or ensure only one connected node is available)",
);
}
const qNorm = normalizeNodeKey(q);
const matches = nodes.filter((n) => {
if (n.nodeId === q) return true;
if (typeof n.remoteIp === "string" && n.remoteIp === q) return true;
const name = typeof n.displayName === "string" ? n.displayName : "";
if (name && normalizeNodeKey(name) === qNorm) return true;
if (q.length >= 6 && n.nodeId.startsWith(q)) return true;
return false;
});
if (matches.length === 1) return matches[0].nodeId;
if (matches.length === 0) {
const known = nodes
.map((n) => n.displayName || n.remoteIp || n.nodeId)
.filter(Boolean)
.join(", ");
throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`);
}
throw new Error(
`ambiguous node: ${q} (matches: ${matches
.map((n) => n.displayName || n.remoteIp || n.nodeId)
.join(", ")})`,
);
}
function normalizeFormat(format: string) {
const trimmed = format.trim().toLowerCase();
if (trimmed === "jpg") return "jpeg";
return trimmed;
}
export function registerCanvasCli(program: Command) {
const canvas = program
.command("canvas")
.description("Control node canvases (present/navigate/eval/snapshot/a2ui)");
const invokeCanvas = async (
opts: CanvasOpts,
command: string,
params?: Record<string, unknown>,
) => {
const nodeId = await resolveNodeId(opts, opts.node);
await callGatewayCli("node.invoke", opts, {
nodeId,
command,
params,
idempotencyKey: randomIdempotencyKey(),
});
};
canvasCallOpts(
canvas
.command("snapshot")
.description("Capture a canvas snapshot (prints MEDIA:<path>)")
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--format <png|jpg>", "Output format", "png")
.option("--max-width <px>", "Max width (px)")
.option("--quality <0-1>", "JPEG quality (default 0.82)")
.action(async (opts: CanvasOpts) => {
try {
const nodeId = await resolveNodeId(opts, opts.node);
const format = normalizeFormat(String(opts.format ?? "png"));
if (format !== "png" && format !== "jpeg") {
throw new Error("invalid format (use png or jpg)");
}
const maxWidth = opts.maxWidth
? Number.parseInt(String(opts.maxWidth), 10)
: undefined;
const quality = opts.quality
? Number.parseFloat(String(opts.quality))
: undefined;
const raw = (await callGatewayCli("node.invoke", opts, {
nodeId,
command: "canvas.snapshot",
params: {
format,
maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined,
quality: Number.isFinite(quality) ? quality : undefined,
},
idempotencyKey: randomIdempotencyKey(),
})) as unknown;
const res =
typeof raw === "object" && raw !== null
? (raw as { payload?: unknown })
: {};
const payload = parseCanvasSnapshotPayload(res.payload);
const filePath = canvasSnapshotTempPath({
ext: payload.format === "jpeg" ? "jpg" : payload.format,
});
await writeBase64ToFile(filePath, payload.base64);
if (opts.json) {
defaultRuntime.log(
JSON.stringify(
{
file: {
path: filePath,
},
},
null,
2,
),
);
return;
}
defaultRuntime.log(`MEDIA:${filePath}`);
} catch (err) {
defaultRuntime.error(`canvas snapshot failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
canvasCallOpts(
canvas
.command("present")
.description("Show the canvas (optionally with a target URL/path)")
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--target <urlOrPath>", "Target URL/path (optional)")
.option("--x <px>", "Placement x coordinate")
.option("--y <px>", "Placement y coordinate")
.option("--width <px>", "Placement width")
.option("--height <px>", "Placement height")
.action(async (opts: CanvasOpts) => {
try {
const placement = {
x: opts.x ? Number.parseFloat(opts.x) : undefined,
y: opts.y ? Number.parseFloat(opts.y) : undefined,
width: opts.width ? Number.parseFloat(opts.width) : undefined,
height: opts.height ? Number.parseFloat(opts.height) : undefined,
};
const params: Record<string, unknown> = {};
if (opts.target) params.url = String(opts.target);
if (
Number.isFinite(placement.x) ||
Number.isFinite(placement.y) ||
Number.isFinite(placement.width) ||
Number.isFinite(placement.height)
) {
params.placement = placement;
}
await invokeCanvas(opts, "canvas.present", params);
if (!opts.json) {
defaultRuntime.log("canvas present ok");
}
} catch (err) {
defaultRuntime.error(`canvas present failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
canvasCallOpts(
canvas
.command("hide")
.description("Hide the canvas")
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
.action(async (opts: CanvasOpts) => {
try {
await invokeCanvas(opts, "canvas.hide", undefined);
if (!opts.json) {
defaultRuntime.log("canvas hide ok");
}
} catch (err) {
defaultRuntime.error(`canvas hide failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
canvasCallOpts(
canvas
.command("navigate")
.description("Navigate the canvas to a URL")
.argument("<url>", "Target URL/path")
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
.action(async (url: string, opts: CanvasOpts) => {
try {
await invokeCanvas(opts, "canvas.navigate", { url });
if (!opts.json) {
defaultRuntime.log("canvas navigate ok");
}
} catch (err) {
defaultRuntime.error(`canvas navigate failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
canvasCallOpts(
canvas
.command("eval")
.description("Evaluate JavaScript in the canvas")
.argument("[js]", "JavaScript to evaluate")
.option("--js <code>", "JavaScript to evaluate")
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
.action(async (jsArg: string | undefined, opts: CanvasOpts) => {
try {
const js = opts.js ?? jsArg;
if (!js) throw new Error("missing --js or <js>");
const nodeId = await resolveNodeId(opts, opts.node);
const raw = (await callGatewayCli("node.invoke", opts, {
nodeId,
command: "canvas.eval",
params: { javaScript: js },
idempotencyKey: randomIdempotencyKey(),
})) as unknown;
if (opts.json) {
defaultRuntime.log(JSON.stringify(raw, null, 2));
return;
}
const payload =
typeof raw === "object" && raw !== null
? (raw as { payload?: { result?: string } }).payload
: undefined;
if (payload?.result) {
defaultRuntime.log(payload.result);
} else {
defaultRuntime.log("canvas eval ok");
}
} catch (err) {
defaultRuntime.error(`canvas eval failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
const a2ui = canvas
.command("a2ui")
.description("Render A2UI content on the canvas");
canvasCallOpts(
a2ui
.command("push")
.description("Push A2UI JSONL to the canvas")
.option("--jsonl <path>", "Path to JSONL payload")
.option("--text <text>", "Render a quick A2UI text payload")
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
.action(async (opts: CanvasOpts) => {
try {
const hasJsonl = Boolean(opts.jsonl);
const hasText = typeof opts.text === "string";
if (hasJsonl === hasText) {
throw new Error("provide exactly one of --jsonl or --text");
}
const jsonl = hasText
? buildA2UITextJsonl(String(opts.text ?? ""))
: await fs.readFile(String(opts.jsonl), "utf8");
const { version, messageCount } = validateA2UIJsonl(jsonl);
if (version === "v0.9") {
throw new Error(
"Detected A2UI v0.9 JSONL (createSurface). Clawdbot currently supports v0.8 only.",
);
}
await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl });
if (!opts.json) {
defaultRuntime.log(
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
);
}
} catch (err) {
defaultRuntime.error(`canvas a2ui push failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
canvasCallOpts(
a2ui
.command("reset")
.description("Reset A2UI renderer state")
.option("--node <idOrNameOrIp>", "Node id, name, or IP")
.action(async (opts: CanvasOpts) => {
try {
await invokeCanvas(opts, "canvas.a2ui.reset", undefined);
if (!opts.json) {
defaultRuntime.log("canvas a2ui reset ok");
}
} catch (err) {
defaultRuntime.error(`canvas a2ui reset failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
}

View File

@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
const callGateway = vi.fn(async () => ({ ok: true })); const callGateway = vi.fn(async () => ({ ok: true }));
const resolveGatewayProgramArguments = vi.fn(async () => ({ const resolveGatewayProgramArguments = vi.fn(async () => ({
programArguments: ["/bin/node", "cli", "gateway-daemon", "--port", "18789"], programArguments: ["/bin/node", "cli", "gateway", "--port", "18789"],
})); }));
const serviceInstall = vi.fn().mockResolvedValue(undefined); const serviceInstall = vi.fn().mockResolvedValue(undefined);
const serviceUninstall = vi.fn().mockResolvedValue(undefined); const serviceUninstall = vi.fn().mockResolvedValue(undefined);

View File

@@ -2,21 +2,15 @@ import { Command } from "commander";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
const callGateway = vi.fn(async () => ({ ok: true })); const callGateway = vi.fn(async () => ({ ok: true }));
const randomIdempotencyKey = vi.fn(() => "rk_test");
const startGatewayServer = vi.fn(async () => ({ const startGatewayServer = vi.fn(async () => ({
close: vi.fn(async () => {}), close: vi.fn(async () => {}),
})); }));
const setVerbose = vi.fn(); const setVerbose = vi.fn();
const createDefaultDeps = vi.fn();
const forceFreePortAndWait = vi.fn(async () => ({ const forceFreePortAndWait = vi.fn(async () => ({
killed: [], killed: [],
waitedMs: 0, waitedMs: 0,
escalatedToSigkill: false, escalatedToSigkill: false,
})); }));
const serviceInstall = vi.fn().mockResolvedValue(undefined);
const serviceStop = vi.fn().mockResolvedValue(undefined);
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue(undefined);
const serviceIsLoaded = vi.fn().mockResolvedValue(true); const serviceIsLoaded = vi.fn().mockResolvedValue(true);
const runtimeLogs: string[] = []; const runtimeLogs: string[] = [];
@@ -53,7 +47,7 @@ async function withEnvOverride<T>(
vi.mock("../gateway/call.js", () => ({ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGateway(opts), callGateway: (opts: unknown) => callGateway(opts),
randomIdempotencyKey: () => randomIdempotencyKey(), randomIdempotencyKey: () => "rk_test",
})); }));
vi.mock("../gateway/server.js", () => ({ vi.mock("../gateway/server.js", () => ({
@@ -71,10 +65,6 @@ vi.mock("../runtime.js", () => ({
defaultRuntime, defaultRuntime,
})); }));
vi.mock("./deps.js", () => ({
createDefaultDeps: () => createDefaultDeps(),
}));
vi.mock("./ports.js", () => ({ vi.mock("./ports.js", () => ({
forceFreePortAndWait: (port: number) => forceFreePortAndWait(port), forceFreePortAndWait: (port: number) => forceFreePortAndWait(port),
})); }));
@@ -84,10 +74,10 @@ vi.mock("../daemon/service.js", () => ({
label: "LaunchAgent", label: "LaunchAgent",
loadedText: "loaded", loadedText: "loaded",
notLoadedText: "not loaded", notLoadedText: "not loaded",
install: serviceInstall, install: vi.fn(),
uninstall: serviceUninstall, uninstall: vi.fn(),
stop: serviceStop, stop: vi.fn(),
restart: serviceRestart, restart: vi.fn(),
isLoaded: serviceIsLoaded, isLoaded: serviceIsLoaded,
readCommand: vi.fn(), readCommand: vi.fn(),
readRuntime: vi.fn().mockResolvedValue({ status: "running" }), readRuntime: vi.fn().mockResolvedValue({ status: "running" }),
@@ -96,12 +86,12 @@ vi.mock("../daemon/service.js", () => ({
vi.mock("../daemon/program-args.js", () => ({ vi.mock("../daemon/program-args.js", () => ({
resolveGatewayProgramArguments: async () => ({ resolveGatewayProgramArguments: async () => ({
programArguments: ["/bin/node", "cli", "gateway-daemon", "--port", "18789"], programArguments: ["/bin/node", "cli", "gateway", "--port", "18789"],
}), }),
})); }));
describe("gateway-cli coverage", () => { describe("gateway-cli coverage", () => {
it("registers call/health/status/send/agent commands and routes to callGateway", async () => { it("registers call/health/status commands and routes to callGateway", async () => {
runtimeLogs.length = 0; runtimeLogs.length = 0;
runtimeErrors.length = 0; runtimeErrors.length = 0;
callGateway.mockClear(); callGateway.mockClear();
@@ -141,66 +131,6 @@ describe("gateway-cli coverage", () => {
expect(runtimeErrors.join("\n")).toContain("Gateway call failed:"); expect(runtimeErrors.join("\n")).toContain("Gateway call failed:");
}); });
it("fills idempotency keys for send/agent when missing", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
randomIdempotencyKey.mockClear();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(
["gateway", "send", "--to", "+1555", "--message", "hi"],
{ from: "user" },
);
await program.parseAsync(
["gateway", "agent", "--message", "hello", "--deliver"],
{ from: "user" },
);
expect(randomIdempotencyKey).toHaveBeenCalled();
const callArgs = callGateway.mock.calls.map((c) => c[0]) as Array<{
method: string;
params?: { idempotencyKey?: string };
expectFinal?: boolean;
}>;
expect(callArgs.some((c) => c.method === "send")).toBe(true);
expect(
callArgs.some((c) => c.method === "agent" && c.expectFinal === true),
).toBe(true);
expect(callArgs.every((c) => c.params?.idempotencyKey === "rk_test")).toBe(
true,
);
});
it("passes gifPlayback for gateway send when flag set", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
randomIdempotencyKey.mockClear();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(
["gateway", "send", "--to", "+1555", "--message", "hi", "--gif-playback"],
{ from: "user" },
);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "send",
params: expect.objectContaining({ gifPlayback: true }),
}),
);
});
it("validates gateway ports and handles force/start errors", async () => { it("validates gateway ports and handles force/start errors", async () => {
runtimeLogs.length = 0; runtimeLogs.length = 0;
runtimeErrors.length = 0; runtimeErrors.length = 0;
@@ -254,49 +184,6 @@ describe("gateway-cli coverage", () => {
} }
}); });
it("supports gateway stop/restart via service helper", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
serviceStop.mockClear();
serviceRestart.mockClear();
serviceIsLoaded.mockResolvedValue(true);
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "stop"], { from: "user" });
await program.parseAsync(["gateway", "restart"], { from: "user" });
expect(serviceStop).toHaveBeenCalledTimes(1);
expect(serviceRestart).toHaveBeenCalledTimes(1);
});
it("supports gateway install/uninstall/start via daemon helpers", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
serviceInstall.mockClear();
serviceUninstall.mockClear();
serviceRestart.mockClear();
serviceIsLoaded.mockResolvedValueOnce(false);
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "install", "--port", "18789"], {
from: "user",
});
await program.parseAsync(["gateway", "uninstall"], { from: "user" });
await program.parseAsync(["gateway", "start"], { from: "user" });
expect(serviceInstall).toHaveBeenCalledTimes(1);
expect(serviceUninstall).toHaveBeenCalledTimes(1);
expect(serviceRestart).toHaveBeenCalledTimes(1);
});
it("prints stop hints on GatewayLockError when service is loaded", async () => { it("prints stop hints on GatewayLockError when service is loaded", async () => {
runtimeLogs.length = 0; runtimeLogs.length = 0;
runtimeErrors.length = 0; runtimeErrors.length = 0;
@@ -320,7 +207,7 @@ describe("gateway-cli coverage", () => {
expect(startGatewayServer).toHaveBeenCalled(); expect(startGatewayServer).toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("Gateway failed to start:"); expect(runtimeErrors.join("\n")).toContain("Gateway failed to start:");
expect(runtimeErrors.join("\n")).toContain("clawdbot gateway stop"); expect(runtimeErrors.join("\n")).toContain("clawdbot daemon stop");
}); });
it("uses env/config port when --port is omitted", async () => { it("uses env/config port when --port is omitted", async () => {

View File

@@ -12,7 +12,7 @@ import {
GATEWAY_WINDOWS_TASK_NAME, GATEWAY_WINDOWS_TASK_NAME,
} from "../daemon/constants.js"; } from "../daemon/constants.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { callGateway } from "../gateway/call.js";
import { startGatewayServer } from "../gateway/server.js"; import { startGatewayServer } from "../gateway/server.js";
import { import {
type GatewayWsLogStyle, type GatewayWsLogStyle,
@@ -23,15 +23,6 @@ import { GatewayLockError } from "../infra/gateway-lock.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging.js"; import { createSubsystemLogger } from "../logging.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import {
runDaemonInstall,
runDaemonRestart,
runDaemonStart,
runDaemonStatus,
runDaemonStop,
runDaemonUninstall,
} from "./daemon-cli.js";
import { createDefaultDeps } from "./deps.js";
import { forceFreePortAndWait } from "./ports.js"; import { forceFreePortAndWait } from "./ports.js";
import { withProgress } from "./progress.js"; import { withProgress } from "./progress.js";
@@ -83,21 +74,21 @@ function renderGatewayServiceStopHints(): string[] {
switch (process.platform) { switch (process.platform) {
case "darwin": case "darwin":
return [ return [
"Tip: clawdbot gateway stop", "Tip: clawdbot daemon stop",
`Or: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`, `Or: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
]; ];
case "linux": case "linux":
return [ return [
"Tip: clawdbot gateway stop", "Tip: clawdbot daemon stop",
`Or: systemctl --user stop ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`, `Or: systemctl --user stop ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
]; ];
case "win32": case "win32":
return [ return [
"Tip: clawdbot gateway stop", "Tip: clawdbot daemon stop",
`Or: schtasks /End /TN "${GATEWAY_WINDOWS_TASK_NAME}"`, `Or: schtasks /End /TN "${GATEWAY_WINDOWS_TASK_NAME}"`,
]; ];
default: default:
return ["Tip: clawdbot gateway stop"]; return ["Tip: clawdbot daemon stop"];
} }
} }
@@ -233,170 +224,6 @@ const callGatewayCli = async (
); );
export function registerGatewayCli(program: Command) { export function registerGatewayCli(program: Command) {
program
.command("gateway-daemon")
.description("Run the WebSocket Gateway as a long-lived daemon")
.option("--port <port>", "Port for the gateway WebSocket")
.option(
"--bind <mode>",
'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).',
)
.option(
"--token <token>",
"Shared token required in connect.params.auth.token (default: CLAWDBOT_GATEWAY_TOKEN env if set)",
)
.option("--auth <mode>", 'Gateway auth mode ("token"|"password")')
.option("--password <password>", "Password for auth mode=password")
.option(
"--tailscale <mode>",
'Tailscale exposure mode ("off"|"serve"|"funnel")',
)
.option(
"--tailscale-reset-on-exit",
"Reset Tailscale serve/funnel configuration on shutdown",
false,
)
.option("--verbose", "Verbose logging to stdout/stderr", false)
.option(
"--ws-log <style>",
'WebSocket log style ("auto"|"full"|"compact")',
"auto",
)
.option("--compact", 'Alias for "--ws-log compact"', false)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const wsLogRaw = (opts.compact ? "compact" : opts.wsLog) as
| string
| undefined;
const wsLogStyle: GatewayWsLogStyle =
wsLogRaw === "compact"
? "compact"
: wsLogRaw === "full"
? "full"
: "auto";
if (
wsLogRaw !== undefined &&
wsLogRaw !== "auto" &&
wsLogRaw !== "compact" &&
wsLogRaw !== "full"
) {
defaultRuntime.error(
'Invalid --ws-log (use "auto", "full", "compact")',
);
defaultRuntime.exit(1);
}
setGatewayWsLogStyle(wsLogStyle);
const cfg = loadConfig();
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
return;
}
const port = portOverride ?? resolveGatewayPort(cfg);
if (!Number.isFinite(port) || port <= 0) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
return;
}
if (opts.token) {
process.env.CLAWDBOT_GATEWAY_TOKEN = String(opts.token);
}
const authModeRaw = opts.auth ? String(opts.auth) : undefined;
const authMode =
authModeRaw === "token" || authModeRaw === "password"
? authModeRaw
: null;
if (authModeRaw && !authMode) {
defaultRuntime.error('Invalid --auth (use "token" or "password")');
defaultRuntime.exit(1);
return;
}
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined;
const tailscaleMode =
tailscaleRaw === "off" ||
tailscaleRaw === "serve" ||
tailscaleRaw === "funnel"
? tailscaleRaw
: null;
if (tailscaleRaw && !tailscaleMode) {
defaultRuntime.error(
'Invalid --tailscale (use "off", "serve", or "funnel")',
);
defaultRuntime.exit(1);
return;
}
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
const bind =
bindRaw === "loopback" ||
bindRaw === "tailnet" ||
bindRaw === "lan" ||
bindRaw === "auto"
? bindRaw
: null;
if (!bind) {
defaultRuntime.error(
'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")',
);
defaultRuntime.exit(1);
return;
}
try {
await runGatewayLoop({
runtime: defaultRuntime,
start: async () =>
await startGatewayServer(port, {
bind,
auth:
authMode || opts.password || authModeRaw
? {
mode: authMode ?? undefined,
password: opts.password
? String(opts.password)
: undefined,
}
: undefined,
tailscale:
tailscaleMode || opts.tailscaleResetOnExit
? {
mode: tailscaleMode ?? undefined,
resetOnExit: Boolean(opts.tailscaleResetOnExit),
}
: undefined,
}),
});
} catch (err) {
if (
err instanceof GatewayLockError ||
(err &&
typeof err === "object" &&
(err as { name?: string }).name === "GatewayLockError")
) {
const errMessage = describeUnknownError(err);
defaultRuntime.error(
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`,
);
try {
const diagnostics = await inspectPortUsage(port);
if (diagnostics.status === "busy") {
for (const line of formatPortDiagnostics(diagnostics)) {
defaultRuntime.error(line);
}
}
} catch {
// ignore diagnostics failures
}
await maybeExplainGatewayServiceStop();
defaultRuntime.exit(1);
return;
}
defaultRuntime.error(`Gateway failed to start: ${String(err)}`);
defaultRuntime.exit(1);
}
});
const gateway = program const gateway = program
.command("gateway") .command("gateway")
.description("Run the WebSocket Gateway") .description("Run the WebSocket Gateway")
@@ -596,7 +423,7 @@ export function registerGatewayCli(program: Command) {
) { ) {
const errMessage = describeUnknownError(err); const errMessage = describeUnknownError(err);
defaultRuntime.error( defaultRuntime.error(
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`, `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot daemon stop`,
); );
try { try {
const diagnostics = await inspectPortUsage(port); const diagnostics = await inspectPortUsage(port);
@@ -617,69 +444,13 @@ export function registerGatewayCli(program: Command) {
} }
}); });
gateway
.command("install")
.description(
"Install the Gateway service (alias for `clawdbot daemon install`)",
)
.option("--port <port>", "Gateway port")
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--token <token>", "Gateway token (token auth)")
.action(async (opts) => {
await runDaemonInstall(opts);
});
gateway
.command("uninstall")
.description(
"Uninstall the Gateway service (alias for `clawdbot daemon uninstall`)",
)
.action(async () => {
await runDaemonUninstall();
});
gateway
.command("start")
.description(
"Start the Gateway service (alias for `clawdbot daemon start`)",
)
.action(async () => {
await runDaemonStart();
});
const gatewayDaemon = gateway
.command("daemon")
.description("Daemon helpers (alias for `clawdbot daemon`)");
gatewayDaemon
.command("status")
.description("Show daemon install status + probe the Gateway")
.option(
"--url <url>",
"Gateway WebSocket URL (defaults to config/remote/local)",
)
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--no-probe", "Skip RPC probe")
.option("--deep", "Scan system-level services", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStatus({
rpc: opts,
probe: Boolean(opts.probe),
deep: Boolean(opts.deep),
json: Boolean(opts.json),
});
});
gatewayCallOpts( gatewayCallOpts(
gateway gateway
.command("call") .command("call")
.description("Call a Gateway method and print JSON") .description("Call a Gateway method and print JSON")
.argument( .argument(
"<method>", "<method>",
"Method name (health/status/system-presence/send/agent/cron.*)", "Method name (health/status/system-presence/cron.*)",
) )
.option("--params <json>", "JSON object string for params", "{}") .option("--params <json>", "JSON object string for params", "{}")
.action(async (method, opts) => { .action(async (method, opts) => {
@@ -724,108 +495,4 @@ export function registerGatewayCli(program: Command) {
}), }),
); );
gatewayCallOpts(
gateway
.command("wake")
.description("Enqueue a system event and optionally trigger a heartbeat")
.requiredOption("--text <text>", "System event text")
.option(
"--mode <mode>",
"Wake mode (now|next-heartbeat)",
"next-heartbeat",
)
.action(async (opts) => {
try {
const result = await callGatewayCli("wake", opts, {
mode: opts.mode,
text: opts.text,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
gatewayCallOpts(
gateway
.command("send")
.description("Send a message via the Gateway")
.requiredOption("--to <jidOrPhone>", "Destination (E.164 or jid)")
.requiredOption("--message <text>", "Message text")
.option("--media-url <url>", "Optional media URL")
.option("--gif-playback", "Treat video media as GIF playback", false)
.option("--idempotency-key <key>", "Idempotency key")
.action(async (opts) => {
try {
const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey();
const result = await callGatewayCli("send", opts, {
to: opts.to,
message: opts.message,
mediaUrl: opts.mediaUrl,
gifPlayback: opts.gifPlayback,
idempotencyKey,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
gatewayCallOpts(
gateway
.command("agent")
.description("Run an agent turn via the Gateway (waits for final)")
.requiredOption("--message <text>", "User message")
.option("--to <jidOrPhone>", "Destination")
.option("--session-id <id>", "Session id")
.option("--thinking <level>", "Thinking level")
.option("--deliver", "Deliver response", false)
.option("--timeout-seconds <n>", "Agent timeout seconds")
.option("--idempotency-key <key>", "Idempotency key")
.action(async (opts) => {
try {
const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey();
const result = await callGatewayCli(
"agent",
{ ...opts, expectFinal: true },
{
message: opts.message,
to: opts.to,
sessionId: opts.sessionId,
thinking: opts.thinking,
deliver: Boolean(opts.deliver),
timeout: opts.timeoutSeconds
? Number.parseInt(String(opts.timeoutSeconds), 10)
: undefined,
idempotencyKey,
},
);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
gateway
.command("stop")
.description("Stop the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonStop();
});
gateway
.command("restart")
.description("Restart the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonRestart();
});
// Build default deps (keeps parity with other commands; future-proofing).
void createDefaultDeps();
} }

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import type { Command } from "commander"; import type { Command } from "commander";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@@ -31,6 +32,14 @@ type NodesRpcOpts = {
params?: string; params?: string;
invokeTimeout?: string; invokeTimeout?: string;
idempotencyKey?: string; idempotencyKey?: string;
target?: string;
x?: string;
y?: string;
width?: string;
height?: string;
js?: string;
jsonl?: string;
text?: string;
cwd?: string; cwd?: string;
env?: string[]; env?: string[];
commandTimeout?: string; commandTimeout?: string;
@@ -99,6 +108,16 @@ type PairingList = {
paired: PairedNode[]; paired: PairedNode[];
}; };
const A2UI_ACTION_KEYS = [
"beginRendering",
"surfaceUpdate",
"dataModelUpdate",
"deleteSurface",
"createSurface",
] as const;
type A2UIVersion = "v0.8" | "v0.9";
const nodesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) => const nodesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
cmd cmd
.option( .option(
@@ -249,6 +268,86 @@ async function resolveNodeId(opts: NodesRpcOpts, query: string) {
); );
} }
function buildA2UITextJsonl(text: string) {
const surfaceId = "main";
const rootId = "root";
const textId = "text";
const payloads = [
{
surfaceUpdate: {
surfaceId,
components: [
{
id: rootId,
component: { Column: { children: { explicitList: [textId] } } },
},
{
id: textId,
component: {
Text: { text: { literalString: text }, usageHint: "body" },
},
},
],
},
},
{ beginRendering: { surfaceId, root: rootId } },
];
return payloads.map((payload) => JSON.stringify(payload)).join("\n");
}
function validateA2UIJsonl(jsonl: string) {
const lines = jsonl.split(/\r?\n/);
const errors: string[] = [];
let sawV08 = false;
let sawV09 = false;
let messageCount = 0;
lines.forEach((line, idx) => {
const trimmed = line.trim();
if (!trimmed) return;
messageCount += 1;
let obj: unknown;
try {
obj = JSON.parse(trimmed) as unknown;
} catch (err) {
errors.push(`line ${idx + 1}: ${String(err)}`);
return;
}
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
errors.push(`line ${idx + 1}: expected JSON object`);
return;
}
const record = obj as Record<string, unknown>;
const actionKeys = A2UI_ACTION_KEYS.filter((key) => key in record);
if (actionKeys.length !== 1) {
errors.push(
`line ${idx + 1}: expected exactly one action key (${A2UI_ACTION_KEYS.join(
", ",
)})`,
);
return;
}
if (actionKeys[0] === "createSurface") {
sawV09 = true;
} else {
sawV08 = true;
}
});
if (messageCount === 0) {
errors.push("no JSONL messages found");
}
if (sawV08 && sawV09) {
errors.push("mixed A2UI v0.8 and v0.9 messages in one file");
}
if (errors.length > 0) {
throw new Error(`Invalid A2UI JSONL:\n- ${errors.join("\n- ")}`);
}
const version: A2UIVersion = sawV09 ? "v0.9" : "v0.8";
return { version, messageCount };
}
export function registerNodesCli(program: Command) { export function registerNodesCli(program: Command) {
const nodes = program const nodes = program
.command("nodes") .command("nodes")
@@ -750,6 +849,25 @@ export function registerNodesCli(program: Command) {
.command("canvas") .command("canvas")
.description("Capture or render canvas content from a paired node"); .description("Capture or render canvas content from a paired node");
const invokeCanvas = async (
opts: NodesRpcOpts,
command: string,
params?: Record<string, unknown>,
) => {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const invokeParams: Record<string, unknown> = {
nodeId,
command,
params,
idempotencyKey: randomIdempotencyKey(),
};
const timeoutMs = parseTimeoutMs(opts.invokeTimeout);
if (typeof timeoutMs === "number") {
invokeParams.timeoutMs = timeoutMs;
}
return await callGatewayCli("node.invoke", opts, invokeParams);
};
nodesCallOpts( nodesCallOpts(
canvas canvas
.command("snapshot") .command("snapshot")
@@ -840,6 +958,181 @@ export function registerNodesCli(program: Command) {
{ timeoutMs: 60_000 }, { timeoutMs: 60_000 },
); );
nodesCallOpts(
canvas
.command("present")
.description("Show the canvas (optionally with a target URL/path)")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--target <urlOrPath>", "Target URL/path (optional)")
.option("--x <px>", "Placement x coordinate")
.option("--y <px>", "Placement y coordinate")
.option("--width <px>", "Placement width")
.option("--height <px>", "Placement height")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (opts: NodesRpcOpts) => {
try {
const placement = {
x: opts.x ? Number.parseFloat(opts.x) : undefined,
y: opts.y ? Number.parseFloat(opts.y) : undefined,
width: opts.width ? Number.parseFloat(opts.width) : undefined,
height: opts.height ? Number.parseFloat(opts.height) : undefined,
};
const params: Record<string, unknown> = {};
if (opts.target) params.url = String(opts.target);
if (
Number.isFinite(placement.x) ||
Number.isFinite(placement.y) ||
Number.isFinite(placement.width) ||
Number.isFinite(placement.height)
) {
params.placement = placement;
}
await invokeCanvas(opts, "canvas.present", params);
if (!opts.json) {
defaultRuntime.log("canvas present ok");
}
} catch (err) {
defaultRuntime.error(`nodes canvas present failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
nodesCallOpts(
canvas
.command("hide")
.description("Hide the canvas")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (opts: NodesRpcOpts) => {
try {
await invokeCanvas(opts, "canvas.hide", undefined);
if (!opts.json) {
defaultRuntime.log("canvas hide ok");
}
} catch (err) {
defaultRuntime.error(`nodes canvas hide failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
nodesCallOpts(
canvas
.command("navigate")
.description("Navigate the canvas to a URL")
.argument("<url>", "Target URL/path")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (url: string, opts: NodesRpcOpts) => {
try {
await invokeCanvas(opts, "canvas.navigate", { url });
if (!opts.json) {
defaultRuntime.log("canvas navigate ok");
}
} catch (err) {
defaultRuntime.error(`nodes canvas navigate failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
nodesCallOpts(
canvas
.command("eval")
.description("Evaluate JavaScript in the canvas")
.argument("[js]", "JavaScript to evaluate")
.option("--js <code>", "JavaScript to evaluate")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (jsArg: string | undefined, opts: NodesRpcOpts) => {
try {
const js = opts.js ?? jsArg;
if (!js) throw new Error("missing --js or <js>");
const raw = await invokeCanvas(opts, "canvas.eval", {
javaScript: js,
});
if (opts.json) {
defaultRuntime.log(JSON.stringify(raw, null, 2));
return;
}
const payload =
typeof raw === "object" && raw !== null
? (raw as { payload?: { result?: string } }).payload
: undefined;
if (payload?.result) {
defaultRuntime.log(payload.result);
} else {
defaultRuntime.log("canvas eval ok");
}
} catch (err) {
defaultRuntime.error(`nodes canvas eval failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
const a2ui = canvas
.command("a2ui")
.description("Render A2UI content on the canvas");
nodesCallOpts(
a2ui
.command("push")
.description("Push A2UI JSONL to the canvas")
.option("--jsonl <path>", "Path to JSONL payload")
.option("--text <text>", "Render a quick A2UI text payload")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (opts: NodesRpcOpts) => {
try {
const hasJsonl = Boolean(opts.jsonl);
const hasText = typeof opts.text === "string";
if (hasJsonl === hasText) {
throw new Error("provide exactly one of --jsonl or --text");
}
const jsonl = hasText
? buildA2UITextJsonl(String(opts.text ?? ""))
: await fs.readFile(String(opts.jsonl), "utf8");
const { version, messageCount } = validateA2UIJsonl(jsonl);
if (version === "v0.9") {
throw new Error(
"Detected A2UI v0.9 JSONL (createSurface). Clawdbot currently supports v0.8 only.",
);
}
await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl });
if (!opts.json) {
defaultRuntime.log(
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
);
}
} catch (err) {
defaultRuntime.error(`nodes canvas a2ui push failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
nodesCallOpts(
a2ui
.command("reset")
.description("Reset A2UI renderer state")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "Node invoke timeout in ms")
.action(async (opts: NodesRpcOpts) => {
try {
await invokeCanvas(opts, "canvas.a2ui.reset", undefined);
if (!opts.json) {
defaultRuntime.log("canvas a2ui reset ok");
}
} catch (err) {
defaultRuntime.error(`nodes canvas a2ui reset failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
nodesCallOpts( nodesCallOpts(
camera camera
.command("list") .command("list")

View File

@@ -644,44 +644,6 @@ describe("cli program", () => {
} }
}); });
it("runs canvas snapshot and prints MEDIA path", async () => {
callGateway
.mockResolvedValueOnce({
ts: Date.now(),
nodes: [
{
nodeId: "mac-1",
displayName: "Mac Node",
platform: "macos",
connected: true,
caps: ["canvas"],
},
],
})
.mockResolvedValueOnce({
ok: true,
nodeId: "mac-1",
command: "canvas.snapshot",
payload: { format: "png", base64: "aGk=" },
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["canvas", "snapshot", "--format", "png"], {
from: "user",
});
const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
const mediaPath = out.replace(/^MEDIA:/, "").trim();
expect(mediaPath).toMatch(/clawdbot-canvas-snapshot-.*\.png$/);
try {
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
} finally {
await fs.unlink(mediaPath).catch(() => {});
}
});
it("fails nodes camera snap on invalid facing", async () => { it("fails nodes camera snap on invalid facing", async () => {
callGateway.mockResolvedValueOnce({ callGateway.mockResolvedValueOnce({
ts: Date.now(), ts: Date.now(),

View File

@@ -14,7 +14,6 @@ import { sendCommand } from "../commands/send.js";
import { sessionsCommand } from "../commands/sessions.js"; import { sessionsCommand } from "../commands/sessions.js";
import { setupCommand } from "../commands/setup.js"; import { setupCommand } from "../commands/setup.js";
import { statusCommand } from "../commands/status.js"; import { statusCommand } from "../commands/status.js";
import { updateCommand } from "../commands/update.js";
import { import {
isNixMode, isNixMode,
loadConfig, loadConfig,
@@ -31,7 +30,6 @@ import { VERSION } from "../version.js";
import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveWhatsAppAccount } from "../web/accounts.js";
import { emitCliBanner, formatCliBannerLine } from "./banner.js"; import { emitCliBanner, formatCliBannerLine } from "./banner.js";
import { registerBrowserCli } from "./browser-cli.js"; import { registerBrowserCli } from "./browser-cli.js";
import { registerCanvasCli } from "./canvas-cli.js";
import { hasExplicitOptions } from "./command-options.js"; import { hasExplicitOptions } from "./command-options.js";
import { registerCronCli } from "./cron-cli.js"; import { registerCronCli } from "./cron-cli.js";
import { registerDaemonCli } from "./daemon-cli.js"; import { registerDaemonCli } from "./daemon-cli.js";
@@ -45,7 +43,6 @@ import { registerNodesCli } from "./nodes-cli.js";
import { registerPairingCli } from "./pairing-cli.js"; import { registerPairingCli } from "./pairing-cli.js";
import { forceFreePort } from "./ports.js"; import { forceFreePort } from "./ports.js";
import { registerProvidersCli } from "./providers-cli.js"; import { registerProvidersCli } from "./providers-cli.js";
import { registerTelegramCli } from "./telegram-cli.js";
import { registerTuiCli } from "./tui-cli.js"; import { registerTuiCli } from "./tui-cli.js";
export { forceFreePort }; export { forceFreePort };
@@ -345,17 +342,6 @@ export function buildProgram() {
}); });
program program
.command("update")
.description("Audit and modernize the local configuration")
.action(async () => {
try {
await updateCommand(defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
program program
.command("login") .command("login")
.description("Link your personal WhatsApp via QR (web provider)") .description("Link your personal WhatsApp via QR (web provider)")
@@ -633,7 +619,6 @@ Examples:
} }
}); });
registerCanvasCli(program);
registerDaemonCli(program); registerDaemonCli(program);
registerGatewayCli(program); registerGatewayCli(program);
registerModelsCli(program); registerModelsCli(program);
@@ -645,7 +630,6 @@ Examples:
registerHooksCli(program); registerHooksCli(program);
registerPairingCli(program); registerPairingCli(program);
registerProvidersCli(program); registerProvidersCli(program);
registerTelegramCli(program);
program program
.command("status") .command("status")

View File

@@ -1,74 +0,0 @@
import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import {
approveTelegramPairingCode,
listTelegramPairingRequests,
} from "../telegram/pairing-store.js";
import { sendMessageTelegram } from "../telegram/send.js";
import { resolveTelegramToken } from "../telegram/token.js";
export function registerTelegramCli(program: Command) {
const telegram = program
.command("telegram")
.description("Telegram helpers (pairing, allowlists)");
const pairing = telegram
.command("pairing")
.description("Secure DM pairing (approve inbound requests)");
pairing
.command("list")
.description("List pending Telegram pairing requests")
.option("--json", "Print JSON", false)
.action(async (opts) => {
const requests = await listTelegramPairingRequests();
if (opts.json) {
console.log(JSON.stringify({ requests }, null, 2));
return;
}
if (requests.length === 0) {
console.log("No pending Telegram pairing requests.");
return;
}
for (const r of requests) {
const name = [r.firstName, r.lastName].filter(Boolean).join(" ").trim();
const username = r.username ? `@${r.username}` : "";
const who = [name, username].filter(Boolean).join(" ").trim();
console.log(
`${r.code} chatId=${r.chatId}${who ? ` ${who}` : ""} ${r.createdAt}`,
);
}
});
pairing
.command("approve")
.description("Approve a pairing code and allow that chatId")
.argument("<code>", "Pairing code (shown to the requester)")
.option("--no-notify", "Do not notify the requester on Telegram")
.action(async (code, opts) => {
const approved = await approveTelegramPairingCode({ code: String(code) });
if (!approved) {
throw new Error(`No pending pairing request found for code: ${code}`);
}
console.log(`Approved Telegram chatId ${approved.chatId}.`);
if (opts.notify === false) return;
const cfg = loadConfig();
const { token } = resolveTelegramToken(cfg);
if (!token) {
console.log(
"Telegram token not configured; skipping requester notification.",
);
return;
}
await sendMessageTelegram(
approved.chatId,
"✅ Clawdbot access approved. Send a message to start chatting.",
{ token },
).catch((err) => {
console.log(`Failed to notify requester: ${String(err)}`);
});
});
}

View File

@@ -87,7 +87,7 @@ const uninstallLegacyGatewayServices = vi.fn().mockResolvedValue([]);
const findExtraGatewayServices = vi.fn().mockResolvedValue([]); const findExtraGatewayServices = vi.fn().mockResolvedValue([]);
const renderGatewayServiceCleanupHints = vi.fn().mockReturnValue(["cleanup"]); const renderGatewayServiceCleanupHints = vi.fn().mockReturnValue(["cleanup"]);
const resolveGatewayProgramArguments = vi.fn().mockResolvedValue({ const resolveGatewayProgramArguments = vi.fn().mockResolvedValue({
programArguments: ["node", "cli", "gateway-daemon", "--port", "18789"], programArguments: ["node", "cli", "gateway", "--port", "18789"],
}); });
const serviceInstall = vi.fn().mockResolvedValue(undefined); const serviceInstall = vi.fn().mockResolvedValue(undefined);
const serviceIsLoaded = vi.fn().mockResolvedValue(false); const serviceIsLoaded = vi.fn().mockResolvedValue(false);

View File

@@ -244,7 +244,7 @@ export async function doctorCommand(
} }
if (process.platform === "darwin") { if (process.platform === "darwin") {
note( note(
`LaunchAgent loaded; stopping requires "clawdbot gateway stop" or launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}.`, `LaunchAgent loaded; stopping requires "clawdbot daemon stop" or launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}.`,
"Gateway", "Gateway",
); );
} }

View File

@@ -1,21 +0,0 @@
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { runConfigureWizard } from "./configure.js";
export async function updateCommand(runtime: RuntimeEnv = defaultRuntime) {
await runConfigureWizard(
{
command: "update",
sections: [
"workspace",
"model",
"gateway",
"daemon",
"providers",
"skills",
"health",
],
},
runtime,
);
}

View File

@@ -23,7 +23,7 @@ export type FindExtraGatewayServicesOptions = {
deep?: boolean; deep?: boolean;
}; };
const EXTRA_MARKERS = ["clawdbot", "clawdis", "gateway-daemon"]; const EXTRA_MARKERS = ["clawdbot", "clawdis"];
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
export function renderGatewayServiceCleanupHints(): string[] { export function renderGatewayServiceCleanupHints(): string[] {

View File

@@ -43,7 +43,7 @@ describe("resolveGatewayProgramArguments", () => {
expect(result.programArguments).toEqual([ expect(result.programArguments).toEqual([
process.execPath, process.execPath,
entryPath, entryPath,
"gateway-daemon", "gateway",
"--port", "--port",
"18789", "18789",
]); ]);
@@ -70,7 +70,7 @@ describe("resolveGatewayProgramArguments", () => {
expect(result.programArguments).toEqual([ expect(result.programArguments).toEqual([
process.execPath, process.execPath,
indexPath, indexPath,
"gateway-daemon", "gateway",
"--port", "--port",
"18789", "18789",
]); ]);

View File

@@ -147,7 +147,7 @@ export async function resolveGatewayProgramArguments(params: {
dev?: boolean; dev?: boolean;
runtime?: GatewayRuntimePreference; runtime?: GatewayRuntimePreference;
}): Promise<GatewayProgramArgs> { }): Promise<GatewayProgramArgs> {
const gatewayArgs = ["gateway-daemon", "--port", String(params.port)]; const gatewayArgs = ["gateway", "--port", String(params.port)];
const execPath = process.execPath; const execPath = process.execPath;
const runtime = params.runtime ?? "auto"; const runtime = params.runtime ?? "auto";

View File

@@ -42,8 +42,7 @@ export function resolveBonjourCliPath(
const argv = opts.argv ?? process.argv; const argv = opts.argv ?? process.argv;
const argvPath = argv[1]; const argvPath = argv[1];
if (argvPath && isFile(argvPath)) { if (argvPath && isFile(argvPath)) {
const base = path.basename(argvPath); return argvPath;
if (!base.includes("gateway-daemon")) return argvPath;
} }
const cwd = opts.cwd ?? process.cwd(); const cwd = opts.cwd ?? process.cwd();

View File

@@ -34,7 +34,7 @@ export function buildPortHints(
const hints: string[] = []; const hints: string[] = [];
if (kinds.has("gateway")) { if (kinds.has("gateway")) {
hints.push( hints.push(
"Gateway already running locally. Stop it (clawdbot gateway stop) or use a different port.", "Gateway already running locally. Stop it (clawdbot daemon stop) or use a different port.",
); );
} }
if (kinds.has("ssh")) { if (kinds.has("ssh")) {