@switchbot/openapi-cli 3.0.0 → 3.1.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 +108 -12
- package/dist/api/client.js +23 -1
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +79 -0
- package/dist/commands/daemon.js +367 -0
- package/dist/commands/devices.js +62 -10
- package/dist/commands/doctor.js +233 -1
- package/dist/commands/health.js +113 -0
- package/dist/commands/mcp.js +93 -5
- package/dist/commands/plan.js +310 -130
- package/dist/commands/policy.js +120 -3
- package/dist/commands/rules.js +220 -2
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/upgrade-check.js +88 -0
- package/dist/index.js +7 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -0
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/schema/v0.2.json +29 -0
- package/dist/rules/conflict-analyzer.js +203 -0
- package/dist/rules/engine.js +195 -5
- package/dist/rules/throttle.js +42 -4
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +1 -1
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)
|
|
@@ -92,7 +94,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
|
|
|
92
94
|
- 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting
|
|
93
95
|
- 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI
|
|
94
96
|
- 🔍 **Dry-run mode** — preview every mutating request before it hits the API
|
|
95
|
-
- 🧪 **Fully tested** —
|
|
97
|
+
- 🧪 **Fully tested** — 1856 Vitest tests, mocked axios, zero network in CI
|
|
96
98
|
- ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell
|
|
97
99
|
|
|
98
100
|
## Requirements
|
|
@@ -275,7 +277,7 @@ executes for you. Supported triggers: **MQTT** (device events),
|
|
|
275
277
|
Supported conditions: `time_between` (quiet hours) and `device_state`
|
|
276
278
|
(live API check with per-tick dedup). Every fire is recorded in
|
|
277
279
|
`~/.switchbot/audit.log`. `rules run` is long-running; use
|
|
278
|
-
`
|
|
280
|
+
`daemon start` / `daemon reload` for the managed background mode.
|
|
279
281
|
|
|
280
282
|
```bash
|
|
281
283
|
# 1. Author rules under `automation.rules`. See examples/policies/automation.yaml
|
|
@@ -285,16 +287,37 @@ Supported conditions: `time_between` (quiet hours) and `device_state`
|
|
|
285
287
|
switchbot rules lint # exit 0 valid, 1 error
|
|
286
288
|
switchbot rules list --json | jq . # structured summary
|
|
287
289
|
|
|
288
|
-
# 3.
|
|
290
|
+
# 3. Inspect a single rule in full detail (trigger, conditions, actions,
|
|
291
|
+
# cooldown, hysteresis, maxFiringsPerHour, suppressIfAlreadyDesired, last fired).
|
|
292
|
+
switchbot rules explain "motion on"
|
|
293
|
+
switchbot rules explain "motion on" --json
|
|
294
|
+
|
|
295
|
+
# 4. Run the engine. --dry-run overrides every rule into audit-only mode;
|
|
289
296
|
# --max-firings bounds a demo session.
|
|
290
297
|
switchbot rules run --dry-run --max-firings 5
|
|
291
298
|
|
|
292
|
-
#
|
|
293
|
-
switchbot
|
|
299
|
+
# 5. Edit policy.yaml in another shell, then hot-reload without restart.
|
|
300
|
+
switchbot daemon reload # managed daemon reload
|
|
294
301
|
|
|
295
|
-
#
|
|
302
|
+
# 6. Review recorded fires.
|
|
296
303
|
switchbot rules tail --follow # stream rule-* audit lines
|
|
297
304
|
switchbot rules replay --since 1h --json # per-rule fires/dries/throttled/errors
|
|
305
|
+
switchbot rules summary # aggregate fires/errors per rule (24h window)
|
|
306
|
+
switchbot rules last-fired -n 20 # 20 most recent fire entries
|
|
307
|
+
|
|
308
|
+
# 7. Conflict and health analysis.
|
|
309
|
+
switchbot rules conflicts # opposing actions, high-frequency MQTT,
|
|
310
|
+
# destructive commands, quiet-hours gaps
|
|
311
|
+
switchbot rules doctor --json # lint + conflicts combined; exit 0 when clean
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
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.
|
|
315
|
+
|
|
316
|
+
Webhook trigger token management:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
switchbot rules webhook-rotate-token # rotate the bearer token for webhook triggers
|
|
320
|
+
switchbot rules webhook-show-token # print current token (creates one if absent)
|
|
298
321
|
```
|
|
299
322
|
|
|
300
323
|
See [`docs/design/phase4-rules.md`](./docs/design/phase4-rules.md) for
|
|
@@ -356,6 +379,12 @@ switchbot devices command ABC123 turnOn --dry-run
|
|
|
356
379
|
switchbot config set-token <token> <secret> # Save to ~/.switchbot/config.json
|
|
357
380
|
switchbot config show # Print current source + masked secret
|
|
358
381
|
switchbot config list-profiles # List saved profiles
|
|
382
|
+
|
|
383
|
+
# Print (or write) the recommended AI-agent profile template
|
|
384
|
+
switchbot config agent-profile # print to stdout
|
|
385
|
+
switchbot config agent-profile --write # write to ~/.switchbot/profiles/agent.json (mode 0600)
|
|
386
|
+
switchbot config agent-profile --write --force # overwrite if it already exists
|
|
387
|
+
switchbot config agent-profile --json # structured JSON envelope
|
|
359
388
|
```
|
|
360
389
|
|
|
361
390
|
### `devices` — list, status, control
|
|
@@ -551,6 +580,10 @@ skipped devices appear under `summary.skipped` with `skippedReason:'offline'`.
|
|
|
551
580
|
```bash
|
|
552
581
|
switchbot scenes list # Columns: sceneId, sceneName
|
|
553
582
|
switchbot scenes execute <sceneId>
|
|
583
|
+
|
|
584
|
+
# One-shot summary: risk profile, execution hint, estimated commands
|
|
585
|
+
switchbot scenes explain <sceneId>
|
|
586
|
+
switchbot scenes explain <sceneId> --json
|
|
554
587
|
```
|
|
555
588
|
|
|
556
589
|
### `webhook` — receive device events over HTTP
|
|
@@ -743,15 +776,18 @@ switchbot plan validate plan.json
|
|
|
743
776
|
# Preview — mutations skipped, GETs still execute
|
|
744
777
|
switchbot --dry-run plan run plan.json
|
|
745
778
|
|
|
746
|
-
#
|
|
747
|
-
switchbot plan
|
|
779
|
+
# Save / review / approve / execute for destructive plans
|
|
780
|
+
switchbot plan save plan.json
|
|
781
|
+
switchbot plan review <planId>
|
|
782
|
+
switchbot plan approve <planId>
|
|
783
|
+
switchbot plan execute <planId>
|
|
748
784
|
switchbot plan run plan.json --continue-on-error
|
|
749
785
|
|
|
750
786
|
# Run with per-step TTY confirmation for destructive steps (human-in-the-loop)
|
|
751
787
|
switchbot plan run plan.json --require-approval
|
|
752
788
|
```
|
|
753
789
|
|
|
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.
|
|
790
|
+
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
791
|
|
|
756
792
|
### `devices watch` — poll status
|
|
757
793
|
|
|
@@ -792,6 +828,56 @@ switchbot doctor --json
|
|
|
792
828
|
|
|
793
829
|
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
830
|
|
|
831
|
+
`--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating:
|
|
832
|
+
|
|
833
|
+
```bash
|
|
834
|
+
switchbot doctor --json | jq '{score: .data.maturityScore, label: .data.maturityLabel}'
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
Pass `--fix --yes` to auto-apply safe fixes (e.g. clear stale cache entries) without a prompt.
|
|
838
|
+
|
|
839
|
+
### `health` — runtime health report
|
|
840
|
+
|
|
841
|
+
```bash
|
|
842
|
+
# One-shot report: quota, audit error rate, circuit-breaker state
|
|
843
|
+
switchbot health check
|
|
844
|
+
switchbot health check --prometheus # Prometheus text format
|
|
845
|
+
switchbot health check --json
|
|
846
|
+
|
|
847
|
+
# Start a long-running HTTP server with /healthz and /metrics
|
|
848
|
+
switchbot health serve # default port 3100, bind 127.0.0.1
|
|
849
|
+
switchbot health serve --port 8080
|
|
850
|
+
switchbot health serve --json # print {"status":"listening",...} on start
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
`/healthz` returns a JSON health report (HTTP 200 when `ok`/`degraded`, 503 when circuit is open).
|
|
854
|
+
`/metrics` returns Prometheus text metrics (`switchbot_quota_used_total`, `switchbot_circuit_open`, …).
|
|
855
|
+
Port conflicts are reported immediately with a clear hint to choose a different port via `--port`.
|
|
856
|
+
|
|
857
|
+
### `upgrade-check` — version check
|
|
858
|
+
|
|
859
|
+
```bash
|
|
860
|
+
switchbot upgrade-check # human output; exits 1 when update available
|
|
861
|
+
switchbot upgrade-check --json # structured JSON output
|
|
862
|
+
switchbot upgrade-check --timeout 5000 # custom registry timeout (ms)
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
Queries the npm registry for the latest published version and compares it against the running version.
|
|
866
|
+
`--json` output:
|
|
867
|
+
|
|
868
|
+
```json
|
|
869
|
+
{
|
|
870
|
+
"current": "3.2.1",
|
|
871
|
+
"latest": "4.0.0",
|
|
872
|
+
"upToDate": false,
|
|
873
|
+
"updateAvailable": true,
|
|
874
|
+
"breakingChange": true,
|
|
875
|
+
"installCommand": "npm install -g @switchbot/openapi-cli@4.0.0"
|
|
876
|
+
}
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
`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.
|
|
880
|
+
|
|
795
881
|
### `quota` — API request counter
|
|
796
882
|
|
|
797
883
|
```bash
|
|
@@ -876,6 +962,11 @@ switchbot policy validate --no-snippet # plain error list, no source
|
|
|
876
962
|
|
|
877
963
|
# Report the schema version the file declares
|
|
878
964
|
switchbot policy migrate
|
|
965
|
+
|
|
966
|
+
# Snapshot and restore the active policy
|
|
967
|
+
switchbot policy backup # write timestamped backup alongside policy file
|
|
968
|
+
switchbot policy backup --out ./backups/ # custom destination directory
|
|
969
|
+
switchbot policy restore <backup-file> # overwrite active policy from backup (auto-backups first)
|
|
879
970
|
```
|
|
880
971
|
|
|
881
972
|
Path resolution order: positional `[path]` > `SWITCHBOT_POLICY_PATH` env var > default policy path.
|
|
@@ -1005,7 +1096,7 @@ npm install
|
|
|
1005
1096
|
|
|
1006
1097
|
npm run dev -- <args> # Run from TypeScript sources via tsx
|
|
1007
1098
|
npm run build # Compile to dist/
|
|
1008
|
-
npm test # Run the Vitest suite (
|
|
1099
|
+
npm test # Run the Vitest suite (1856 tests)
|
|
1009
1100
|
npm run test:watch # Watch mode
|
|
1010
1101
|
npm run test:coverage # Coverage report (v8, HTML + text)
|
|
1011
1102
|
```
|
|
@@ -1045,6 +1136,8 @@ src/
|
|
|
1045
1136
|
│ ├── webhook-listener.ts # HTTP listener (bearer token, localhost-only)
|
|
1046
1137
|
│ ├── pid-file.ts # Hot-reload via SIGHUP or sentinel file
|
|
1047
1138
|
│ ├── audit-query.ts # Audit log filtering + aggregation
|
|
1139
|
+
│ ├── conflict-analyzer.ts # Static conflict detection (opposing actions,
|
|
1140
|
+
│ │ # high-freq MQTT, destructive cmds, quiet-hours gaps)
|
|
1048
1141
|
│ ├── suggest.ts # Heuristic-based rule YAML generation
|
|
1049
1142
|
│ └── types.ts # Shared rule/trigger/condition/action types
|
|
1050
1143
|
├── status-sync/
|
|
@@ -1060,8 +1153,11 @@ src/
|
|
|
1060
1153
|
│ ├── device-meta.ts # `devices meta` — local aliases / hide flags
|
|
1061
1154
|
│ ├── install.ts # `switchbot install` / `uninstall`
|
|
1062
1155
|
│ ├── policy.ts # `policy validate/new/migrate/diff/add-rule`
|
|
1063
|
-
│ ├── rules.ts # `rules suggest/lint/list/run/reload/tail/replay
|
|
1156
|
+
│ ├── rules.ts # `rules suggest/lint/list/explain/run/reload/tail/replay/
|
|
1157
|
+
│ │ # conflicts/doctor/summary/last-fired/webhook-*`
|
|
1064
1158
|
│ ├── scenes.ts
|
|
1159
|
+
│ ├── health.ts # `health check/serve` — report + HTTP endpoints
|
|
1160
|
+
│ ├── upgrade-check.ts # `upgrade-check` — npm registry version check
|
|
1065
1161
|
│ ├── status-sync.ts # `status-sync run/start/stop/status`
|
|
1066
1162
|
│ ├── webhook.ts
|
|
1067
1163
|
│ ├── watch.ts # `devices watch <deviceId>`
|
|
@@ -1082,7 +1178,7 @@ src/
|
|
|
1082
1178
|
├── format.ts # renderRows / filterFields / output-format dispatch
|
|
1083
1179
|
├── audit.ts # JSONL audit log writer
|
|
1084
1180
|
└── quota.ts # Local daily-quota counter
|
|
1085
|
-
tests/ # Vitest suite (
|
|
1181
|
+
tests/ # Vitest suite (1856 tests, mocked axios, no network)
|
|
1086
1182
|
```
|
|
1087
1183
|
|
|
1088
1184
|
### Release flow
|
package/dist/api/client.js
CHANGED
|
@@ -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
|
package/dist/commands/batch.js
CHANGED
|
@@ -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
|
|
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.
|
|
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' --
|
|
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
|
|
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) {
|
|
@@ -28,6 +28,16 @@ const AGENT_GUIDE = {
|
|
|
28
28
|
action: 'Mutates device or cloud state but is reversible and routine (turnOn, setColor).',
|
|
29
29
|
destructive: 'Hard to reverse / physical-world side effects (unlock, garage open, delete key). Requires explicit user confirmation.',
|
|
30
30
|
},
|
|
31
|
+
riskLevels: {
|
|
32
|
+
low: 'Read-only or non-mutating. Safe to call autonomously.',
|
|
33
|
+
medium: 'Mutates state (action tier). Prefer `plan` workflow. Reversible.',
|
|
34
|
+
high: 'Destructive / hard-to-reverse. Must go through review-before-execute. Direct --yes execution is reserved for explicit dev profiles.',
|
|
35
|
+
},
|
|
36
|
+
recommendedModes: {
|
|
37
|
+
direct: 'May be called directly without a plan step.',
|
|
38
|
+
plan: 'Prefer batching in a plan for traceability and dry-run support.',
|
|
39
|
+
'review-before-execute': 'Must be reviewed/approved before execution. Use `plan save`, `plan review`, `plan approve`, then `plan execute`.',
|
|
40
|
+
},
|
|
31
41
|
verifiability: {
|
|
32
42
|
local: 'Result is fully verifiable from the CLI return value itself.',
|
|
33
43
|
deviceConfirmed: 'Device returns an ack with an observable state field.',
|
|
@@ -35,55 +45,128 @@ const AGENT_GUIDE = {
|
|
|
35
45
|
none: 'No feedback — e.g. IR transmission. Pair with an external sensor to confirm.',
|
|
36
46
|
},
|
|
37
47
|
};
|
|
48
|
+
function deriveRiskMeta(meta) {
|
|
49
|
+
const riskLevel = meta.agentSafetyTier === 'destructive' ? 'high'
|
|
50
|
+
: meta.agentSafetyTier === 'action' ? 'medium' : 'low';
|
|
51
|
+
return {
|
|
52
|
+
riskLevel,
|
|
53
|
+
requiresConfirmation: meta.agentSafetyTier === 'destructive',
|
|
54
|
+
supportsDryRun: meta.mutating,
|
|
55
|
+
idempotencyHint: meta.idempotencySupported ? 'safe' : meta.mutating ? 'non-idempotent' : 'safe',
|
|
56
|
+
recommendedMode: meta.agentSafetyTier === 'destructive' ? 'review-before-execute'
|
|
57
|
+
: meta.agentSafetyTier === 'action' ? 'plan' : 'direct',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function meta(mutating, consumesQuota, idempotencySupported, agentSafetyTier, verifiability, typicalLatencyMs) {
|
|
61
|
+
return { mutating, consumesQuota, idempotencySupported, agentSafetyTier, verifiability, typicalLatencyMs };
|
|
62
|
+
}
|
|
63
|
+
const READ_LOCAL = meta(false, false, false, 'read', 'local', 20);
|
|
64
|
+
const READ_REMOTE = meta(false, true, false, 'read', 'local', 500);
|
|
65
|
+
const ACTION_LOCAL = meta(true, false, false, 'action', 'local', 20);
|
|
66
|
+
const ACTION_REMOTE = meta(true, true, false, 'action', 'deviceDependent', 900);
|
|
67
|
+
const ACTION_REMOTE_IDEMPOTENT = meta(true, true, true, 'action', 'deviceDependent', 900);
|
|
68
|
+
const DESTRUCTIVE_LOCAL = meta(true, false, false, 'destructive', 'local', 20);
|
|
69
|
+
const DESTRUCTIVE_REMOTE = meta(true, true, false, 'destructive', 'deviceDependent', 1200);
|
|
70
|
+
const READ_NONE = meta(false, false, false, 'read', 'none', 50);
|
|
38
71
|
const COMMAND_META = {
|
|
39
|
-
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
'
|
|
46
|
-
|
|
47
|
-
'
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
'
|
|
51
|
-
|
|
52
|
-
'
|
|
53
|
-
'
|
|
54
|
-
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
-
'
|
|
58
|
-
|
|
59
|
-
'
|
|
60
|
-
'
|
|
61
|
-
'
|
|
62
|
-
|
|
63
|
-
'
|
|
64
|
-
'
|
|
65
|
-
'
|
|
66
|
-
|
|
67
|
-
'
|
|
68
|
-
'
|
|
69
|
-
'
|
|
70
|
-
'
|
|
71
|
-
'
|
|
72
|
-
'
|
|
73
|
-
'
|
|
74
|
-
'
|
|
75
|
-
'
|
|
76
|
-
'
|
|
77
|
-
'
|
|
78
|
-
'
|
|
79
|
-
'
|
|
80
|
-
'
|
|
81
|
-
'history
|
|
82
|
-
'
|
|
83
|
-
'
|
|
84
|
-
'
|
|
85
|
-
'
|
|
86
|
-
'
|
|
72
|
+
'agent-bootstrap': READ_LOCAL,
|
|
73
|
+
'auth keychain describe': READ_LOCAL,
|
|
74
|
+
'auth keychain get': READ_LOCAL,
|
|
75
|
+
'auth keychain set': DESTRUCTIVE_LOCAL,
|
|
76
|
+
'auth keychain delete': DESTRUCTIVE_LOCAL,
|
|
77
|
+
'auth keychain migrate': DESTRUCTIVE_LOCAL,
|
|
78
|
+
'cache show': READ_LOCAL,
|
|
79
|
+
'cache clear': ACTION_LOCAL,
|
|
80
|
+
'capabilities': READ_LOCAL,
|
|
81
|
+
'catalog path': READ_LOCAL,
|
|
82
|
+
'catalog show': READ_LOCAL,
|
|
83
|
+
'catalog search': READ_LOCAL,
|
|
84
|
+
'catalog diff': READ_LOCAL,
|
|
85
|
+
'catalog refresh': ACTION_LOCAL,
|
|
86
|
+
'completion': READ_LOCAL,
|
|
87
|
+
'config set-token': DESTRUCTIVE_LOCAL,
|
|
88
|
+
'config show': READ_LOCAL,
|
|
89
|
+
'config list-profiles': READ_LOCAL,
|
|
90
|
+
'config agent-profile': ACTION_LOCAL,
|
|
91
|
+
'daemon start': ACTION_LOCAL,
|
|
92
|
+
'daemon stop': ACTION_LOCAL,
|
|
93
|
+
'daemon status': READ_LOCAL,
|
|
94
|
+
'daemon reload': ACTION_LOCAL,
|
|
95
|
+
'devices list': READ_REMOTE,
|
|
96
|
+
'devices status': READ_REMOTE,
|
|
97
|
+
'devices command': ACTION_REMOTE_IDEMPOTENT,
|
|
98
|
+
'devices types': READ_LOCAL,
|
|
99
|
+
'devices commands': READ_LOCAL,
|
|
100
|
+
'devices describe': READ_REMOTE,
|
|
101
|
+
'devices batch': ACTION_REMOTE_IDEMPOTENT,
|
|
102
|
+
'devices watch': READ_REMOTE,
|
|
103
|
+
'devices explain': READ_LOCAL,
|
|
104
|
+
'devices expand': READ_LOCAL,
|
|
105
|
+
'devices meta set': ACTION_LOCAL,
|
|
106
|
+
'devices meta get': READ_LOCAL,
|
|
107
|
+
'devices meta list': READ_LOCAL,
|
|
108
|
+
'devices meta clear': ACTION_LOCAL,
|
|
109
|
+
'doctor': READ_LOCAL,
|
|
110
|
+
'events tail': READ_NONE,
|
|
111
|
+
'events mqtt-tail': READ_REMOTE,
|
|
112
|
+
'health check': READ_LOCAL,
|
|
113
|
+
'health serve': READ_LOCAL,
|
|
114
|
+
'history show': READ_LOCAL,
|
|
115
|
+
'history replay': ACTION_REMOTE_IDEMPOTENT,
|
|
116
|
+
'history range': READ_LOCAL,
|
|
117
|
+
'history stats': READ_LOCAL,
|
|
118
|
+
'history verify': READ_LOCAL,
|
|
119
|
+
'history aggregate': READ_LOCAL,
|
|
120
|
+
'install': ACTION_LOCAL,
|
|
121
|
+
'mcp serve': READ_LOCAL,
|
|
122
|
+
'plan schema': READ_LOCAL,
|
|
123
|
+
'plan validate': READ_LOCAL,
|
|
124
|
+
'plan suggest': READ_LOCAL,
|
|
125
|
+
'plan run': ACTION_REMOTE_IDEMPOTENT,
|
|
126
|
+
'plan save': ACTION_LOCAL,
|
|
127
|
+
'plan list': READ_LOCAL,
|
|
128
|
+
'plan review': READ_LOCAL,
|
|
129
|
+
'plan approve': DESTRUCTIVE_LOCAL,
|
|
130
|
+
'plan execute': DESTRUCTIVE_REMOTE,
|
|
131
|
+
'policy validate': READ_LOCAL,
|
|
132
|
+
'policy new': ACTION_LOCAL,
|
|
133
|
+
'policy migrate': ACTION_LOCAL,
|
|
134
|
+
'policy diff': READ_LOCAL,
|
|
135
|
+
'policy add-rule': ACTION_LOCAL,
|
|
136
|
+
'policy backup': READ_LOCAL,
|
|
137
|
+
'policy restore': DESTRUCTIVE_LOCAL,
|
|
138
|
+
'quota status': READ_LOCAL,
|
|
139
|
+
'quota reset': ACTION_LOCAL,
|
|
140
|
+
'rules suggest': READ_LOCAL,
|
|
141
|
+
'rules lint': READ_LOCAL,
|
|
142
|
+
'rules list': READ_LOCAL,
|
|
143
|
+
'rules run': ACTION_REMOTE,
|
|
144
|
+
'rules reload': ACTION_LOCAL,
|
|
145
|
+
'rules tail': READ_LOCAL,
|
|
146
|
+
'rules replay': READ_LOCAL,
|
|
147
|
+
'rules webhook-rotate-token': DESTRUCTIVE_LOCAL,
|
|
148
|
+
'rules webhook-show-token': DESTRUCTIVE_LOCAL,
|
|
149
|
+
'rules conflicts': READ_LOCAL,
|
|
150
|
+
'rules doctor': READ_LOCAL,
|
|
151
|
+
'rules summary': READ_LOCAL,
|
|
152
|
+
'rules last-fired': READ_LOCAL,
|
|
153
|
+
'schema export': READ_LOCAL,
|
|
154
|
+
'scenes list': READ_REMOTE,
|
|
155
|
+
'scenes execute': ACTION_REMOTE,
|
|
156
|
+
'scenes describe': READ_REMOTE,
|
|
157
|
+
'scenes validate': READ_REMOTE,
|
|
158
|
+
'scenes simulate': READ_REMOTE,
|
|
159
|
+
'scenes explain': READ_REMOTE,
|
|
160
|
+
'status-sync run': ACTION_REMOTE,
|
|
161
|
+
'status-sync start': ACTION_LOCAL,
|
|
162
|
+
'status-sync stop': ACTION_LOCAL,
|
|
163
|
+
'status-sync status': READ_LOCAL,
|
|
164
|
+
'uninstall': ACTION_LOCAL,
|
|
165
|
+
'upgrade-check': READ_REMOTE,
|
|
166
|
+
'webhook setup': ACTION_REMOTE,
|
|
167
|
+
'webhook query': READ_REMOTE,
|
|
168
|
+
'webhook update': ACTION_REMOTE,
|
|
169
|
+
'webhook delete': DESTRUCTIVE_REMOTE,
|
|
87
170
|
};
|
|
88
171
|
function metaFor(command) {
|
|
89
172
|
return COMMAND_META[command] ?? null;
|
|
@@ -110,27 +193,30 @@ const IDEMPOTENCY_CONTRACT = {
|
|
|
110
193
|
scope: 'Process-local. Replay + conflict apply within a single long-lived process (MCP session, devices batch, plan run, history replay). Independent CLI invocations do NOT share cache — each fresh `node` process starts empty.',
|
|
111
194
|
mcp: 'MCP send_command accepts the same idempotencyKey field with identical semantics.',
|
|
112
195
|
};
|
|
196
|
+
function enumerateLeafNames(program, prefix = '') {
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const cmd of program.commands) {
|
|
199
|
+
const full = prefix ? `${prefix} ${cmd.name()}` : cmd.name();
|
|
200
|
+
if (cmd.commands.length === 0)
|
|
201
|
+
out.push(full);
|
|
202
|
+
else
|
|
203
|
+
out.push(...enumerateLeafNames(cmd, full));
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
function validateCommandMetaCoverage(program) {
|
|
208
|
+
const leaves = enumerateLeafNames(program);
|
|
209
|
+
return leaves.filter((leaf) => !COMMAND_META[leaf]).sort().map((leaf) => `missing:${leaf}`);
|
|
210
|
+
}
|
|
113
211
|
function enumerateLeaves(program, prefix = '') {
|
|
114
212
|
const out = [];
|
|
115
213
|
for (const cmd of program.commands) {
|
|
116
214
|
const full = prefix ? `${prefix} ${cmd.name()}` : cmd.name();
|
|
117
215
|
if (cmd.commands.length === 0) {
|
|
118
216
|
const meta = metaFor(full);
|
|
119
|
-
if (meta)
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
// Unknown leaf → default to read-safe with a warning flag so agents notice.
|
|
124
|
-
out.push({
|
|
125
|
-
name: full,
|
|
126
|
-
mutating: false,
|
|
127
|
-
consumesQuota: false,
|
|
128
|
-
idempotencySupported: false,
|
|
129
|
-
agentSafetyTier: 'read',
|
|
130
|
-
verifiability: 'local',
|
|
131
|
-
typicalLatencyMs: 50,
|
|
132
|
-
});
|
|
133
|
-
}
|
|
217
|
+
if (!meta)
|
|
218
|
+
throw new Error(`capabilities metadata missing for leaf command "${full}"`);
|
|
219
|
+
out.push({ name: full, ...meta, ...deriveRiskMeta(meta) });
|
|
134
220
|
}
|
|
135
221
|
else {
|
|
136
222
|
out.push(...enumerateLeaves(cmd, full));
|
|
@@ -157,6 +243,10 @@ export function registerCapabilitiesCommand(program) {
|
|
|
157
243
|
.option('--surface <s>', 'Restrict surfaces block to one of: cli, mcp, plan, mqtt, all (default: all)', enumArg('--surface', SURFACES))
|
|
158
244
|
.option('--project <csv>', 'Project top-level fields (e.g. --project identity,commands,agentGuide)', stringArg('--project'))
|
|
159
245
|
.action((opts) => {
|
|
246
|
+
const coverageIssues = validateCommandMetaCoverage(program);
|
|
247
|
+
if (coverageIssues.length > 0) {
|
|
248
|
+
throw new Error(`capabilities metadata coverage error: ${coverageIssues.join(', ')}`);
|
|
249
|
+
}
|
|
160
250
|
const compact = Boolean(opts.minimal || opts.compact);
|
|
161
251
|
const catalog = getEffectiveCatalog();
|
|
162
252
|
const leaves = enumerateLeaves(program);
|
|
@@ -246,8 +336,8 @@ export function registerCapabilitiesCommand(program) {
|
|
|
246
336
|
// Flat command → meta map keyed by full command path. Published in
|
|
247
337
|
// addition to the tree (where every leaf `subcommands[*]` already
|
|
248
338
|
// carries the same fields via spread) so agents can do O(1) lookup
|
|
249
|
-
// without walking the tree.
|
|
250
|
-
commandMeta: COMMAND_META,
|
|
339
|
+
// without walking the tree. Includes derived risk metadata fields.
|
|
340
|
+
commandMeta: Object.fromEntries(Object.entries(COMMAND_META).map(([k, v]) => [k, { ...v, ...deriveRiskMeta(v) }])),
|
|
251
341
|
...(globalFlags ? { globalFlags } : {}),
|
|
252
342
|
catalog: {
|
|
253
343
|
typeCount: catalog.length,
|