@switchbot/openapi-cli 1.1.0 → 1.2.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.
Files changed (90) hide show
  1. package/README.md +174 -18
  2. package/dist/api/client.d.ts +7 -1
  3. package/dist/api/client.js +44 -8
  4. package/dist/api/client.js.map +1 -1
  5. package/dist/commands/batch.d.ts +2 -0
  6. package/dist/commands/batch.js +252 -0
  7. package/dist/commands/batch.js.map +1 -0
  8. package/dist/commands/cache.d.ts +2 -0
  9. package/dist/commands/cache.js +108 -0
  10. package/dist/commands/cache.js.map +1 -0
  11. package/dist/commands/capabilities.d.ts +2 -0
  12. package/dist/commands/capabilities.js +91 -0
  13. package/dist/commands/capabilities.js.map +1 -0
  14. package/dist/commands/catalog.d.ts +2 -0
  15. package/dist/commands/catalog.js +291 -0
  16. package/dist/commands/catalog.js.map +1 -0
  17. package/dist/commands/config.js +123 -10
  18. package/dist/commands/config.js.map +1 -1
  19. package/dist/commands/devices.js +234 -147
  20. package/dist/commands/devices.js.map +1 -1
  21. package/dist/commands/doctor.d.ts +2 -0
  22. package/dist/commands/doctor.js +147 -0
  23. package/dist/commands/doctor.js.map +1 -0
  24. package/dist/commands/events.d.ts +15 -0
  25. package/dist/commands/events.js +188 -0
  26. package/dist/commands/events.js.map +1 -0
  27. package/dist/commands/explain.d.ts +2 -0
  28. package/dist/commands/explain.js +137 -0
  29. package/dist/commands/explain.js.map +1 -0
  30. package/dist/commands/history.d.ts +2 -0
  31. package/dist/commands/history.js +104 -0
  32. package/dist/commands/history.js.map +1 -0
  33. package/dist/commands/mcp.d.ts +4 -0
  34. package/dist/commands/mcp.js +386 -0
  35. package/dist/commands/mcp.js.map +1 -0
  36. package/dist/commands/plan.d.ts +37 -0
  37. package/dist/commands/plan.js +344 -0
  38. package/dist/commands/plan.js.map +1 -0
  39. package/dist/commands/quota.d.ts +2 -0
  40. package/dist/commands/quota.js +77 -0
  41. package/dist/commands/quota.js.map +1 -0
  42. package/dist/commands/scenes.js +19 -13
  43. package/dist/commands/scenes.js.map +1 -1
  44. package/dist/commands/schema.d.ts +2 -0
  45. package/dist/commands/schema.js +77 -0
  46. package/dist/commands/schema.js.map +1 -0
  47. package/dist/commands/watch.d.ts +2 -0
  48. package/dist/commands/watch.js +161 -0
  49. package/dist/commands/watch.js.map +1 -0
  50. package/dist/commands/webhook.js +37 -22
  51. package/dist/commands/webhook.js.map +1 -1
  52. package/dist/config.d.ts +11 -0
  53. package/dist/config.js +32 -6
  54. package/dist/config.js.map +1 -1
  55. package/dist/devices/cache.d.ts +50 -0
  56. package/dist/devices/cache.js +152 -1
  57. package/dist/devices/cache.js.map +1 -1
  58. package/dist/devices/catalog.d.ts +49 -0
  59. package/dist/devices/catalog.js +362 -92
  60. package/dist/devices/catalog.js.map +1 -1
  61. package/dist/index.js +31 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/lib/devices.d.ts +144 -0
  64. package/dist/lib/devices.js +329 -0
  65. package/dist/lib/devices.js.map +1 -0
  66. package/dist/lib/scenes.d.ts +7 -0
  67. package/dist/lib/scenes.js +11 -0
  68. package/dist/lib/scenes.js.map +1 -0
  69. package/dist/utils/audit.d.ts +13 -0
  70. package/dist/utils/audit.js +43 -0
  71. package/dist/utils/audit.js.map +1 -0
  72. package/dist/utils/filter.d.ts +45 -0
  73. package/dist/utils/filter.js +96 -0
  74. package/dist/utils/filter.js.map +1 -0
  75. package/dist/utils/flags.d.ts +42 -0
  76. package/dist/utils/flags.js +108 -0
  77. package/dist/utils/flags.js.map +1 -1
  78. package/dist/utils/format.d.ts +9 -0
  79. package/dist/utils/format.js +109 -0
  80. package/dist/utils/format.js.map +1 -0
  81. package/dist/utils/output.d.ts +11 -0
  82. package/dist/utils/output.js +37 -6
  83. package/dist/utils/output.js.map +1 -1
  84. package/dist/utils/quota.d.ts +48 -0
  85. package/dist/utils/quota.js +144 -0
  86. package/dist/utils/quota.js.map +1 -0
  87. package/dist/utils/retry.d.ts +23 -0
  88. package/dist/utils/retry.js +60 -0
  89. package/dist/utils/retry.js.map +1 -0
  90. package/package.json +4 -1
