@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 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), and `setMode` (Relay Switch) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. 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.
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~=Light,family=Living'
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. Uses the same `--filter` expressions as `devices list`. Destructive commands (Smart Lock unlock, Garage Door Opener, etc.) require `--yes` to prevent accidents.
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=<id>`, `type=<deviceType>` (comma-separated for AND logic).
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. Designed for agents and tooling that need to discover the CLI's capabilities programmatically.
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
@@ -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
- throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0, { transient: true, retryable: false });
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,
@@ -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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: error.message } }));
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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: error.message } }));
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
- console.error(JSON.stringify({
206
- error: {
207
- code: 2,
208
- kind: 'guard',
209
- message: `Destructive command "${cmd}" requires --yes to run on ${blockedForDestructive.length} device(s).`,
210
- hint: 'Re-issue the call with --yes to proceed.',
211
- context: { command: cmd, deviceIds },
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
- deviceId: s.deviceId,
329
- result: s.result,
330
- startedAt: s.startedAt,
331
- finishedAt: s.finishedAt,
332
- durationMs: s.durationMs,
333
- replayed: s.replayed,
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,
@@ -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
- const key = options.key;
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;
@@ -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
- .description("Show the effective catalog (or one entry). Defaults to 'effective' source.")
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')