@switchbot/openapi-cli 3.0.0 → 3.1.1

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
@@ -64,6 +64,8 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
64
64
  - [`plan`](#plan--declarative-batch-operations)
65
65
  - [`mcp`](#mcp--model-context-protocol-server)
66
66
  - [`doctor`](#doctor--self-check)
67
+ - [`health`](#health--runtime-health-report)
68
+ - [`upgrade-check`](#upgrade-check--version-check)
67
69
  - [`quota`](#quota--api-request-counter)
68
70
  - [`history`](#history--audit-log)
69
71
  - [`catalog`](#catalog--device-type-catalog)
@@ -71,6 +73,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
71
73
  - [`capabilities`](#capabilities--cli-manifest)
72
74
  - [`cache`](#cache--inspect-and-clear-local-cache)
73
75
  - [`policy`](#policy--validate-scaffold-and-migrate-policyyaml)
76
+ - [`daemon`](#daemon--background-rules-engine-process)
74
77
  - [`completion`](#completion--shell-tab-completion)
75
78
  - [Output modes](#output-modes)
76
79
  - [Cache](#cache)
@@ -78,8 +81,6 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
78
81
  - [Environment variables](#environment-variables)
79
82
  - [Scripting examples](#scripting-examples)
80
83
  - [Development](#development)
81
- - [Contributing](#contributing)
82
- - [Roadmap](#roadmap)
83
84
  - [License](#license)
84
85
  - [References](#references)
85
86
 
@@ -92,7 +93,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
92
93
  - 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting
93
94
  - 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI
94
95
  - 🔍 **Dry-run mode** — preview every mutating request before it hits the API
95
- - 🧪 **Fully tested** — 1765 Vitest tests, mocked axios, zero network in CI
96
+ - 🧪 **Fully tested** — 1882 Vitest tests, mocked axios, zero network in CI
96
97
  - ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell
97
98
 
98
99
  ## Requirements
@@ -275,7 +276,7 @@ executes for you. Supported triggers: **MQTT** (device events),
275
276
  Supported conditions: `time_between` (quiet hours) and `device_state`
276
277
  (live API check with per-tick dedup). Every fire is recorded in
277
278
  `~/.switchbot/audit.log`. `rules run` is long-running; use
278
- `rules reload` to hot-reload policy without dropping listeners.
279
+ `daemon start` / `daemon reload` for the managed background mode.
279
280
 
280
281
  ```bash
281
282
  # 1. Author rules under `automation.rules`. See examples/policies/automation.yaml
@@ -285,16 +286,37 @@ Supported conditions: `time_between` (quiet hours) and `device_state`
285
286
  switchbot rules lint # exit 0 valid, 1 error
286
287
  switchbot rules list --json | jq . # structured summary
287
288
 
288
- # 3. Run the engine. --dry-run overrides every rule into audit-only mode;
289
+ # 3. Inspect a single rule in full detail (trigger, conditions, actions,
290
+ # cooldown, hysteresis, maxFiringsPerHour, suppressIfAlreadyDesired, last fired).
291
+ switchbot rules explain "motion on"
292
+ switchbot rules explain "motion on" --json
293
+
294
+ # 4. Run the engine. --dry-run overrides every rule into audit-only mode;
289
295
  # --max-firings bounds a demo session.
290
296
  switchbot rules run --dry-run --max-firings 5
291
297
 
292
- # 4. Edit policy.yaml in another shell, then hot-reload without restart.
293
- switchbot rules reload # SIGHUP on Unix, sentinel file on Windows
298
+ # 5. Edit policy.yaml in another shell, then hot-reload without restart.
299
+ switchbot daemon reload # managed daemon reload
294
300
 
295
- # 5. Review recorded fires.
301
+ # 6. Review recorded fires.
296
302
  switchbot rules tail --follow # stream rule-* audit lines
297
303
  switchbot rules replay --since 1h --json # per-rule fires/dries/throttled/errors
304
+ switchbot rules summary # aggregate fires/errors per rule (24h window)
305
+ switchbot rules last-fired -n 20 # 20 most recent fire entries
306
+
307
+ # 7. Conflict and health analysis.
308
+ switchbot rules conflicts # opposing actions, high-frequency MQTT,
309
+ # destructive commands, quiet-hours gaps
310
+ switchbot rules doctor --json # lint + conflicts combined; exit 0 when clean
311
+ ```
312
+
313
+ When `quiet_hours` is configured in `policy.yaml`, `rules conflicts` additionally flags event-driven (MQTT / webhook) rules that lack a `time_between` condition — they would fire uninhibited during the quiet window. The hint in each finding includes a ready-to-paste `time_between` condition to add.
314
+
315
+ Webhook trigger token management:
316
+
317
+ ```bash
318
+ switchbot rules webhook-rotate-token # rotate the bearer token for webhook triggers
319
+ switchbot rules webhook-show-token # print current token (creates one if absent)
298
320
  ```
299
321
 
300
322
  See [`docs/design/phase4-rules.md`](./docs/design/phase4-rules.md) for
@@ -356,6 +378,12 @@ switchbot devices command ABC123 turnOn --dry-run
356
378
  switchbot config set-token <token> <secret> # Save to ~/.switchbot/config.json
357
379
  switchbot config show # Print current source + masked secret
358
380
  switchbot config list-profiles # List saved profiles
381
+
382
+ # Print (or write) the recommended AI-agent profile template
383
+ switchbot config agent-profile # print to stdout
384
+ switchbot config agent-profile --write # write to ~/.switchbot/profiles/agent.json (mode 0600)
385
+ switchbot config agent-profile --write --force # overwrite if it already exists
386
+ switchbot config agent-profile --json # structured JSON envelope
359
387
  ```
360
388
 
361
389
  ### `devices` — list, status, control
@@ -551,6 +579,10 @@ skipped devices appear under `summary.skipped` with `skippedReason:'offline'`.
551
579
  ```bash
552
580
  switchbot scenes list # Columns: sceneId, sceneName
553
581
  switchbot scenes execute <sceneId>
582
+
583
+ # One-shot summary: risk profile, execution hint, estimated commands
584
+ switchbot scenes explain <sceneId>
585
+ switchbot scenes explain <sceneId> --json
554
586
  ```
555
587
 
556
588
  ### `webhook` — receive device events over HTTP
@@ -710,6 +742,34 @@ switchbot events mqtt-tail --sink homeassistant --ha-url http://homeassistant.lo
710
742
 
711
743
  Device state is also persisted to `~/.switchbot/device-history/<deviceId>.json` (latest + 100-entry ring buffer) regardless of sink configuration. This enables the `get_device_history` MCP tool to answer state queries without an API call.
712
744
 
745
+ ### `daemon` — background rules-engine process
746
+
747
+ Runs `switchbot rules run` as a detached background process. Tracks runtime
748
+ metadata in `~/.switchbot/daemon.state.json` and can co-launch a health HTTP
749
+ server.
750
+
751
+ ```bash
752
+ # Start the daemon (no-op if already running)
753
+ switchbot daemon start
754
+ switchbot daemon start --policy ./my-policy.yaml
755
+ switchbot daemon start --healthz-port 3100 # also launch health serve on port 3100
756
+ switchbot daemon start --force # restart even if already running
757
+
758
+ # Inspect daemon state (pid, log path, health server, last reload)
759
+ switchbot daemon status
760
+ switchbot daemon status --json
761
+
762
+ # Hot-reload policy without restarting (sends SIGHUP on Unix, writes sentinel on Windows)
763
+ switchbot daemon reload
764
+
765
+ # Stop the daemon and any co-launched health server
766
+ switchbot daemon stop
767
+ ```
768
+
769
+ Start prints the PID, log path, and state file location. If the process exits
770
+ within 300 ms of launch, start fails immediately and includes the last 20 lines
771
+ of the log in the error message for fast diagnosis.
772
+
713
773
  ### `completion` — shell tab-completion
714
774
 
715
775
  ```bash
@@ -743,15 +803,18 @@ switchbot plan validate plan.json
743
803
  # Preview — mutations skipped, GETs still execute
744
804
  switchbot --dry-run plan run plan.json
745
805
 
746
- # Run pass --yes to allow destructive steps
747
- switchbot plan run plan.json --yes
806
+ # Save / review / approve / execute for destructive plans
807
+ switchbot plan save plan.json
808
+ switchbot plan review <planId>
809
+ switchbot plan approve <planId>
810
+ switchbot plan execute <planId>
748
811
  switchbot plan run plan.json --continue-on-error
749
812
 
750
813
  # Run with per-step TTY confirmation for destructive steps (human-in-the-loop)
751
814
  switchbot plan run plan.json --require-approval
752
815
  ```
753
816
 
754
- A plan file is a JSON document with `version`, `description`, and a `steps` array of `command`, `scene`, or `wait` steps. Steps execute sequentially; a failed step stops the run unless `--continue-on-error` is set. `--require-approval` prompts for each destructive step individually, letting you approve or reject without re-running the whole plan. See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full schema and agent integration patterns.
817
+ A plan file is a JSON document with `version`, `description`, and a `steps` array of `command`, `scene`, or `wait` steps. Steps execute sequentially; a failed step stops the run unless `--continue-on-error` is set. `plan run` is the preview/direct path, but destructive steps are blocked by default and should go through `plan save` → `plan review` → `plan approve` → `plan execute`. See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full schema and agent integration patterns.
755
818
 
756
819
  ### `devices watch` — poll status
757
820
 
@@ -792,6 +855,56 @@ switchbot doctor --json
792
855
 
793
856
  Runs local checks (Node version, credentials, profiles, catalog, cache, quota, clock, MQTT, policy, MCP) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). Use this to diagnose connectivity or config issues before running automation.
794
857
 
858
+ `--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating:
859
+
860
+ ```bash
861
+ switchbot doctor --json | jq '{score: .data.maturityScore, label: .data.maturityLabel}'
862
+ ```
863
+
864
+ Pass `--fix --yes` to auto-apply safe fixes (e.g. clear stale cache entries) without a prompt.
865
+
866
+ ### `health` — runtime health report
867
+
868
+ ```bash
869
+ # One-shot report: quota, audit error rate, circuit-breaker state
870
+ switchbot health check
871
+ switchbot health check --prometheus # Prometheus text format
872
+ switchbot health check --json
873
+
874
+ # Start a long-running HTTP server with /healthz and /metrics
875
+ switchbot health serve # default port 3100, bind 127.0.0.1
876
+ switchbot health serve --port 8080
877
+ switchbot health serve --json # print {"status":"listening",...} on start
878
+ ```
879
+
880
+ `/healthz` returns a JSON health report (HTTP 200 when `ok`/`degraded`, 503 when circuit is open).
881
+ `/metrics` returns Prometheus text metrics (`switchbot_quota_used_total`, `switchbot_circuit_open`, …).
882
+ Port conflicts are reported immediately with a clear hint to choose a different port via `--port`.
883
+
884
+ ### `upgrade-check` — version check
885
+
886
+ ```bash
887
+ switchbot upgrade-check # human output; exits 1 when update available
888
+ switchbot upgrade-check --json # structured JSON output
889
+ switchbot upgrade-check --timeout 5000 # custom registry timeout (ms)
890
+ ```
891
+
892
+ Queries the npm registry for the latest published version and compares it against the running version. When the registry's `dist-tags.latest` is itself a prerelease (e.g. `4.0.0-rc.1`), the check is skipped and the current version is treated as up-to-date — accidental prerelease tags don't trigger spurious upgrade prompts.
893
+ `--json` output:
894
+
895
+ ```json
896
+ {
897
+ "current": "3.2.1",
898
+ "latest": "4.0.0",
899
+ "upToDate": false,
900
+ "updateAvailable": true,
901
+ "breakingChange": true,
902
+ "installCommand": "npm install -g @switchbot/openapi-cli@4.0.0"
903
+ }
904
+ ```
905
+
906
+ `breakingChange` is `true` when the latest major version is higher than the current — useful for agents or CI that need to distinguish breaking upgrades from patch releases.
907
+
795
908
  ### `quota` — API request counter
796
909
 
797
910
  ```bash
@@ -876,6 +989,11 @@ switchbot policy validate --no-snippet # plain error list, no source
876
989
 
877
990
  # Report the schema version the file declares
878
991
  switchbot policy migrate
992
+
993
+ # Snapshot and restore the active policy
994
+ switchbot policy backup # write timestamped backup alongside policy file
995
+ switchbot policy backup --out ./backups/ # custom destination directory
996
+ switchbot policy restore <backup-file> # overwrite active policy from backup (auto-backups first)
879
997
  ```
880
998
 
881
999
  Path resolution order: positional `[path]` > `SWITCHBOT_POLICY_PATH` env var > default policy path.
@@ -1005,7 +1123,7 @@ npm install
1005
1123
 
1006
1124
  npm run dev -- <args> # Run from TypeScript sources via tsx
1007
1125
  npm run build # Compile to dist/
1008
- npm test # Run the Vitest suite (1765 tests)
1126
+ npm test # Run the Vitest suite (1882 tests)
1009
1127
  npm run test:watch # Watch mode
1010
1128
  npm run test:coverage # Coverage report (v8, HTML + text)
1011
1129
  ```
@@ -1045,6 +1163,8 @@ src/
1045
1163
  │ ├── webhook-listener.ts # HTTP listener (bearer token, localhost-only)
1046
1164
  │ ├── pid-file.ts # Hot-reload via SIGHUP or sentinel file
1047
1165
  │ ├── audit-query.ts # Audit log filtering + aggregation
1166
+ │ ├── conflict-analyzer.ts # Static conflict detection (opposing actions,
1167
+ │ │ # high-freq MQTT, destructive cmds, quiet-hours gaps)
1048
1168
  │ ├── suggest.ts # Heuristic-based rule YAML generation
1049
1169
  │ └── types.ts # Shared rule/trigger/condition/action types
1050
1170
  ├── status-sync/
@@ -1059,9 +1179,12 @@ src/
1059
1179
  │ ├── explain.ts # `devices explain` — one-shot device summary
1060
1180
  │ ├── device-meta.ts # `devices meta` — local aliases / hide flags
1061
1181
  │ ├── install.ts # `switchbot install` / `uninstall`
1062
- │ ├── policy.ts # `policy validate/new/migrate/diff/add-rule`
1063
- │ ├── rules.ts # `rules suggest/lint/list/run/reload/tail/replay`
1182
+ │ ├── policy.ts # `policy validate/new/migrate/diff/add-rule/backup/restore`
1183
+ │ ├── rules.ts # `rules suggest/lint/list/explain/run/reload/tail/replay/
1184
+ │ │ # conflicts/doctor/summary/last-fired/webhook-*`
1064
1185
  │ ├── scenes.ts
1186
+ │ ├── health.ts # `health check/serve` — report + HTTP endpoints
1187
+ │ ├── upgrade-check.ts # `upgrade-check` — npm registry version check
1065
1188
  │ ├── status-sync.ts # `status-sync run/start/stop/status`
1066
1189
  │ ├── webhook.ts
1067
1190
  │ ├── watch.ts # `devices watch <deviceId>`
@@ -1082,7 +1205,7 @@ src/
1082
1205
  ├── format.ts # renderRows / filterFields / output-format dispatch
1083
1206
  ├── audit.ts # JSONL audit log writer
1084
1207
  └── quota.ts # Local daily-quota counter
1085
- tests/ # Vitest suite (1765 tests, mocked axios, no network)
1208
+ tests/ # Vitest suite (1882 tests, mocked axios, no network)
1086
1209
  ```
1087
1210
 
1088
1211
  ### Release flow
@@ -1096,41 +1219,6 @@ git push --follow-tags
1096
1219
 
1097
1220
  Then on GitHub → **Releases → Draft a new release → select tag → Publish**. The `publish.yml` workflow runs tests, verifies the tag matches `package.json`, and publishes `@switchbot/openapi-cli` to npm with [provenance](https://docs.npmjs.com/generating-provenance-statements).
1098
1221
 
1099
- ## Contributing
1100
-
1101
- Bug reports, feature requests, and PRs are welcome.
1102
-
1103
- 1. Fork the repo and create a topic branch.
1104
- 2. Keep changes small and focused; add or update Vitest cases for any behavior change.
1105
- 3. Run `npm test` and `npm run build` locally — both must pass.
1106
- 4. Open a pull request against `main`. CI runs on Node 18/20/22; all three must stay green.
1107
-
1108
- ## Roadmap
1109
-
1110
- Phase 1 through Phase 4 are shipped. The authoritative phase/track table
1111
- (including skill-side `autonomyLevel` L1/L2/L3 mapping) lives in
1112
- [`docs/design/roadmap.md`](./docs/design/roadmap.md).
1113
-
1114
- Shipped tracks summary:
1115
-
1116
- - **Track β**: one-command install/uninstall surface (`switchbot install` / `switchbot uninstall`).
1117
- - **Track γ**: rules v0.2 runtime increment (`days` + `all`/`any`/`not`).
1118
- - **Track δ (L2)**: plan authoring + guarded execution (`plan suggest`, `plan run --require-approval`) and MCP review/execute tools (`plan_suggest`, `plan_run`, `audit_query`, `audit_stats`, `policy_diff`).
1119
- - **Track ζ (L3)**: autonomous rule authoring (`rules suggest`, `policy add-rule`) with MCP parity (`rules_suggest`, `policy_add_rule`).
1120
- - **Track ε**: cross-OS keychain CI matrix (macOS + Linux libsecret + Windows Credential Manager).
1121
-
1122
- Backlog tracks still open:
1123
-
1124
- 1. **Daemon mode** — long-running local process with Unix/named-pipe
1125
- transport so repeated MCP or plan invocations avoid fresh-process
1126
- startup cost.
1127
- 2. **`npx @switchbot/mcp-server`** — split the MCP server into a tiny
1128
- package so non-CLI users can run it directly with `npx`.
1129
- 3. **`switchbot self-test`** — scripted end-to-end go/no-go checks for
1130
- token/secret validity plus representative device control.
1131
- 4. **Record / replay** — capture request/response fixtures and replay
1132
- offline for deterministic integration tests and CI.
1133
-
1134
1222
  ## License
1135
1223
 
1136
1224
  [MIT](./LICENSE) © chenliuyun
@@ -3,7 +3,7 @@ import chalk from 'chalk';
3
3
  import { buildAuthHeaders } from '../auth.js';
4
4
  import { loadConfig } from '../config.js';
5
5
  import { isVerbose, isDryRun, getTimeout, getRetryOn429, getRetryOn5xx, getBackoffStrategy, isQuotaDisabled, } from '../utils/flags.js';
6
- import { nextRetryDelayMs, sleep } from '../utils/retry.js';
6
+ import { nextRetryDelayMs, sleep, CircuitBreaker, CircuitOpenError } from '../utils/retry.js';
7
7
  import { recordRequest, checkDailyCap } from '../utils/quota.js';
8
8
  import { readProfileMeta } from '../config.js';
9
9
  import { getActiveProfile } from '../lib/request-context.js';
@@ -40,6 +40,17 @@ export class DryRunSignal extends Error {
40
40
  this.name = 'DryRunSignal';
41
41
  }
42
42
  }
43
+ /**
44
+ * Module-level circuit breaker for the SwitchBot API. Shared across all
45
+ * client instances in the process. Opens after 5 consecutive 5xx / network
46
+ * errors; resets to half-open after 60 s.
47
+ * Exported for health-check inspection.
48
+ */
49
+ export const apiCircuitBreaker = new CircuitBreaker('switchbot-api', {
50
+ failureThreshold: 5,
51
+ resetTimeoutMs: 60_000,
52
+ });
53
+ export { CircuitOpenError };
43
54
  export function createClient() {
44
55
  const { token, secret } = loadConfig();
45
56
  const verbose = isVerbose();
@@ -57,6 +68,8 @@ export function createClient() {
57
68
  });
58
69
  // Inject auth headers; optionally log the request; short-circuit on --dry-run.
59
70
  client.interceptors.request.use((config) => {
71
+ // Circuit breaker check — fail fast when the API is consistently down.
72
+ apiCircuitBreaker.checkAndAllow();
60
73
  // Pre-flight cap check: refuse the call before it touches the network.
61
74
  if (dailyCap) {
62
75
  const check = checkDailyCap(dailyCap);
@@ -110,6 +123,8 @@ export function createClient() {
110
123
  `API error code: ${data.statusCode}`;
111
124
  throw new ApiError(msg, data.statusCode);
112
125
  }
126
+ // Successful HTTP response — record for circuit breaker.
127
+ apiCircuitBreaker.recordSuccess();
113
128
  return response;
114
129
  }, (error) => {
115
130
  if (error instanceof DryRunSignal)
@@ -131,6 +146,8 @@ export function createClient() {
131
146
  return sleep(delay).then(() => client.request(config));
132
147
  }
133
148
  }
149
+ // Network-level failure — record for circuit breaker.
150
+ apiCircuitBreaker.recordFailure();
134
151
  throw new ApiError(`Request timed out after ${getTimeout()}ms (override with --timeout <ms>)`, 0, { transient: true, retryable: isIdempotentRead });
135
152
  }
136
153
  const status = error.response?.status;
@@ -165,6 +182,11 @@ export function createClient() {
165
182
  return sleep(delay).then(() => client.request(config));
166
183
  }
167
184
  }
185
+ // Record 5xx and network errors for circuit breaker. 4xx errors are
186
+ // expected business responses — don't count toward circuit threshold.
187
+ if (status === undefined || status >= 500) {
188
+ apiCircuitBreaker.recordFailure();
189
+ }
168
190
  // P8: quota already recorded in the request interceptor before
169
191
  // dispatch — no extra bookkeeping needed here on the error path.
170
192
  // Timeouts, DNS failures, 5xx, and exhausted retries all counted
@@ -6,6 +6,7 @@ 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
8
  import { getCachedTypeMap, getCachedDevice, loadStatusCache } from '../devices/cache.js';
9
+ import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js';
9
10
  const DEFAULT_CONCURRENCY = 5;
10
11
  const COMMAND_TYPES = ['command', 'customize'];
11
12
  /**
@@ -98,7 +99,7 @@ export function registerBatchCommand(devices) {
98
99
  .option('--stagger <ms>', 'Fixed delay between task starts in ms (default 0 = random 20-60ms jitter)', intArg('--stagger', { min: 0 }), '0')
99
100
  .option('--plan', '[DEPRECATED, use --emit-plan] With --dry-run: emit a plan JSON document instead of executing anything')
100
101
  .option('--emit-plan', 'With --dry-run: emit a plan JSON document instead of executing anything')
101
- .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
102
+ .option('--yes', 'Allow destructive commands only from an explicit dev profile')
102
103
  .option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
103
104
  .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
104
105
  .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'))
@@ -134,7 +135,8 @@ Planning:
134
135
 
135
136
  Safety:
136
137
  Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
137
- Keypad createKey/deleteKey) are blocked by default. Pass --yes to override.
138
+ Keypad createKey/deleteKey) are blocked by default. Use the reviewed plan
139
+ flow instead of direct execution.
138
140
  --dry-run intercepts every POST and reports the intended calls without
139
141
  hitting the API.
140
142
 
@@ -142,7 +144,7 @@ Examples:
142
144
  $ switchbot devices batch turnOff --filter 'type~=Light,family=home'
143
145
  $ switchbot devices batch turnOn --ids ID1,ID2,ID3
144
146
  $ switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle -
145
- $ switchbot devices batch unlock --filter 'type=Smart Lock' --yes
147
+ $ switchbot devices batch unlock --filter 'type=Smart Lock' --dry-run --emit-plan
146
148
  `)
147
149
  .action(async (cmd, parameter, options, commandObj) => {
148
150
  // Trailing "-" sentinel selects stdin mode.
@@ -234,10 +236,24 @@ Examples:
234
236
  code: 2,
235
237
  kind: 'guard',
236
238
  message: `Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes: ${deviceIds.join(', ')}`,
237
- hint: 'Re-issue the call with --yes to proceed.',
239
+ hint: 'Re-issue the call with --yes only from an explicit dev profile, or use the reviewed plan flow.',
238
240
  context: { command: cmd, deviceIds },
239
241
  });
240
242
  }
243
+ if (blockedForDestructive.length > 0 && options.yes && !allowsDirectDestructiveExecution()) {
244
+ exitWithError({
245
+ code: 2,
246
+ kind: 'guard',
247
+ message: `Direct destructive execution is disabled for batch command "${cmd}".`,
248
+ hint: destructiveExecutionHint(),
249
+ context: {
250
+ command: cmd,
251
+ blockedCount: blockedForDestructive.length,
252
+ deviceIds: blockedForDestructive.map((item) => item.deviceId),
253
+ requiredWorkflow: 'plan-approval',
254
+ },
255
+ });
256
+ }
241
257
  // parameter may be a JSON object string; mirror the single-command action.
242
258
  let parsedParam = parameter ?? 'default';
243
259
  if (parameter) {