@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 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** — 1765 Vitest tests, mocked axios, zero network in CI
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
- `rules reload` to hot-reload policy without dropping listeners.
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. Run the engine. --dry-run overrides every rule into audit-only mode;
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
- # 4. Edit policy.yaml in another shell, then hot-reload without restart.
293
- switchbot rules reload # SIGHUP on Unix, sentinel file on Windows
299
+ # 5. Edit policy.yaml in another shell, then hot-reload without restart.
300
+ switchbot daemon reload # managed daemon reload
294
301
 
295
- # 5. Review recorded fires.
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
- # Run pass --yes to allow destructive steps
747
- switchbot plan run plan.json --yes
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. `--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.
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 (1765 tests)
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 (1765 tests, mocked axios, no network)
1181
+ tests/ # Vitest suite (1856 tests, mocked axios, no network)
1086
1182
  ```
1087
1183
 
1088
1184
  ### Release flow
@@ -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) {
@@ -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
- // devices: reads
40
- 'devices list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 },
41
- 'devices status': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
42
- 'devices describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 600 },
43
- 'devices types': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
44
- 'devices commands': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
45
- 'devices watch': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
46
- // devices meta (local metadata — no quota, no API call)
47
- 'devices meta set': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
48
- 'devices meta get': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
49
- 'devices meta list': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
50
- 'devices meta clear': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
51
- // devices: actions
52
- 'devices command': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 800 },
53
- 'devices batch': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1200 },
54
- // scenes
55
- 'scenes list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
56
- 'scenes execute': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1500 },
57
- 'scenes describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
58
- // webhook
59
- 'webhook setup': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 500 },
60
- 'webhook query': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
61
- 'webhook delete': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 500 },
62
- // quota
63
- 'quota status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
64
- 'quota show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
65
- 'quota reset': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 10 },
66
- // doctor / schema / capabilities / catalog / config / cache / events / history / plan
67
- 'doctor': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 900 },
68
- 'schema export': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
69
- 'capabilities': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 },
70
- 'catalog': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 15 },
71
- 'config set-token': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'destructive', verifiability: 'local', typicalLatencyMs: 5 },
72
- 'config show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
73
- 'config list-profiles': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
74
- 'cache status': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
75
- 'cache clear': { mutating: true, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 5 },
76
- 'events mqtt-tail': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
77
- 'history show': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
78
- 'history replay': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1000 },
79
- 'history range': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 50 },
80
- 'history stats': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
81
- 'history aggregate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 80 },
82
- 'plan run': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 2000 },
83
- 'plan validate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
84
- 'plan schema': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
85
- 'completion': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 5 },
86
- 'mcp serve': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
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
- out.push({ name: full, ...meta });
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,