package/README.md CHANGED
@@ -15,6 +15,20 @@ List devices, query live status, send control commands, run scenes, and manage w
15
15
 
16
16
  ---
17
17
 
18
+ ## Who is this for?
19
+
20
+ Three entry points, same binary — pick the one that matches how you use it:
21
+
22
+ | Audience | Where to start | What you get |
23
+ |-----------|---------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
24
+ | **Human** | this README ([Quick start](#quick-start)) | Colored tables, helpful hints on errors, shell completion, `switchbot doctor` self-check. |
25
+ | **Script**| [Output modes](#output-modes), [Scripting examples](#scripting-examples) | `--json`, `--format=tsv/yaml/id`, `--fields`, stable exit codes, `history replay`, audit log. |
26
+ | **Agent** | [`docs/agent-guide.md`](./docs/agent-guide.md) | `switchbot mcp serve` (stdio MCP server), `schema export`, `plan run`, destructive-command guard. |
27
+
28
+ Under the hood every surface shares the same catalog, cache, and HMAC client — switching between them costs nothing.
29
+
30
+ ---
31
+
18
32
  ## Table of contents
19
33
 
20
34
  - [Features](#features)
@@ -28,8 +42,13 @@ List devices, query live status, send control commands, run scenes, and manage w
28
42
  - [`devices`](#devices--list-status-control)
29
43
  - [`scenes`](#scenes--run-manual-scenes)
30
44
  - [`webhook`](#webhook--receive-device-events-over-http)
45
+ - [`batch`](#batch--run-multiple-commands)
46
+ - [`watch`](#watch--poll-device-status)
47
+ - [`mcp`](#mcp--model-context-protocol-server)
48
+ - [`cache`](#cache--inspect-and-clear-local-cache)
31
49
  - [`completion`](#completion--shell-tab-completion)
32
50
  - [Output modes](#output-modes)
51
+ - [Cache](#cache-1)
33
52
  - [Exit codes & error codes](#exit-codes--error-codes)
34
53
  - [Environment variables](#environment-variables)
35
54
  - [Scripting examples](#scripting-examples)
@@ -47,7 +66,7 @@ List devices, query live status, send control commands, run scenes, and manage w
47
66
  - 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting
48
67
  - 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI
49
68
  - 🔍 **Dry-run mode** — preview every mutating request before it hits the API
50
- - 🧪 **Fully tested** — 282 Vitest tests, mocked axios, zero network in CI
69
+ - 🧪 **Fully tested** — 592 Vitest tests, mocked axios, zero network in CI
51
70
  - ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell
52
71
 
53
72
  ## Requirements
@@ -119,15 +138,27 @@ switchbot config show
119
138
 
120
139
  ## Global options
121
140
 
122
- | Option | Description |
123
- | ------------------- | ------------------------------------------------------------------------ |
124
- | `--json` | Print the raw JSON response instead of a formatted table |
125
- | `-v`, `--verbose` | Log HTTP request/response details to stderr |
126
- | `--dry-run` | Print mutating requests (POST/PUT/DELETE) without sending them |
127
- | `--timeout <ms>` | HTTP request timeout in milliseconds (default: `30000`) |
128
- | `--config <path>` | Override credential file location (default: `~/.switchbot/config.json`) |
129
- | `-V`, `--version` | Print the CLI version |
130
- | `-h`, `--help` | Show help for any command or subcommand |
141
+ | Option | Description |
142
+ | --------------------------- | ------------------------------------------------------------------------ |
143
+ | `--json` | Print the raw JSON response instead of a formatted table |
144
+ | `--format <fmt>` | Output format: `tsv`, `yaml`, `jsonl`, `json`, `id` |
145
+ | `--fields <cols>` | Comma-separated column names to include (e.g. `deviceId,type`) |
146
+ | `-v`, `--verbose` | Log HTTP request/response details to stderr |
147
+ | `--dry-run` | Print mutating requests (POST/PUT/DELETE) without sending them |
148
+ | `--timeout <ms>` | HTTP request timeout in milliseconds (default: `30000`) |
149
+ | `--config <path>` | Override credential file location (default: `~/.switchbot/config.json`) |
150
+ | `--profile <name>` | Use a named credential profile (`~/.switchbot/profiles/<name>.json`) |
151
+ | `--cache <dur>` | Set list and status cache TTL, e.g. `5m`, `1h`, `off`, `auto` (default) |
152
+ | `--cache-list <dur>` | Set list-cache TTL independently (overrides `--cache`) |
153
+ | `--cache-status <dur>` | Set status-cache TTL independently (default off; overrides `--cache`) |
154
+ | `--no-cache` | Disable all cache reads for this invocation |
155
+ | `--retry-on-429 <n>` | Max 429 retry attempts (default: `3`) |
156
+ | `--no-retry` | Disable automatic 429 retries |
157
+ | `--backoff <strategy>` | Retry backoff: `exponential` (default) or `linear` |
158
+ | `--no-quota` | Disable local request-quota tracking |
159
+ | `--audit-log [path]` | Append mutating commands to a JSONL audit log (default path: `~/.switchbot/audit.log`) |
160
+ | `-V`, `--version` | Print the CLI version |
161
+ | `-h`, `--help` | Show help for any command or subcommand |
131
162
 
132
163
  Every subcommand supports `--help`, and most include a parameter-format reference and examples.
133
164
 
@@ -161,10 +192,16 @@ switchbot config show # Print current source + masked s
161
192
 
162
193
  ```bash
163
194
  # List all physical devices and IR remote devices
164
- # Columns: deviceId, deviceName, type, controlType, family, roomID, room, hub, cloud
195
+ # Default columns (4): deviceId, deviceName, type, category
196
+ # Pass --wide for the full 10-column operator view
165
197
  switchbot devices list
198
+ switchbot devices list --wide
166
199
  switchbot devices list --json | jq '.deviceList[].deviceId'
167
200
 
201
+ # IR remotes: type = remoteType (e.g. "TV"), category = "ir"
202
+ # Physical: category = "physical"
203
+ switchbot devices list --format=tsv --fields=deviceId,type,category
204
+
168
205
  # Filter by family / room (family & room info requires the 'src: OpenClaw'
169
206
  # header, which this CLI sends on every request)
170
207
  switchbot devices list --json | jq '.deviceList[] | select(.familyName == "Home")'
@@ -259,15 +296,115 @@ switchbot completion powershell >> $PROFILE
259
296
 
260
297
  Supported shells: `bash`, `zsh`, `fish`, `powershell` (`pwsh` is accepted as an alias).
261
298
 
262
- ## Output modes
299
+ ### `batch` — run multiple commands
300
+
301
+ ```bash
302
+ # Run a sequence of commands from a JSON/YAML file
303
+ switchbot batch run commands.json
304
+ switchbot batch run commands.yaml --dry-run
305
+
306
+ # Validate a plan file without executing it
307
+ switchbot batch validate commands.json
308
+ ```
309
+
310
+ A batch file is a JSON array of `{ deviceId, command, parameter?, commandType? }` objects.
311
+
312
+ ### `watch` — poll device status
313
+
314
+ ```bash
315
+ # Poll a device's status every 30 s until Ctrl-C
316
+ switchbot watch <deviceId>
317
+ switchbot watch <deviceId> --interval 10s --json
318
+ ```
319
+
320
+ Output is a stream of JSON status objects (with `--json`) or a refreshed table.
321
+
322
+ ### `mcp` — Model Context Protocol server
323
+
324
+ ```bash
325
+ # Start the stdio MCP server (connect via Claude, Cursor, etc.)
326
+ switchbot mcp serve
327
+ ```
328
+
329
+ Exposes 7 MCP tools: `list_devices`, `describe_device`, `get_device_status`, `send_command`, `list_scenes`, `run_scene`, `search_catalog`.
330
+ See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full tool reference and safety rules (destructive-command guard).
331
+
332
+ ### `cache` — inspect and clear local cache
333
+
334
+ ```bash
335
+ # Show cache status (paths, age, entry counts)
336
+ switchbot cache show
337
+
338
+ # Clear everything
339
+ switchbot cache clear
340
+
341
+ # Clear only the device-list cache or only the status cache
342
+ switchbot cache clear --key list
343
+ switchbot cache clear --key status
344
+ ```
345
+
346
+
263
347
 
264
348
  - **Default** — ANSI-colored tables for `list`/`status`, key-value tables for details.
265
- - **`--json`** — raw JSON passthrough, ideal for `jq` and scripting.
349
+ - **`--json`** — raw API payload passthrough. Output is the exact JSON the SwitchBot API returned, ideal for `jq` and scripting. Errors are also JSON on stderr: `{ "error": { "code", "kind", "message", "hint?" } }`.
350
+ - **`--format=json`** — projected row view. Same JSON structure but built from the CLI's column model (`--fields` applies). Use this when you only want specific fields.
351
+ - **`--format=tsv|yaml|jsonl|id`** — tabular text formats; `--fields` filters columns.
266
352
 
267
353
  ```bash
354
+ # Raw API payload (--json)
268
355
  switchbot devices list --json | jq '.deviceList[] | {id: .deviceId, name: .deviceName}'
356
+
357
+ # Projected rows with field filter (--format)
358
+ switchbot devices list --format tsv --fields deviceId,deviceName,type,cloud
359
+ switchbot devices list --format id # one deviceId per line
360
+ switchbot devices status <id> --format yaml
269
361
  ```
270
362
 
363
+ ## Cache
364
+
365
+ The CLI maintains two local disk caches under `~/.switchbot/`:
366
+
367
+ | File | Contents | Default TTL |
368
+ | ---- | -------- | ----------- |
369
+ | `devices.json` | Device metadata (id, name, type, category, hub, room…) | 1 hour |
370
+ | `status.json` | Per-device status bodies | off (0) |
371
+
372
+ The device-list cache powers offline validation (command name checks, destructive-command guard) and the MCP server's `send_command` tool. It is refreshed automatically on every `devices list` call.
373
+
374
+ ### Cache control flags
375
+
376
+ ```bash
377
+ # Turn off all cache reads for one invocation
378
+ switchbot devices list --no-cache
379
+
380
+ # Set both list and status TTL to 5 minutes
381
+ switchbot devices status <id> --cache 5m
382
+
383
+ # Set TTLs independently
384
+ switchbot devices status <id> --cache-list 2h --cache-status 30s
385
+
386
+ # Disable only the list cache (keep status cache at its current TTL)
387
+ switchbot devices list --cache-list 0
388
+ ```
389
+
390
+ ### Cache management commands
391
+
392
+ ```bash
393
+ # Show paths, age, and entry counts
394
+ switchbot cache show
395
+
396
+ # Clear all cached data
397
+ switchbot cache clear
398
+
399
+ # Scope the clear to one store
400
+ switchbot cache clear --key list
401
+ switchbot cache clear --key status
402
+ ```
403
+
404
+ ### Status-cache GC
405
+
406
+ `status.json` entries are automatically evicted after 24 hours (or 10× the configured status TTL, whichever is longer), so the file cannot grow without bound even when the status cache is left enabled long-term.
407
+
271
408
  ## Exit codes & error codes
272
409
 
273
410
  | Code | Meaning |
@@ -318,7 +455,7 @@ npm install
318
455
 
319
456
  npm run dev -- <args> # Run from TypeScript sources via tsx
320
457
  npm run build # Compile to dist/
321
- npm test # Run the Vitest suite (282 tests)
458
+ npm test # Run the Vitest suite (592 tests)
322
459
  npm run test:watch # Watch mode
323
460
  npm run test:coverage # Coverage report (v8, HTML + text)
324
461
  ```
@@ -332,17 +469,36 @@ src/
332
469
  ├── config.ts # Credential load/save; env > file priority; --config override
333
470
  ├── api/client.ts # axios instance + request/response interceptors;
334
471
  │ # --verbose / --dry-run / --timeout wiring
335
- ├── devices/catalog.ts # Static catalog powering `devices types`/`devices commands`
472
+ ├── devices/
473
+ │ ├── catalog.ts # Static device catalog (commands, params, status fields)
474
+ │ └── cache.ts # Disk + in-memory cache for device list and status
475
+ ├── lib/
476
+ │ └── devices.ts # Shared logic: listDevices, describeDevice, isDestructiveCommand
336
477
  ├── commands/
337
478
  │ ├── config.ts
338
479
  │ ├── devices.ts
339
480
  │ ├── scenes.ts
340
481
  │ ├── webhook.ts
482
+ │ ├── batch.ts # `switchbot batch run/validate`
483
+ │ ├── watch.ts # `switchbot watch <deviceId>`
484
+ │ ├── mcp.ts # `switchbot mcp serve` (MCP stdio server)
485
+ │ ├── cache.ts # `switchbot cache show/clear`
486
+ │ ├── history.ts # `switchbot history [replay]`
487
+ │ ├── events.ts # `switchbot events`
488
+ │ ├── quota.ts # `switchbot quota`
489
+ │ ├── explain.ts # `switchbot explain <deviceId>`
490
+ │ ├── plan.ts # `switchbot plan run <file>`
491
+ │ ├── doctor.ts # `switchbot doctor`
492
+ │ ├── schema.ts # `switchbot schema export`
493
+ │ ├── catalog.ts # `switchbot catalog search`
341
494
  │ └── completion.ts # `switchbot completion bash|zsh|fish|powershell`
342
495
  └── utils/
343
- ├── flags.ts # Global flag readers (isVerbose / isDryRun / getTimeout / getConfigPath)
344
- └── output.ts # printTable / printKeyValue / printJson / handleError
345
- tests/ # Vitest suite (282 tests, mocked axios, no network)
496
+ ├── flags.ts # Global flag readers (isVerbose / isDryRun / getCacheMode / )
497
+ ├── output.ts # printTable / printKeyValue / printJson / handleError / buildErrorPayload
498
+ ├── format.ts # renderRows / filterFields / output-format dispatch
499
+ ├── audit.ts # JSONL audit log writer
500
+ └── quota.ts # Local daily-quota counter
501
+ tests/ # Vitest suite (592 tests, mocked axios, no network)
346
502
  ```
347
503
 
348
504
  ### Release flow
@@ -6,7 +6,13 @@ export declare class DryRunSignal extends Error {
6
6
  constructor(method: string, url: string);
7
7
  }
8
8
  export declare function createClient(): AxiosInstance;
9
+ export interface ApiErrorMeta {
10
+ retryable?: boolean;
11
+ hint?: string;
12
+ }
9
13
  export declare class ApiError extends Error {
10
14
  readonly code: number;
11
- constructor(message: string, code: number);
15
+ readonly retryable: boolean;
16
+ readonly hint?: string;
17
+ constructor(message: string, code: number, meta?: ApiErrorMeta);
12
18
  }
@@ -2,7 +2,9 @@ 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 } from '../utils/flags.js';
5
+ import { isVerbose, isDryRun, getTimeout, getRetryOn429, getBackoffStrategy, isQuotaDisabled, } from '../utils/flags.js';
6
+ import { nextRetryDelayMs, sleep } from '../utils/retry.js';
7
+ import { recordRequest } from '../utils/quota.js';
6
8
  const API_ERROR_MESSAGES = {
7
9
  151: 'Device type does not support this command',
8
10
  152: 'Device ID does not exist',
@@ -26,6 +28,9 @@ export function createClient() {
26
28
  const { token, secret } = loadConfig();
27
29
  const verbose = isVerbose();
28
30
  const dryRun = isDryRun();
31
+ const maxRetries = getRetryOn429();
32
+ const backoff = getBackoffStrategy();
33
+ const quotaEnabled = !isQuotaDisabled();
29
34
  const client = axios.create({
30
35
  baseURL: 'https://api.switch-bot.com',
31
36
  timeout: getTimeout(),
@@ -43,9 +48,9 @@ export function createClient() {
43
48
  }
44
49
  }
45
50
  if (dryRun && method !== 'GET') {
46
- console.log(chalk.yellow(`[dry-run] Would ${method} ${url}`));
51
+ process.stderr.write(chalk.yellow(`[dry-run] Would ${method} ${url}\n`));
47
52
  if (config.data !== undefined) {
48
- console.log(chalk.yellow(`[dry-run] body: ${JSON.stringify(config.data)}`));
53
+ process.stderr.write(chalk.yellow(`[dry-run] body: ${JSON.stringify(config.data)}\n`));
49
54
  }
50
55
  throw new DryRunSignal(method, url);
51
56
  }
@@ -56,6 +61,11 @@ export function createClient() {
56
61
  if (verbose) {
57
62
  process.stderr.write(chalk.grey(`[verbose] ${response.status} ${response.statusText}\n`));
58
63
  }
64
+ if (quotaEnabled && response.config) {
65
+ const method = (response.config.method ?? 'get').toUpperCase();
66
+ const url = `${response.config.baseURL ?? ''}${response.config.url ?? ''}`;
67
+ recordRequest(method, url);
68
+ }
59
69
  const data = response.data;
60
70
  if (data.statusCode !== undefined && data.statusCode !== 100) {
61
71
  const msg = API_ERROR_MESSAGES[data.statusCode] ??
@@ -69,16 +79,38 @@ export function createClient() {
69
79
  throw error;
70
80
  if (axios.isAxiosError(error)) {
71
81
  if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
72
- throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0);
82
+ throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0, { retryable: false });
73
83
  }
74
84
  const status = error.response?.status;
85
+ const config = error.config;
86
+ // 429 → transparent retry with Retry-After / exponential backoff.
87
+ // Skipped when: no config (shouldn't happen for real axios errors),
88
+ // retries disabled, or we've already used our budget.
89
+ if (status === 429 && config && maxRetries > 0) {
90
+ const attempt = config.__retryCount ?? 0;
91
+ if (attempt < maxRetries) {
92
+ config.__retryCount = attempt + 1;
93
+ const delay = nextRetryDelayMs(attempt, backoff, error.response?.headers?.['retry-after']);
94
+ if (verbose) {
95
+ process.stderr.write(chalk.grey(`[verbose] 429 received — retry ${attempt + 1}/${maxRetries} in ${delay}ms\n`));
96
+ }
97
+ return sleep(delay).then(() => client.request(config));
98
+ }
99
+ }
100
+ // Record exhausted/non-retryable HTTP responses too — they count
101
+ // against the daily quota.
102
+ if (quotaEnabled && error.response && config) {
103
+ const method = (config.method ?? 'get').toUpperCase();
104
+ const url = `${config.baseURL ?? ''}${config.url ?? ''}`;
105
+ recordRequest(method, url);
106
+ }
75
107
  if (status === 401) {
76
- throw new ApiError('Authentication failed: invalid token or daily 10,000-request quota exceeded', 401);
108
+ throw new ApiError('Authentication failed: invalid token or daily 10,000-request quota exceeded', 401, { retryable: false, hint: 'Run `switchbot config set-token <token> <secret>` to re-enter credentials, or `switchbot quota status` to check today\'s local count.' });
77
109
  }
78
110
  if (status === 429) {
79
- throw new ApiError('Request rate too high: daily 10,000-request quota exceeded', 429);
111
+ throw new ApiError('Request rate too high: daily 10,000-request quota exceeded (retries exhausted)', 429, { retryable: true, hint: 'Use `switchbot quota status` to see today\'s usage; raise `--retry-on-429 <n>` for more retries.' });
80
112
  }
81
- throw new ApiError(`HTTP ${status ?? '?'}: ${error.message}`, status ?? 0);
113
+ throw new ApiError(`HTTP ${status ?? '?'}: ${error.message}`, status ?? 0, { retryable: status !== undefined && status >= 500 });
82
114
  }
83
115
  throw error;
84
116
  });
@@ -86,10 +118,14 @@ export function createClient() {
86
118
  }
87
119
  export class ApiError extends Error {
88
120
  code;
89
- constructor(message, code) {
121
+ retryable;
122
+ hint;
123
+ constructor(message, code, meta = {}) {
90
124
  super(message);
91
125
  this.code = code;
92
126
  this.name = 'ApiError';
127
+ this.retryable = meta.retryable ?? false;
128
+ this.hint = meta.hint;
93
129
  }
94
130
  }
95
131
  //# sourceMappingURL=client.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerBatchCommand(devices: Command): void;
@@ -0,0 +1,252 @@
1
+ import { printJson, isJsonMode, handleError } from '../utils/output.js';
2
+ import { fetchDeviceList, executeCommand, isDestructiveCommand, buildHubLocationMap, } from '../lib/devices.js';
3
+ import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js';
4
+ import { isDryRun } from '../utils/flags.js';
5
+ import { DryRunSignal } from '../api/client.js';
6
+ const DEFAULT_CONCURRENCY = 5;
7
+ /** Run `task(x)` for every element with at most `concurrency` running at once. */
8
+ async function runPool(items, concurrency, task) {
9
+ const results = new Array(items.length);
10
+ let cursor = 0;
11
+ const workers = [];
12
+ const width = Math.max(1, Math.min(concurrency, items.length));
13
+ for (let w = 0; w < width; w++) {
14
+ workers.push((async () => {
15
+ while (cursor < items.length) {
16
+ const idx = cursor++;
17
+ results[idx] = await task(items[idx]);
18
+ // Tiny jitter between starts so we don't hammer the endpoint in a
19
+ // perfectly aligned burst. Keeps the default concurrency=5 polite.
20
+ await new Promise((r) => setTimeout(r, 20 + Math.random() * 40));
21
+ }
22
+ })());
23
+ }
24
+ await Promise.all(workers);
25
+ return results;
26
+ }
27
+ async function resolveTargetIds(options) {
28
+ const explicit = [];
29
+ if (options.ids) {
30
+ for (const id of options.ids.split(',').map((s) => s.trim()).filter(Boolean)) {
31
+ explicit.push(id);
32
+ }
33
+ }
34
+ if (options.readStdin) {
35
+ const chunks = [];
36
+ for await (const chunk of process.stdin)
37
+ chunks.push(chunk);
38
+ const raw = Buffer.concat(chunks).toString('utf-8');
39
+ for (const line of raw.split(/\r?\n/)) {
40
+ const id = line.trim();
41
+ if (id)
42
+ explicit.push(id);
43
+ }
44
+ }
45
+ const hasFilter = Boolean(options.filter);
46
+ if (explicit.length === 0 && !hasFilter) {
47
+ throw new Error('No target devices supplied — provide --ids, --filter, or pass "-" to read deviceIds from stdin.');
48
+ }
49
+ // Always fetch the device list so we can (a) apply --filter when present
50
+ // and (b) build a deviceId → deviceType map for destructive/validation
51
+ // checks regardless of how the ids were provided.
52
+ const body = await fetchDeviceList();
53
+ const hubLoc = buildHubLocationMap(body.deviceList);
54
+ const typeMap = new Map();
55
+ for (const d of body.deviceList)
56
+ if (d.deviceType)
57
+ typeMap.set(d.deviceId, d.deviceType);
58
+ for (const ir of body.infraredRemoteList)
59
+ typeMap.set(ir.deviceId, ir.remoteType);
60
+ let ids;
61
+ if (hasFilter) {
62
+ const clauses = parseFilter(options.filter);
63
+ const matched = applyFilter(clauses, body.deviceList, body.infraredRemoteList, hubLoc);
64
+ const filteredIds = new Set(matched.map((m) => m.deviceId));
65
+ ids =
66
+ explicit.length > 0 ? explicit.filter((id) => filteredIds.has(id)) : [...filteredIds];
67
+ }
68
+ else {
69
+ ids = explicit;
70
+ }
71
+ return { ids, typeMap };
72
+ }
73
+ export function registerBatchCommand(devices) {
74
+ devices
75
+ .command('batch')
76
+ .description('Send the same command to many devices in one run (filter- or stdin-driven)')
77
+ .argument('<command>', 'Command name, e.g. turnOn, turnOff, setBrightness')
78
+ .argument('[parameter]', 'Command parameter (same rules as `devices command`; omit for no-arg)')
79
+ .option('--filter <expr>', 'Target devices matching a filter, e.g. type=Bot,family=Home')
80
+ .option('--ids <csv>', 'Explicit comma-separated list of deviceIds')
81
+ .option('--concurrency <n>', 'Max parallel in-flight requests (default 5)', '5')
82
+ .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
83
+ .option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', 'command')
84
+ .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
85
+ .addHelpText('after', `
86
+ Targets are resolved in this priority order:
87
+ 1. --ids when present (explicit deviceIds)
88
+ 2. stdin when --stdin / "-" (one deviceId per line)
89
+ 3. --filter (matches the account's device list)
90
+ You can combine explicit ids with --filter to intersect them.
91
+
92
+ Filter grammar:
93
+ key=value exact match
94
+ key~=value case-insensitive substring match
95
+ clauses are comma-separated AND
96
+
97
+ Supported keys: type, family, room, category (category: physical | ir)
98
+
99
+ Output:
100
+ Human mode: one status line per device, summary at the end.
101
+ --json: {succeeded[], failed[{deviceId,error}], summary:{total,ok,failed,skipped,durationMs}}
102
+
103
+ Safety:
104
+ Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
105
+ Keypad createKey/deleteKey) are blocked by default. Pass --yes to override.
106
+ --dry-run intercepts every POST and reports the intended calls without
107
+ hitting the API.
108
+
109
+ Examples:
110
+ $ switchbot devices batch turnOff --filter 'type~=Light,family=家里'
111
+ $ switchbot devices batch turnOn --ids ID1,ID2,ID3
112
+ $ switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle -
113
+ $ switchbot devices batch unlock --filter 'type=Smart Lock' --yes
114
+ `)
115
+ .action(async (cmd, parameter, options, commandObj) => {
116
+ // Trailing "-" sentinel selects stdin mode.
117
+ const extra = commandObj.args ?? [];
118
+ const readStdin = Boolean(options.stdin) || extra.includes('-');
119
+ let resolved;
120
+ try {
121
+ resolved = await resolveTargetIds({
122
+ filter: options.filter,
123
+ ids: options.ids,
124
+ readStdin,
125
+ });
126
+ }
127
+ catch (error) {
128
+ if (error instanceof FilterSyntaxError) {
129
+ if (isJsonMode()) {
130
+ console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: error.message } }));
131
+ }
132
+ else {
133
+ console.error(`Error: ${error.message}`);
134
+ }
135
+ process.exit(2);
136
+ }
137
+ if (error instanceof Error && error.message.startsWith('No target devices')) {
138
+ if (isJsonMode()) {
139
+ console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: error.message } }));
140
+ }
141
+ else {
142
+ console.error(`Error: ${error.message}`);
143
+ }
144
+ process.exit(2);
145
+ }
146
+ handleError(error);
147
+ }
148
+ if (resolved.ids.length === 0) {
149
+ const out = {
150
+ succeeded: [],
151
+ failed: [],
152
+ summary: { total: 0, ok: 0, failed: 0, skipped: 0, durationMs: 0 },
153
+ };
154
+ if (isJsonMode())
155
+ printJson(out);
156
+ else
157
+ console.log('No devices matched — nothing to do.');
158
+ return;
159
+ }
160
+ const effectiveType = (options.type === 'customize' ? 'customize' : 'command');
161
+ // Pre-flight: identify destructive targets before spending API calls.
162
+ const blockedForDestructive = [];
163
+ for (const id of resolved.ids) {
164
+ const t = resolved.typeMap.get(id);
165
+ if (isDestructiveCommand(t, cmd, effectiveType) && !options.yes) {
166
+ blockedForDestructive.push({
167
+ deviceId: id,
168
+ reason: `destructive command "${cmd}" on ${t ?? 'unknown'} requires --yes`,
169
+ });
170
+ }
171
+ }
172
+ if (blockedForDestructive.length > 0 && !options.yes) {
173
+ if (isJsonMode()) {
174
+ const deviceIds = blockedForDestructive.map((b) => b.deviceId);
175
+ console.error(JSON.stringify({
176
+ error: {
177
+ code: 2,
178
+ kind: 'guard',
179
+ message: `Destructive command "${cmd}" requires --yes to run on ${blockedForDestructive.length} device(s).`,
180
+ hint: 'Re-issue the call with --yes to proceed.',
181
+ context: { command: cmd, deviceIds },
182
+ },
183
+ }));
184
+ }
185
+ else {
186
+ console.error(`Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes:`);
187
+ for (const b of blockedForDestructive)
188
+ console.error(` ${b.deviceId}`);
189
+ }
190
+ process.exit(2);
191
+ }
192
+ // parameter may be a JSON object string; mirror the single-command action.
193
+ let parsedParam = parameter ?? 'default';
194
+ if (parameter) {
195
+ try {
196
+ parsedParam = JSON.parse(parameter);
197
+ }
198
+ catch {
199
+ // keep as string
200
+ }
201
+ }
202
+ const concurrency = Math.max(1, Number.parseInt(options.concurrency, 10) || DEFAULT_CONCURRENCY);
203
+ const dryRun = isDryRun();
204
+ const startedAt = Date.now();
205
+ const outcomes = await runPool(resolved.ids, concurrency, async (id) => {
206
+ try {
207
+ const result = await executeCommand(id, cmd, parsedParam, effectiveType);
208
+ if (!isJsonMode()) {
209
+ console.log(`✓ ${id}: ${cmd}`);
210
+ }
211
+ return { ok: true, deviceId: id, result };
212
+ }
213
+ catch (err) {
214
+ // --dry-run uses DryRunSignal to short-circuit; surface that as a
215
+ // "skipped" outcome, not a failure.
216
+ if (err instanceof DryRunSignal) {
217
+ return { ok: 'dry-run', deviceId: id };
218
+ }
219
+ const message = err instanceof Error ? err.message : String(err);
220
+ if (!isJsonMode()) {
221
+ console.error(`✗ ${id}: ${message}`);
222
+ }
223
+ return { ok: false, deviceId: id, error: message };
224
+ }
225
+ });
226
+ const succeeded = outcomes.filter((o) => o.ok === true);
227
+ const failed = outcomes.filter((o) => o.ok === false);
228
+ const dryRunned = outcomes.filter((o) => o.ok === 'dry-run');
229
+ const result = {
230
+ succeeded: succeeded.map((s) => ({ deviceId: s.deviceId, result: s.result })),
231
+ failed: failed.map((f) => ({ deviceId: f.deviceId, error: f.error })),
232
+ summary: {
233
+ total: resolved.ids.length,
234
+ ok: succeeded.length,
235
+ failed: failed.length,
236
+ skipped: dryRunned.length,
237
+ durationMs: Date.now() - startedAt,
238
+ ...(dryRun ? { dryRun: true } : {}),
239
+ },
240
+ };
241
+ if (isJsonMode()) {
242
+ printJson(result);
243
+ }
244
+ else {
245
+ console.log(`\nSummary: ${result.summary.ok} ok, ${result.summary.failed} failed, ${result.summary.skipped} skipped (${result.summary.durationMs}ms)`);
246
+ }
247
+ // Non-zero exit when anything failed so scripts can react.
248
+ if (failed.length > 0)
249
+ process.exit(1);
250
+ });
251
+ }
252
+ //# sourceMappingURL=batch.js.map