@switchbot/openapi-cli 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -7
- package/dist/api/client.js +35 -3
- package/dist/commands/agent-bootstrap.js +3 -0
- package/dist/commands/batch.js +64 -24
- package/dist/commands/cache.js +17 -1
- package/dist/commands/capabilities.js +36 -0
- package/dist/commands/catalog.js +60 -2
- package/dist/commands/config.js +6 -6
- package/dist/commands/device-meta.js +18 -1
- package/dist/commands/devices.js +148 -68
- package/dist/commands/events.js +63 -32
- package/dist/commands/expand.js +7 -5
- package/dist/commands/history.js +4 -7
- package/dist/commands/mcp.js +54 -8
- package/dist/commands/plan.js +6 -1
- package/dist/commands/scenes.js +9 -0
- package/dist/commands/watch.js +7 -0
- package/dist/devices/cache.js +61 -26
- package/dist/devices/param-validator.js +170 -0
- package/dist/utils/arg-parsers.js +2 -1
- package/dist/utils/filter.js +120 -39
- package/dist/utils/flags.js +27 -1
- package/dist/utils/format.js +2 -2
- package/dist/utils/name-resolver.js +6 -3
- package/dist/utils/output.js +64 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
|
|
|
63
63
|
- [Scripting examples](#scripting-examples)
|
|
64
64
|
- [Development](#development)
|
|
65
65
|
- [Contributing](#contributing)
|
|
66
|
+
- [Roadmap](#roadmap)
|
|
66
67
|
- [License](#license)
|
|
67
68
|
- [References](#references)
|
|
68
69
|
|
|
@@ -221,6 +222,12 @@ switchbot devices list --filter category=physical
|
|
|
221
222
|
switchbot devices list --filter type=Bot
|
|
222
223
|
switchbot devices list --filter name=living,category=physical
|
|
223
224
|
|
|
225
|
+
# Filter operators: = (substring; exact for `category`), ~ (substring),
|
|
226
|
+
# =/regex/ (case-insensitive regex). Clauses are AND-ed.
|
|
227
|
+
switchbot devices list --filter 'name~living'
|
|
228
|
+
switchbot devices list --filter 'type=/Hub.*/'
|
|
229
|
+
switchbot devices list --filter 'name~office,type=/Bulb|Strip/'
|
|
230
|
+
|
|
224
231
|
# Filter by family / room (family & room info requires the 'src: OpenClaw'
|
|
225
232
|
# header, which this CLI sends on every request)
|
|
226
233
|
switchbot devices list --json | jq '.deviceList[] | select(.familyName == "Home")'
|
|
@@ -255,6 +262,21 @@ switchbot devices commands "Smart Lock"
|
|
|
255
262
|
switchbot devices commands curtain # Case-insensitive, substring match
|
|
256
263
|
```
|
|
257
264
|
|
|
265
|
+
#### Filter expressions — per-command reference
|
|
266
|
+
|
|
267
|
+
Three commands accept `--filter`. They share one four-operator grammar,
|
|
268
|
+
but each exposes its own key set:
|
|
269
|
+
|
|
270
|
+
| Command | Operators | Supported keys |
|
|
271
|
+
|-------------------------------------|-----------------------------------------------------------------------------------------------|---------------------------------------|
|
|
272
|
+
| `devices list` | `=` (substring; **exact** for `category`), `!=` (negated), `~` (substring), `=/regex/` (case-insensitive regex) | `type`, `name`, `category`, `room` |
|
|
273
|
+
| `devices batch` | same | `type`, `family`, `room`, `category` |
|
|
274
|
+
| `events tail` / `events mqtt-tail` | same (tail only; mqtt-tail uses `--topic` instead) | `deviceId`, `type` |
|
|
275
|
+
|
|
276
|
+
Clauses are comma-separated and AND-ed. No OR across clauses — use regex
|
|
277
|
+
alternation (`=/A|B/`) for that. `category` is the one key that stays exact
|
|
278
|
+
under `=` / `!=` to preserve `category=physical` / `category!=ir` semantics.
|
|
279
|
+
|
|
258
280
|
#### Parameter formats
|
|
259
281
|
|
|
260
282
|
`parameter` is optional — omit it for commands like `turnOn`/`turnOff` (auto-defaults to `"default"`).
|
|
@@ -278,7 +300,11 @@ Generic parameter shapes (which one applies is decided by the device — see the
|
|
|
278
300
|
| `<json object>` | `'{"action":"sweep","param":{"fanLevel":2,"times":1}}'` |
|
|
279
301
|
| Custom IR button | `devices command <id> MyButton --type customize` |
|
|
280
302
|
|
|
281
|
-
Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt),
|
|
303
|
+
Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), `setMode` (Relay Switch), `setBrightness` (dimmable lights), and `setColor` (Color Bulb / Strip Light / Ceiling Light) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. `setColor` accepts `R:G:B`, `R,G,B`, `#RRGGBB`, `#RGB`, and CSS named colors (`red`, `blue`, …); all normalize to `R:G:B` before hitting the API. Pass `--skip-param-validation` to bypass (escape hatch — prefer fixing the argument). Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list.
|
|
304
|
+
|
|
305
|
+
Unknown deviceIds (not in the local cache) exit 2 by default so `--dry-run` is a reliable pre-flight gate. Run `switchbot devices list` first, or pass `--allow-unknown-device` for scripted pass-through.
|
|
306
|
+
|
|
307
|
+
Negative numeric parameters (e.g. `setBrightness -1` for a probe) are passed through to the command validator instead of being swallowed by the flag parser as an unknown option.
|
|
282
308
|
|
|
283
309
|
For the complete per-device command reference, see the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands).
|
|
284
310
|
|
|
@@ -302,7 +328,7 @@ switchbot devices expand <blindId> setPosition --direction up --angle 50
|
|
|
302
328
|
switchbot devices expand <relayId> setMode --channel 1 --mode edge
|
|
303
329
|
```
|
|
304
330
|
|
|
305
|
-
Run `switchbot devices expand <id> <command> --help` to see the available flags for any device command.
|
|
331
|
+
Run `switchbot devices expand <id> <command> --help` to see the available flags for any device command. `expand` is only meaningful for multi-parameter commands (the four above); single-parameter commands like `setBrightness 50` or `setColor "#FF0000"` are already flag-free at the CLI level.
|
|
306
332
|
|
|
307
333
|
#### `devices explain` — one-shot device summary
|
|
308
334
|
|
|
@@ -333,7 +359,7 @@ Stores local annotations (alias, hidden flag, notes) in `~/.switchbot/device-met
|
|
|
333
359
|
```bash
|
|
334
360
|
# Send the same command to every device matching a filter
|
|
335
361
|
switchbot devices batch turnOff --filter 'type=Bot'
|
|
336
|
-
switchbot devices batch setBrightness 50 --filter 'type
|
|
362
|
+
switchbot devices batch setBrightness 50 --filter 'type~Light,family=Living'
|
|
337
363
|
|
|
338
364
|
# Explicit device IDs (comma-separated)
|
|
339
365
|
switchbot devices batch turnOn --ids ID1,ID2,ID3
|
|
@@ -343,9 +369,18 @@ switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch
|
|
|
343
369
|
|
|
344
370
|
# Destructive commands require --yes
|
|
345
371
|
switchbot devices batch unlock --filter 'type=Smart Lock' --yes
|
|
372
|
+
|
|
373
|
+
# Skip devices whose cached status is offline (default: off)
|
|
374
|
+
switchbot devices batch turnOn --ids ID1,ID2 --skip-offline
|
|
375
|
+
|
|
376
|
+
# --idempotency-key is an alias for --idempotency-key-prefix; both append -<deviceId>
|
|
377
|
+
switchbot devices batch turnOn --ids ID1,ID2 --idempotency-key morning-lights
|
|
346
378
|
```
|
|
347
379
|
|
|
348
|
-
Sends the same command to many devices in one run.
|
|
380
|
+
Sends the same command to many devices in one run. Filter grammar matches `devices list` (`=` substring, `~` substring, `=/regex/` regex — clauses AND-ed); supported keys here are `type`, `family`, `room`, `category`. Destructive commands (Smart Lock unlock, Garage Door Opener, etc.) require `--yes` to prevent accidents.
|
|
381
|
+
|
|
382
|
+
`--skip-offline` reads from the local status cache only (no new API calls);
|
|
383
|
+
skipped devices appear under `summary.skipped` with `skippedReason:'offline'`.
|
|
349
384
|
|
|
350
385
|
### `scenes` — run manual scenes
|
|
351
386
|
|
|
@@ -390,6 +425,9 @@ switchbot events tail --filter deviceId=ABC123
|
|
|
390
425
|
# Stop after 5 matching events
|
|
391
426
|
switchbot events tail --filter 'type=WoMeter' --max 5
|
|
392
427
|
|
|
428
|
+
# Stop after 10 minutes regardless of event count
|
|
429
|
+
switchbot events tail --for 10m
|
|
430
|
+
|
|
393
431
|
# Custom port / path
|
|
394
432
|
switchbot events tail --port 8080 --path /hook --json
|
|
395
433
|
```
|
|
@@ -401,7 +439,7 @@ Output (one JSON line per matched event):
|
|
|
401
439
|
{ "t": "2024-01-01T12:00:00.000Z", "remote": "1.2.3.4:54321", "path": "/", "body": {...}, "matched": true }
|
|
402
440
|
```
|
|
403
441
|
|
|
404
|
-
Filter keys: `deviceId
|
|
442
|
+
Filter keys: `deviceId`, `type`. Operators: `=` (substring), `~` (substring), `=/regex/` (case-insensitive regex). Clauses comma-separated and AND-ed.
|
|
405
443
|
|
|
406
444
|
#### `events mqtt-tail` — real-time MQTT stream
|
|
407
445
|
|
|
@@ -414,6 +452,9 @@ switchbot events mqtt-tail --topic 'switchbot/#'
|
|
|
414
452
|
|
|
415
453
|
# Stop after 10 events
|
|
416
454
|
switchbot events mqtt-tail --max 10 --json
|
|
455
|
+
|
|
456
|
+
# Stop after a fixed duration (emits __session_start under --json before connect)
|
|
457
|
+
switchbot events mqtt-tail --for 30s --json
|
|
417
458
|
```
|
|
418
459
|
|
|
419
460
|
Connects to the SwitchBot MQTT service automatically using the same credentials configured for the REST API (`SWITCHBOT_TOKEN` + `SWITCHBOT_SECRET`). No additional MQTT configuration is required — the client certificates are provisioned on first use.
|
|
@@ -514,9 +555,12 @@ switchbot devices watch <deviceId>
|
|
|
514
555
|
|
|
515
556
|
# Custom interval; emit every tick even when nothing changed
|
|
516
557
|
switchbot devices watch <deviceId> --interval 10s --include-unchanged --json
|
|
558
|
+
|
|
559
|
+
# Time-bounded: stop after 5 minutes instead of a fixed tick count
|
|
560
|
+
switchbot devices watch <deviceId> --for 5m
|
|
517
561
|
```
|
|
518
562
|
|
|
519
|
-
Output is a JSONL stream of status-change events (with `--json`) or a refreshed table. Use `--max <n>` to stop after N ticks.
|
|
563
|
+
Output is a JSONL stream of status-change events (with `--json`) or a refreshed table. Use `--max <n>` to stop after N ticks, or `--for <duration>` to stop after an elapsed wall-clock window (e.g. `30s`, `1h`, `2d`). When both are set, whichever limit trips first wins.
|
|
520
564
|
|
|
521
565
|
### `mcp` — Model Context Protocol server
|
|
522
566
|
|
|
@@ -561,7 +605,9 @@ Reads the JSONL audit log (`~/.switchbot/audit.log` by default; override with `-
|
|
|
561
605
|
|
|
562
606
|
```bash
|
|
563
607
|
switchbot catalog show # all 42 built-in types
|
|
608
|
+
switchbot catalog list # alias for `show`
|
|
564
609
|
switchbot catalog show Bot # one type
|
|
610
|
+
switchbot catalog search Hub # fuzzy match across type / aliases / commands
|
|
565
611
|
switchbot catalog diff # what a local overlay changes vs built-in
|
|
566
612
|
switchbot catalog path # location of the local overlay file
|
|
567
613
|
switchbot catalog refresh # reload local overlay (clears in-process cache)
|
|
@@ -583,9 +629,10 @@ Exports the effective catalog in a machine-readable format. Pipe the output into
|
|
|
583
629
|
|
|
584
630
|
```bash
|
|
585
631
|
switchbot capabilities --json
|
|
632
|
+
switchbot capabilities --used --json # only types seen in the local cache
|
|
586
633
|
```
|
|
587
634
|
|
|
588
|
-
Prints a versioned JSON manifest describing available surfaces (CLI, MCP, MQTT, plan runner), commands, and environment variables.
|
|
635
|
+
Prints a versioned JSON manifest describing available surfaces (CLI, MCP, MQTT, plan runner), commands, and environment variables. Every subcommand leaf now carries a `{mutating, consumesQuota, idempotencySupported, agentSafetyTier, verifiability, typicalLatencyMs}` block, and the top-level payload publishes a flat `commandMeta` path-keyed lookup so agents don't have to walk the tree. `--used` filters the per-type summary to devices actually present in the local cache (same semantics as `schema export --used`).
|
|
589
636
|
|
|
590
637
|
### `cache` — inspect and clear local cache
|
|
591
638
|
|
|
@@ -781,6 +828,21 @@ Bug reports, feature requests, and PRs are welcome.
|
|
|
781
828
|
3. Run `npm test` and `npm run build` locally — both must pass.
|
|
782
829
|
4. Open a pull request against `main`. CI runs on Node 18/20/22; all three must stay green.
|
|
783
830
|
|
|
831
|
+
## Roadmap
|
|
832
|
+
|
|
833
|
+
Tracked for a future v3.x line (OpenClaw B-17 / B-18 / B-19 / B-21) — each is a
|
|
834
|
+
standalone track rather than a bug fix:
|
|
835
|
+
|
|
836
|
+
- **Daemon mode** — long-running local process with a Unix/named-pipe socket so
|
|
837
|
+
repeated MCP or plan invocations don't pay fresh-process startup every call.
|
|
838
|
+
- **`npx @switchbot/mcp-server`** — split the MCP server into its own tiny
|
|
839
|
+
published package so non-CLI users can `npx` it directly without installing
|
|
840
|
+
the full CLI.
|
|
841
|
+
- **`switchbot self-test`** — scripted end-to-end harness that checks a live
|
|
842
|
+
token + a representative device and prints a go/no-go report.
|
|
843
|
+
- **Record / replay** — capture raw request/response pairs into a fixture file
|
|
844
|
+
and replay them offline for deterministic testing and CI.
|
|
845
|
+
|
|
784
846
|
## License
|
|
785
847
|
|
|
786
848
|
[MIT](./LICENSE) © chenliuyun
|
package/dist/api/client.js
CHANGED
|
@@ -2,7 +2,7 @@ import axios from 'axios';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { buildAuthHeaders } from '../auth.js';
|
|
4
4
|
import { loadConfig } from '../config.js';
|
|
5
|
-
import { isVerbose, isDryRun, getTimeout, getRetryOn429, getBackoffStrategy, isQuotaDisabled, } from '../utils/flags.js';
|
|
5
|
+
import { isVerbose, isDryRun, getTimeout, getRetryOn429, getRetryOn5xx, getBackoffStrategy, isQuotaDisabled, } from '../utils/flags.js';
|
|
6
6
|
import { nextRetryDelayMs, sleep } from '../utils/retry.js';
|
|
7
7
|
import { recordRequest, checkDailyCap } from '../utils/quota.js';
|
|
8
8
|
import { readProfileMeta } from '../config.js';
|
|
@@ -45,6 +45,7 @@ export function createClient() {
|
|
|
45
45
|
const verbose = isVerbose();
|
|
46
46
|
const dryRun = isDryRun();
|
|
47
47
|
const maxRetries = getRetryOn429();
|
|
48
|
+
const max5xxRetries = getRetryOn5xx();
|
|
48
49
|
const backoff = getBackoffStrategy();
|
|
49
50
|
const quotaEnabled = !isQuotaDisabled();
|
|
50
51
|
const profile = getActiveProfile();
|
|
@@ -110,11 +111,25 @@ export function createClient() {
|
|
|
110
111
|
if (error instanceof DryRunSignal)
|
|
111
112
|
throw error;
|
|
112
113
|
if (axios.isAxiosError(error)) {
|
|
114
|
+
const config = error.config;
|
|
115
|
+
const method = (config?.method ?? 'get').toUpperCase();
|
|
116
|
+
const isIdempotentRead = method === 'GET';
|
|
113
117
|
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
|
114
|
-
|
|
118
|
+
// Retry idempotent GETs on timeout up to `max5xxRetries` times.
|
|
119
|
+
if (isIdempotentRead && config && max5xxRetries > 0) {
|
|
120
|
+
const attempt = config.__retryCount ?? 0;
|
|
121
|
+
if (attempt < max5xxRetries) {
|
|
122
|
+
config.__retryCount = attempt + 1;
|
|
123
|
+
const delay = nextRetryDelayMs(attempt, backoff, undefined);
|
|
124
|
+
if (verbose) {
|
|
125
|
+
process.stderr.write(chalk.grey(`[verbose] timeout — retry ${attempt + 1}/${max5xxRetries} in ${delay}ms\n`));
|
|
126
|
+
}
|
|
127
|
+
return sleep(delay).then(() => client.request(config));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0, { transient: true, retryable: isIdempotentRead });
|
|
115
131
|
}
|
|
116
132
|
const status = error.response?.status;
|
|
117
|
-
const config = error.config;
|
|
118
133
|
// 429 → transparent retry with Retry-After / exponential backoff.
|
|
119
134
|
// Skipped when: no config (shouldn't happen for real axios errors),
|
|
120
135
|
// retries disabled, or we've already used our budget.
|
|
@@ -129,6 +144,23 @@ export function createClient() {
|
|
|
129
144
|
return sleep(delay).then(() => client.request(config));
|
|
130
145
|
}
|
|
131
146
|
}
|
|
147
|
+
// 502/503/504 on idempotent GETs → transparent retry. Mutating calls
|
|
148
|
+
// never auto-retry; use --idempotency-key for safe POST retries.
|
|
149
|
+
if (isIdempotentRead &&
|
|
150
|
+
status !== undefined &&
|
|
151
|
+
(status === 502 || status === 503 || status === 504) &&
|
|
152
|
+
config &&
|
|
153
|
+
max5xxRetries > 0) {
|
|
154
|
+
const attempt = config.__retryCount ?? 0;
|
|
155
|
+
if (attempt < max5xxRetries) {
|
|
156
|
+
config.__retryCount = attempt + 1;
|
|
157
|
+
const delay = nextRetryDelayMs(attempt, backoff, error.response?.headers?.['retry-after']);
|
|
158
|
+
if (verbose) {
|
|
159
|
+
process.stderr.write(chalk.grey(`[verbose] ${status} received — retry ${attempt + 1}/${max5xxRetries} in ${delay}ms\n`));
|
|
160
|
+
}
|
|
161
|
+
return sleep(delay).then(() => client.request(config));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
132
164
|
// Record exhausted/non-retryable HTTP responses too — they count
|
|
133
165
|
// against the daily quota.
|
|
134
166
|
if (quotaEnabled && error.response && config) {
|
|
@@ -3,6 +3,7 @@ import { loadCache } from '../devices/cache.js';
|
|
|
3
3
|
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
4
4
|
import { readProfileMeta } from '../config.js';
|
|
5
5
|
import { todayUsage, DAILY_QUOTA } from '../utils/quota.js';
|
|
6
|
+
import { ALL_STRATEGIES } from '../utils/name-resolver.js';
|
|
6
7
|
import { createRequire } from 'node:module';
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const { version: pkgVersion } = require('../../package.json');
|
|
@@ -24,6 +25,7 @@ const QUICK_REFERENCE = {
|
|
|
24
25
|
safety: ['--dry-run', '--idempotency-key <k>', '--audit-log', '--no-quota'],
|
|
25
26
|
observability: ['doctor --json', 'quota status', 'cache status', 'events mqtt-tail'],
|
|
26
27
|
history: ['history range <id> --since 7d', 'history stats <id>'],
|
|
28
|
+
meta: ['devices meta set <id> --alias <name>', 'devices meta list', 'devices meta get <id>'],
|
|
27
29
|
};
|
|
28
30
|
export function registerAgentBootstrapCommand(program) {
|
|
29
31
|
program
|
|
@@ -97,6 +99,7 @@ Examples:
|
|
|
97
99
|
identity: IDENTITY,
|
|
98
100
|
quickReference: QUICK_REFERENCE,
|
|
99
101
|
safetyTiers: SAFETY_TIERS,
|
|
102
|
+
nameStrategies: [...ALL_STRATEGIES],
|
|
100
103
|
profile: meta
|
|
101
104
|
? {
|
|
102
105
|
label: meta.label ?? null,
|
package/dist/commands/batch.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { intArg, enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
2
|
-
import { printJson, isJsonMode, handleError, buildErrorPayload } from '../utils/output.js';
|
|
2
|
+
import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, emitJsonError } from '../utils/output.js';
|
|
3
3
|
import { fetchDeviceList, executeCommand, isDestructiveCommand, buildHubLocationMap, } from '../lib/devices.js';
|
|
4
4
|
import { createClient } from '../api/client.js';
|
|
5
5
|
import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js';
|
|
6
6
|
import { isDryRun } from '../utils/flags.js';
|
|
7
7
|
import { DryRunSignal } from '../api/client.js';
|
|
8
|
-
import { getCachedTypeMap } from '../devices/cache.js';
|
|
8
|
+
import { getCachedTypeMap, getCachedDevice, loadStatusCache } from '../devices/cache.js';
|
|
9
9
|
const DEFAULT_CONCURRENCY = 5;
|
|
10
10
|
const COMMAND_TYPES = ['command', 'customize'];
|
|
11
11
|
/**
|
|
@@ -101,6 +101,8 @@ export function registerBatchCommand(devices) {
|
|
|
101
101
|
.option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
|
|
102
102
|
.option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
|
|
103
103
|
.option('--idempotency-key-prefix <prefix>', 'Client-supplied prefix for idempotency keys (key per device: <prefix>-<deviceId>). process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key-prefix'))
|
|
104
|
+
.option('--idempotency-key <prefix>', 'Alias for --idempotency-key-prefix.', stringArg('--idempotency-key'))
|
|
105
|
+
.option('--skip-offline', 'Skip devices whose cached status is offline (no API call; cache miss → send as usual).')
|
|
104
106
|
.addHelpText('after', `
|
|
105
107
|
Targets are resolved in this priority order:
|
|
106
108
|
1. --ids when present (explicit deviceIds)
|
|
@@ -144,6 +146,14 @@ Examples:
|
|
|
144
146
|
// Trailing "-" sentinel selects stdin mode.
|
|
145
147
|
const extra = commandObj.args ?? [];
|
|
146
148
|
const readStdin = Boolean(options.stdin) || extra.includes('-');
|
|
149
|
+
// Accept --idempotency-key as alias; reject when both forms are supplied.
|
|
150
|
+
if (options.idempotencyKey !== undefined && options.idempotencyKeyPrefix !== undefined) {
|
|
151
|
+
handleError(new UsageError('Use either --idempotency-key or --idempotency-key-prefix, not both.'));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (options.idempotencyKey !== undefined && options.idempotencyKeyPrefix === undefined) {
|
|
155
|
+
options.idempotencyKeyPrefix = options.idempotencyKey;
|
|
156
|
+
}
|
|
147
157
|
let client;
|
|
148
158
|
const getClient = () => (client ??= createClient());
|
|
149
159
|
let resolved;
|
|
@@ -157,7 +167,7 @@ Examples:
|
|
|
157
167
|
catch (error) {
|
|
158
168
|
if (error instanceof FilterSyntaxError) {
|
|
159
169
|
if (isJsonMode()) {
|
|
160
|
-
|
|
170
|
+
emitJsonError({ code: 2, kind: 'usage', message: error.message });
|
|
161
171
|
}
|
|
162
172
|
else {
|
|
163
173
|
console.error(`Error: ${error.message}`);
|
|
@@ -166,7 +176,7 @@ Examples:
|
|
|
166
176
|
}
|
|
167
177
|
if (error instanceof Error && error.message.startsWith('No target devices')) {
|
|
168
178
|
if (isJsonMode()) {
|
|
169
|
-
|
|
179
|
+
emitJsonError({ code: 2, kind: 'usage', message: error.message });
|
|
170
180
|
}
|
|
171
181
|
else {
|
|
172
182
|
console.error(`Error: ${error.message}`);
|
|
@@ -179,7 +189,7 @@ Examples:
|
|
|
179
189
|
const out = {
|
|
180
190
|
succeeded: [],
|
|
181
191
|
failed: [],
|
|
182
|
-
summary: { total: 0, ok: 0, failed: 0, skipped: 0, durationMs: 0 },
|
|
192
|
+
summary: { total: 0, ok: 0, failed: 0, skipped: 0, durationMs: 0, unverifiableCount: 0 },
|
|
183
193
|
};
|
|
184
194
|
if (isJsonMode())
|
|
185
195
|
printJson(out);
|
|
@@ -188,6 +198,24 @@ Examples:
|
|
|
188
198
|
return;
|
|
189
199
|
}
|
|
190
200
|
const effectiveType = (options.type === 'customize' ? 'customize' : 'command');
|
|
201
|
+
// --skip-offline: preflight using the status cache (no network). Cache
|
|
202
|
+
// miss = send as usual; only definite "offline" cached entries skip.
|
|
203
|
+
const preSkipped = [];
|
|
204
|
+
if (options.skipOffline && resolved.ids.length > 0) {
|
|
205
|
+
const statusCache = loadStatusCache();
|
|
206
|
+
const kept = [];
|
|
207
|
+
for (const id of resolved.ids) {
|
|
208
|
+
const entry = statusCache.entries[id];
|
|
209
|
+
const online = entry?.body?.onlineStatus;
|
|
210
|
+
if (online === 'offline') {
|
|
211
|
+
preSkipped.push({ deviceId: id, reason: 'offline' });
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
kept.push(id);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
resolved = { ...resolved, ids: kept };
|
|
218
|
+
}
|
|
191
219
|
// Pre-flight: identify destructive targets before spending API calls.
|
|
192
220
|
const blockedForDestructive = [];
|
|
193
221
|
for (const id of resolved.ids) {
|
|
@@ -202,15 +230,13 @@ Examples:
|
|
|
202
230
|
if (blockedForDestructive.length > 0 && !options.yes) {
|
|
203
231
|
if (isJsonMode()) {
|
|
204
232
|
const deviceIds = blockedForDestructive.map((b) => b.deviceId);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
},
|
|
213
|
-
}));
|
|
233
|
+
emitJsonError({
|
|
234
|
+
code: 2,
|
|
235
|
+
kind: 'guard',
|
|
236
|
+
message: `Destructive command "${cmd}" requires --yes to run on ${blockedForDestructive.length} device(s).`,
|
|
237
|
+
hint: 'Re-issue the call with --yes to proceed.',
|
|
238
|
+
context: { command: cmd, deviceIds },
|
|
239
|
+
});
|
|
214
240
|
}
|
|
215
241
|
else {
|
|
216
242
|
console.error(`Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes:`);
|
|
@@ -324,14 +350,26 @@ Examples:
|
|
|
324
350
|
const failed = outcomes.filter((o) => o.ok === false);
|
|
325
351
|
const dryRunned = outcomes.filter((o) => o.ok === 'dry-run');
|
|
326
352
|
const result = {
|
|
327
|
-
succeeded: succeeded.map((s) =>
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
353
|
+
succeeded: succeeded.map((s) => {
|
|
354
|
+
const isIr = getCachedDevice(s.deviceId)?.category === 'ir';
|
|
355
|
+
const entry = {
|
|
356
|
+
deviceId: s.deviceId,
|
|
357
|
+
result: s.result,
|
|
358
|
+
startedAt: s.startedAt,
|
|
359
|
+
finishedAt: s.finishedAt,
|
|
360
|
+
durationMs: s.durationMs,
|
|
361
|
+
replayed: s.replayed,
|
|
362
|
+
};
|
|
363
|
+
if (isIr) {
|
|
364
|
+
entry.subKind = 'ir-no-feedback';
|
|
365
|
+
entry.verification = {
|
|
366
|
+
verifiable: false,
|
|
367
|
+
reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
|
|
368
|
+
suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return entry;
|
|
372
|
+
}),
|
|
335
373
|
failed: failed.map((f) => ({
|
|
336
374
|
deviceId: f.deviceId,
|
|
337
375
|
error: f.error,
|
|
@@ -339,12 +377,14 @@ Examples:
|
|
|
339
377
|
finishedAt: f.finishedAt,
|
|
340
378
|
durationMs: f.durationMs,
|
|
341
379
|
})),
|
|
380
|
+
...(preSkipped.length > 0 ? { skipped: preSkipped } : {}),
|
|
342
381
|
summary: {
|
|
343
|
-
total: resolved.ids.length,
|
|
382
|
+
total: resolved.ids.length + preSkipped.length,
|
|
344
383
|
ok: succeeded.length,
|
|
345
384
|
failed: failed.length,
|
|
346
|
-
skipped: dryRunned.length,
|
|
385
|
+
skipped: dryRunned.length + preSkipped.length,
|
|
347
386
|
durationMs: Date.now() - startedAt,
|
|
387
|
+
unverifiableCount: succeeded.filter((s) => getCachedDevice(s.deviceId)?.category === 'ir').length,
|
|
348
388
|
schemaVersion: '1.1',
|
|
349
389
|
maxConcurrent: concurrency,
|
|
350
390
|
staggerMs,
|
package/dist/commands/cache.js
CHANGED
|
@@ -48,6 +48,10 @@ Examples:
|
|
|
48
48
|
.command('show')
|
|
49
49
|
.alias('status')
|
|
50
50
|
.description('Summarize the cache files (paths, ages, entry counts)')
|
|
51
|
+
.addHelpText('after', `
|
|
52
|
+
Cache TTL is computed from the 'lastUpdated' field inside the JSON, not the file mtime.
|
|
53
|
+
touch does not invalidate; use 'cache clear' to force a refresh.
|
|
54
|
+
`)
|
|
51
55
|
.action(() => {
|
|
52
56
|
const summary = describeCache();
|
|
53
57
|
if (isJsonMode()) {
|
|
@@ -82,9 +86,21 @@ Examples:
|
|
|
82
86
|
.command('clear')
|
|
83
87
|
.description('Delete cache files')
|
|
84
88
|
.option('--key <which>', 'Which cache to clear: "list" | "status" | "all" (default)', enumArg('--key', CACHE_KEYS), 'all')
|
|
89
|
+
.option('--status', 'Shorthand for --key status')
|
|
90
|
+
.option('--list', 'Shorthand for --key list')
|
|
85
91
|
.action((options) => {
|
|
86
92
|
try {
|
|
87
|
-
|
|
93
|
+
if (options.status && options.list) {
|
|
94
|
+
throw new UsageError('--status and --list are mutually exclusive.');
|
|
95
|
+
}
|
|
96
|
+
if ((options.status || options.list) && options.key !== 'all') {
|
|
97
|
+
throw new UsageError('--status / --list cannot be combined with --key.');
|
|
98
|
+
}
|
|
99
|
+
let key = options.key;
|
|
100
|
+
if (options.status)
|
|
101
|
+
key = 'status';
|
|
102
|
+
if (options.list)
|
|
103
|
+
key = 'list';
|
|
88
104
|
if (!['list', 'status', 'all'].includes(key)) {
|
|
89
105
|
throw new UsageError(`Unknown --key "${key}". Expected: list, status, all.`);
|
|
90
106
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
2
|
+
import { loadCache } from '../devices/cache.js';
|
|
2
3
|
import { printJson } from '../utils/output.js';
|
|
3
4
|
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
4
5
|
const AGENT_GUIDE = {
|
|
@@ -22,6 +23,11 @@ const COMMAND_META = {
|
|
|
22
23
|
'devices types': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
23
24
|
'devices commands': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
|
|
24
25
|
'devices watch': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
|
|
26
|
+
// devices meta (local metadata — no quota, no API call)
|
|
27
|
+
'devices meta set': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
|
|
28
|
+
'devices meta get': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
29
|
+
'devices meta list': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
|
|
30
|
+
'devices meta clear': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
|
|
25
31
|
// devices: actions
|
|
26
32
|
'devices command': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 800 },
|
|
27
33
|
'devices batch': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1200 },
|
|
@@ -144,6 +150,7 @@ export function registerCapabilitiesCommand(program) {
|
|
|
144
150
|
.description('Print a machine-readable manifest of CLI capabilities (for agent bootstrap)')
|
|
145
151
|
.option('--minimal', 'Omit per-subcommand flag details to reduce output size (alias of --compact)')
|
|
146
152
|
.option('--compact', 'Emit a compact summary: identity + leaf command list with safety metadata only')
|
|
153
|
+
.option('--used', 'Restrict the catalog summary to device types present in the local cache. Mirrors `schema export --used`.')
|
|
147
154
|
.option('--surface <s>', 'Restrict surfaces block to one of: cli, mcp, plan, mqtt, all (default: all)', enumArg('--surface', SURFACES))
|
|
148
155
|
.option('--project <csv>', 'Project top-level fields (e.g. --project identity,commands,agentGuide)', stringArg('--project'))
|
|
149
156
|
.action((opts) => {
|
|
@@ -233,6 +240,11 @@ export function registerCapabilitiesCommand(program) {
|
|
|
233
240
|
identity: IDENTITY,
|
|
234
241
|
surfaces: filteredSurfaces,
|
|
235
242
|
commands: compact ? leaves : fullCommands,
|
|
243
|
+
// Flat command → meta map keyed by full command path. Published in
|
|
244
|
+
// addition to the tree (where every leaf `subcommands[*]` already
|
|
245
|
+
// carries the same fields via spread) so agents can do O(1) lookup
|
|
246
|
+
// without walking the tree.
|
|
247
|
+
commandMeta: COMMAND_META,
|
|
236
248
|
...(globalFlags ? { globalFlags } : {}),
|
|
237
249
|
catalog: {
|
|
238
250
|
typeCount: catalog.length,
|
|
@@ -243,6 +255,30 @@ export function registerCapabilitiesCommand(program) {
|
|
|
243
255
|
};
|
|
244
256
|
if (!compact)
|
|
245
257
|
payload.generatedAt = new Date().toISOString();
|
|
258
|
+
if (opts.used) {
|
|
259
|
+
const cache = loadCache();
|
|
260
|
+
if (!cache || Object.keys(cache.devices).length === 0) {
|
|
261
|
+
// No cache → return the payload unchanged but add a `usedFilter` note
|
|
262
|
+
// so agents know the filter was requested but noop'd.
|
|
263
|
+
payload.usedFilter = { applied: false, reason: 'no local cache — run `switchbot devices list` first' };
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
for (const id of Object.keys(cache.devices)) {
|
|
268
|
+
const t = cache.devices[id].type;
|
|
269
|
+
if (t)
|
|
270
|
+
seen.add(t);
|
|
271
|
+
}
|
|
272
|
+
const filteredCatalog = catalog.filter((e) => seen.has(e.type) || (e.aliases ?? []).some((a) => seen.has(a)));
|
|
273
|
+
payload.catalog = {
|
|
274
|
+
typeCount: filteredCatalog.length,
|
|
275
|
+
roles: [...new Set(filteredCatalog.map((e) => e.role ?? 'other'))].sort(),
|
|
276
|
+
destructiveCommandCount: filteredCatalog.reduce((n, e) => n + e.commands.filter((c) => c.destructive).length, 0),
|
|
277
|
+
readOnlyTypeCount: filteredCatalog.filter((e) => e.readOnly).length,
|
|
278
|
+
};
|
|
279
|
+
payload.usedFilter = { applied: true, typesInCache: [...seen].sort() };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
246
282
|
const projected = opts.project
|
|
247
283
|
? projectObject(payload, opts.project.split(',').map((s) => s.trim()).filter(Boolean))
|
|
248
284
|
: payload;
|
package/dist/commands/catalog.js
CHANGED
|
@@ -23,14 +23,18 @@ Overlay rules (applied in order):
|
|
|
23
23
|
|
|
24
24
|
Subcommands:
|
|
25
25
|
path Print the overlay file path and whether it exists
|
|
26
|
-
show Show the effective catalog (or one entry)
|
|
26
|
+
show Show the effective catalog (or one entry). Alias: list
|
|
27
|
+
list Alias of show (matches the muscle-memory spelling)
|
|
28
|
+
search Fuzzy search types/aliases/roles/commands for a keyword
|
|
27
29
|
diff Show what the overlay changes vs the built-in catalog
|
|
28
30
|
refresh Re-read the overlay file (clears in-process cache)
|
|
29
31
|
|
|
30
32
|
Examples:
|
|
31
33
|
$ switchbot catalog path
|
|
34
|
+
$ switchbot catalog list
|
|
32
35
|
$ switchbot catalog show
|
|
33
36
|
$ switchbot catalog show "Smart Lock"
|
|
37
|
+
$ switchbot catalog search Hub
|
|
34
38
|
$ switchbot catalog show --source built-in
|
|
35
39
|
$ switchbot catalog diff
|
|
36
40
|
$ switchbot catalog refresh
|
|
@@ -66,7 +70,8 @@ Examples:
|
|
|
66
70
|
});
|
|
67
71
|
catalog
|
|
68
72
|
.command('show')
|
|
69
|
-
.
|
|
73
|
+
.alias('list')
|
|
74
|
+
.description("Show the effective catalog (or one entry). Alias: 'list'. Defaults to 'effective' source.")
|
|
70
75
|
.argument('[type...]', 'Optional device type/alias (case-insensitive, partial match)')
|
|
71
76
|
.option('--source <source>', 'Which catalog to show: built-in | overlay | effective (default)', enumArg('--source', SOURCES), 'effective')
|
|
72
77
|
.action((typeParts, options) => {
|
|
@@ -139,6 +144,59 @@ Examples:
|
|
|
139
144
|
handleError(error);
|
|
140
145
|
}
|
|
141
146
|
});
|
|
147
|
+
catalog
|
|
148
|
+
.command('search')
|
|
149
|
+
.description('Fuzzy search the effective catalog by type name, alias, role, or command name')
|
|
150
|
+
.argument('<keyword>', 'Substring to match (case-insensitive) against type, alias, role, or command')
|
|
151
|
+
.action((keyword) => {
|
|
152
|
+
try {
|
|
153
|
+
const q = keyword.toLowerCase();
|
|
154
|
+
const entries = getEffectiveCatalog();
|
|
155
|
+
const hits = entries.filter((e) => {
|
|
156
|
+
if (e.type.toLowerCase().includes(q))
|
|
157
|
+
return true;
|
|
158
|
+
if ((e.role ?? '').toLowerCase().includes(q))
|
|
159
|
+
return true;
|
|
160
|
+
if ((e.aliases ?? []).some((a) => a.toLowerCase().includes(q)))
|
|
161
|
+
return true;
|
|
162
|
+
if (e.commands.some((c) => c.command.toLowerCase().includes(q)))
|
|
163
|
+
return true;
|
|
164
|
+
return false;
|
|
165
|
+
});
|
|
166
|
+
if (isJsonMode()) {
|
|
167
|
+
printJson({ query: keyword, matches: hits });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (hits.length === 0) {
|
|
171
|
+
console.log(`No catalog entries match "${keyword}".`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const fmt = resolveFormat();
|
|
175
|
+
const headers = ['type', 'category', 'role', 'matched'];
|
|
176
|
+
const rows = hits.map((e) => {
|
|
177
|
+
const matched = [];
|
|
178
|
+
if (e.type.toLowerCase().includes(q))
|
|
179
|
+
matched.push('type');
|
|
180
|
+
if ((e.aliases ?? []).some((a) => a.toLowerCase().includes(q)))
|
|
181
|
+
matched.push('alias');
|
|
182
|
+
if ((e.role ?? '').toLowerCase().includes(q))
|
|
183
|
+
matched.push('role');
|
|
184
|
+
const cmdMatches = e.commands
|
|
185
|
+
.filter((c) => c.command.toLowerCase().includes(q))
|
|
186
|
+
.map((c) => c.command);
|
|
187
|
+
if (cmdMatches.length > 0)
|
|
188
|
+
matched.push(`commands[${cmdMatches.join(',')}]`);
|
|
189
|
+
return [e.type, e.category, e.role ?? '—', matched.join(', ') || '—'];
|
|
190
|
+
});
|
|
191
|
+
renderRows(headers, rows, fmt, resolveFields());
|
|
192
|
+
if (fmt === 'table') {
|
|
193
|
+
console.log(`\n${hits.length} match${hits.length === 1 ? '' : 'es'} for "${keyword}"`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
handleError(error);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
142
200
|
catalog
|
|
143
201
|
.command('diff')
|
|
144
202
|
.description('Show what the overlay replaces, adds, or removes vs the built-in catalog')
|