@switchbot/openapi-cli 2.7.2 → 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.
Files changed (69) hide show
  1. package/README.md +481 -103
  2. package/dist/api/client.js +23 -1
  3. package/dist/commands/agent-bootstrap.js +47 -2
  4. package/dist/commands/auth.js +354 -0
  5. package/dist/commands/batch.js +20 -4
  6. package/dist/commands/capabilities.js +155 -65
  7. package/dist/commands/config.js +109 -0
  8. package/dist/commands/daemon.js +367 -0
  9. package/dist/commands/devices.js +62 -11
  10. package/dist/commands/doctor.js +417 -8
  11. package/dist/commands/events.js +3 -3
  12. package/dist/commands/explain.js +1 -2
  13. package/dist/commands/health.js +113 -0
  14. package/dist/commands/install.js +246 -0
  15. package/dist/commands/mcp.js +888 -7
  16. package/dist/commands/plan.js +379 -103
  17. package/dist/commands/policy.js +586 -0
  18. package/dist/commands/rules.js +875 -0
  19. package/dist/commands/scenes.js +140 -0
  20. package/dist/commands/schema.js +0 -2
  21. package/dist/commands/status-sync.js +131 -0
  22. package/dist/commands/uninstall.js +237 -0
  23. package/dist/commands/upgrade-check.js +88 -0
  24. package/dist/config.js +14 -0
  25. package/dist/credentials/backends/file.js +101 -0
  26. package/dist/credentials/backends/linux.js +129 -0
  27. package/dist/credentials/backends/macos.js +129 -0
  28. package/dist/credentials/backends/windows.js +215 -0
  29. package/dist/credentials/keychain.js +88 -0
  30. package/dist/credentials/prime.js +52 -0
  31. package/dist/devices/catalog.js +4 -10
  32. package/dist/index.js +30 -1
  33. package/dist/install/default-steps.js +257 -0
  34. package/dist/install/preflight.js +212 -0
  35. package/dist/install/steps.js +67 -0
  36. package/dist/lib/command-keywords.js +17 -0
  37. package/dist/lib/daemon-state.js +46 -0
  38. package/dist/lib/destructive-mode.js +12 -0
  39. package/dist/lib/devices.js +1 -1
  40. package/dist/lib/plan-store.js +68 -0
  41. package/dist/policy/add-rule.js +124 -0
  42. package/dist/policy/diff.js +91 -0
  43. package/dist/policy/examples/policy.example.yaml +99 -0
  44. package/dist/policy/format.js +57 -0
  45. package/dist/policy/load.js +61 -0
  46. package/dist/policy/migrate.js +67 -0
  47. package/dist/policy/schema/v0.2.json +331 -0
  48. package/dist/policy/schema.js +18 -0
  49. package/dist/policy/validate.js +262 -0
  50. package/dist/rules/action.js +205 -0
  51. package/dist/rules/audit-query.js +89 -0
  52. package/dist/rules/conflict-analyzer.js +203 -0
  53. package/dist/rules/cron-scheduler.js +186 -0
  54. package/dist/rules/destructive.js +52 -0
  55. package/dist/rules/engine.js +757 -0
  56. package/dist/rules/matcher.js +230 -0
  57. package/dist/rules/pid-file.js +95 -0
  58. package/dist/rules/quiet-hours.js +45 -0
  59. package/dist/rules/suggest.js +95 -0
  60. package/dist/rules/throttle.js +116 -0
  61. package/dist/rules/types.js +34 -0
  62. package/dist/rules/webhook-listener.js +223 -0
  63. package/dist/rules/webhook-token.js +90 -0
  64. package/dist/status-sync/manager.js +268 -0
  65. package/dist/utils/audit.js +12 -2
  66. package/dist/utils/health.js +101 -0
  67. package/dist/utils/output.js +72 -23
  68. package/dist/utils/retry.js +81 -0
  69. package/package.json +12 -4
@@ -1,9 +1,29 @@
1
1
  import fs from 'node:fs';
2
- import { printJson, isJsonMode, handleError } from '../utils/output.js';
2
+ import readline from 'node:readline';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { printJson, isJsonMode, handleError, exitWithError } from '../utils/output.js';
3
5
  import { executeCommand, isDestructiveCommand } from '../lib/devices.js';
4
6
  import { executeScene } from '../lib/scenes.js';
5
7
  import { getCachedDevice } from '../devices/cache.js';
6
8
  import { resolveDeviceId } from '../utils/name-resolver.js';
9
+ import { COMMAND_KEYWORDS } from '../lib/command-keywords.js';
10
+ import { savePlanRecord, loadPlanRecord, updatePlanRecord, listPlanRecords, PLANS_DIR, } from '../lib/plan-store.js';
11
+ import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js';
12
+ function findDestructivePlanSteps(plan) {
13
+ const destructive = [];
14
+ for (let i = 0; i < plan.steps.length; i++) {
15
+ const step = plan.steps[i];
16
+ if (step.type !== 'command')
17
+ continue;
18
+ const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
19
+ const deviceType = getCachedDevice(resolvedDeviceId)?.type;
20
+ const commandType = step.commandType ?? 'command';
21
+ if (isDestructiveCommand(deviceType, step.command, commandType)) {
22
+ destructive.push({ index: i + 1, deviceId: resolvedDeviceId, command: step.command, commandType, deviceType: deviceType ?? null });
23
+ }
24
+ }
25
+ return destructive;
26
+ }
7
27
  const PLAN_JSON_SCHEMA = {
8
28
  $schema: 'https://json-schema.org/draft/2020-12/schema',
9
29
  $id: 'https://switchbot.dev/plan-1.0.json',
@@ -132,6 +152,26 @@ export function validatePlan(raw) {
132
152
  return { ok: false, issues };
133
153
  return { ok: true, plan: raw };
134
154
  }
155
+ export function suggestPlan(opts) {
156
+ const warnings = [];
157
+ let command = '';
158
+ for (const k of COMMAND_KEYWORDS) {
159
+ if (k.pattern.test(opts.intent)) {
160
+ command = k.command;
161
+ break;
162
+ }
163
+ }
164
+ if (!command) {
165
+ command = 'turnOn';
166
+ warnings.push(`Could not infer command from intent "${opts.intent}" — defaulted to "turnOn". Edit the generated plan to set the correct command.`);
167
+ }
168
+ const steps = opts.devices.map((d) => ({
169
+ type: 'command',
170
+ deviceId: d.id,
171
+ command,
172
+ }));
173
+ return { plan: { version: '1.0', description: opts.intent, steps }, warnings };
174
+ }
135
175
  async function readPlanSource(file) {
136
176
  const text = file === undefined || file === '-'
137
177
  ? await readStdin()
@@ -157,6 +197,111 @@ function readStdin() {
157
197
  process.stdin.on('error', reject);
158
198
  });
159
199
  }
200
+ async function promptApproval(stepIdx, command, deviceId) {
201
+ if (!process.stdin.isTTY)
202
+ return false;
203
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
204
+ return new Promise((resolve) => {
205
+ rl.question(` Approve step ${stepIdx} — ${command} on ${deviceId}? [y/N] `, (answer) => {
206
+ rl.close();
207
+ resolve(answer.trim().toLowerCase() === 'y');
208
+ });
209
+ });
210
+ }
211
+ /** Shared plan-execution core used by both `plan run` and `plan execute`. */
212
+ async function executePlanSteps(plan, planId, options) {
213
+ const out = {
214
+ plan,
215
+ results: [],
216
+ summary: { total: plan.steps.length, ok: 0, error: 0, skipped: 0 },
217
+ };
218
+ for (let i = 0; i < plan.steps.length; i++) {
219
+ const step = plan.steps[i];
220
+ const idx = i + 1;
221
+ if (step.type === 'wait') {
222
+ await new Promise((r) => setTimeout(r, step.ms));
223
+ out.results.push({ step: idx, type: 'wait', ms: step.ms, status: 'ok' });
224
+ out.summary.ok++;
225
+ if (!isJsonMode())
226
+ console.log(` ${idx}. wait ${step.ms}ms`);
227
+ continue;
228
+ }
229
+ if (step.type === 'scene') {
230
+ try {
231
+ await executeScene(step.sceneId);
232
+ out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'ok' });
233
+ out.summary.ok++;
234
+ if (!isJsonMode())
235
+ console.log(` ${idx}. ✓ scene ${step.sceneId}`);
236
+ }
237
+ catch (err) {
238
+ const msg = err instanceof Error ? err.message : String(err);
239
+ out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'error', error: msg });
240
+ out.summary.error++;
241
+ if (!isJsonMode())
242
+ console.log(` ${idx}. ✗ scene ${step.sceneId}: ${msg}`);
243
+ if (!options.continueOnError)
244
+ break;
245
+ }
246
+ continue;
247
+ }
248
+ const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
249
+ const deviceType = getCachedDevice(resolvedDeviceId)?.type;
250
+ const commandType = step.commandType ?? 'command';
251
+ const destructive = isDestructiveCommand(deviceType, step.command, commandType);
252
+ let approvalDecision;
253
+ if (destructive && !options.yes) {
254
+ if (options.requireApproval) {
255
+ const approved = await promptApproval(idx, step.command, resolvedDeviceId);
256
+ if (approved) {
257
+ approvalDecision = 'approved';
258
+ }
259
+ else {
260
+ out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'skipped', error: 'destructive — rejected at prompt', decision: 'rejected' });
261
+ out.summary.skipped++;
262
+ if (!isJsonMode())
263
+ console.log(` ${idx}. ✗ skipped ${step.command} on ${resolvedDeviceId} (rejected)`);
264
+ if (!options.continueOnError)
265
+ break;
266
+ continue;
267
+ }
268
+ }
269
+ else {
270
+ out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'skipped', error: 'destructive — rerun with --yes' });
271
+ out.summary.skipped++;
272
+ if (!isJsonMode())
273
+ console.log(` ${idx}. ⚠ skipped ${step.command} on ${resolvedDeviceId} (destructive — pass --yes)`);
274
+ if (!options.continueOnError)
275
+ break;
276
+ continue;
277
+ }
278
+ }
279
+ try {
280
+ await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType, undefined, { planId });
281
+ out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'ok', ...(approvalDecision ? { decision: approvalDecision } : {}) });
282
+ out.summary.ok++;
283
+ if (!isJsonMode())
284
+ console.log(` ${idx}. ✓ ${step.command} on ${resolvedDeviceId}`);
285
+ }
286
+ catch (err) {
287
+ if (err instanceof Error && err.name === 'DryRunSignal') {
288
+ out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'ok' });
289
+ out.summary.ok++;
290
+ if (!isJsonMode())
291
+ console.log(` ${idx}. ◦ dry-run ${step.command} on ${resolvedDeviceId}`);
292
+ continue;
293
+ }
294
+ const msg = err instanceof Error ? err.message : String(err);
295
+ out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'error', error: msg });
296
+ out.summary.error++;
297
+ if (!isJsonMode())
298
+ console.log(` ${idx}. ✗ ${step.command} on ${resolvedDeviceId}: ${msg}`);
299
+ if (!options.continueOnError)
300
+ break;
301
+ }
302
+ }
303
+ return out;
304
+ }
160
305
  export function registerPlanCommand(program) {
161
306
  const plan = program
162
307
  .command('plan')
@@ -175,7 +320,10 @@ Workflow:
175
320
  $ switchbot plan schema > plan.schema.json # export the contract
176
321
  $ switchbot plan validate my-plan.json # check shape without running
177
322
  $ switchbot --dry-run plan run my-plan.json # preview (mutations skipped)
178
- $ switchbot plan run my-plan.json --yes # execute destructive steps
323
+ $ switchbot plan save my-plan.json # store a reviewed plan
324
+ $ switchbot plan review <planId>
325
+ $ switchbot plan approve <planId>
326
+ $ switchbot plan execute <planId>
179
327
  $ cat plan.json | switchbot plan run - # or stream via stdin
180
328
  `);
181
329
  plan
@@ -234,13 +382,49 @@ against the live API without executing any mutations.
234
382
  }
235
383
  }
236
384
  });
385
+ plan
386
+ .command('suggest')
387
+ .description('Generate a candidate Plan JSON from intent + devices (heuristic, no LLM)')
388
+ .requiredOption('--intent <text>', 'Natural language description (e.g. "turn off all lights")')
389
+ .option('--device <id>', 'Device ID to include (repeatable)', (v, prev) => [...prev, v], [])
390
+ .option('--out <file>', 'Write plan JSON to file instead of stdout')
391
+ .action((opts) => {
392
+ if (opts.device.length === 0) {
393
+ console.error('error: at least one --device is required');
394
+ process.exit(1);
395
+ }
396
+ const devices = opts.device.map((ref) => {
397
+ const cached = getCachedDevice(ref);
398
+ return { id: ref, name: cached?.name, type: cached?.type };
399
+ });
400
+ const { plan: suggested, warnings } = suggestPlan({ intent: opts.intent, devices });
401
+ for (const w of warnings)
402
+ process.stderr.write(`warning: ${w}\n`);
403
+ const json = JSON.stringify(suggested, null, 2);
404
+ if (opts.out) {
405
+ fs.writeFileSync(opts.out, json + '\n', 'utf8');
406
+ if (!isJsonMode())
407
+ console.log(`✓ plan written to ${opts.out}`);
408
+ }
409
+ else if (isJsonMode()) {
410
+ printJson({ plan: suggested, warnings });
411
+ }
412
+ else {
413
+ console.log(json);
414
+ }
415
+ });
237
416
  plan
238
417
  .command('run')
239
- .description('Validate + execute a plan. Respects --dry-run; destructive steps require --yes')
418
+ .description('Validate + preview/execute a plan. Respects --dry-run; destructive steps require the reviewed plan flow by default')
240
419
  .argument('[file]', 'Path to plan.json, or "-" / omit to read stdin')
241
420
  .option('--yes', 'Authorize destructive commands (e.g. Smart Lock unlock, Garage open)')
421
+ .option('--require-approval', 'Prompt for confirmation before each destructive step (TTY only; mutually exclusive with --json)')
242
422
  .option('--continue-on-error', 'Keep running after a failed step (default: stop at first error)')
243
423
  .action(async (file, options) => {
424
+ if (options.requireApproval && isJsonMode()) {
425
+ console.error('error: --require-approval cannot be used with --json (no TTY available for prompts)');
426
+ process.exit(1);
427
+ }
244
428
  let raw;
245
429
  try {
246
430
  raw = await readPlanSource(file);
@@ -260,108 +444,32 @@ against the live API without executing any mutations.
260
444
  }
261
445
  process.exit(2);
262
446
  }
263
- const out = {
264
- plan: v.plan,
265
- results: [],
266
- summary: { total: v.plan.steps.length, ok: 0, error: 0, skipped: 0 },
267
- };
268
- try {
269
- for (let i = 0; i < v.plan.steps.length; i++) {
270
- const step = v.plan.steps[i];
271
- const idx = i + 1;
272
- if (step.type === 'wait') {
273
- await new Promise((r) => setTimeout(r, step.ms));
274
- out.results.push({ step: idx, type: 'wait', ms: step.ms, status: 'ok' });
275
- out.summary.ok++;
276
- if (!isJsonMode())
277
- console.log(` ${idx}. wait ${step.ms}ms`);
278
- continue;
279
- }
280
- if (step.type === 'scene') {
281
- try {
282
- await executeScene(step.sceneId);
283
- out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'ok' });
284
- out.summary.ok++;
285
- if (!isJsonMode())
286
- console.log(` ${idx}. ✓ scene ${step.sceneId}`);
287
- }
288
- catch (err) {
289
- const msg = err instanceof Error ? err.message : String(err);
290
- out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'error', error: msg });
291
- out.summary.error++;
292
- if (!isJsonMode())
293
- console.log(` ${idx}. ✗ scene ${step.sceneId}: ${msg}`);
294
- if (!options.continueOnError)
295
- break;
296
- }
297
- continue;
298
- }
299
- // command
300
- const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
301
- const deviceType = getCachedDevice(resolvedDeviceId)?.type;
302
- const commandType = step.commandType ?? 'command';
303
- const destructive = isDestructiveCommand(deviceType, step.command, commandType);
304
- if (destructive && !options.yes) {
305
- out.results.push({
306
- step: idx,
307
- type: 'command',
308
- deviceId: resolvedDeviceId,
309
- command: step.command,
310
- status: 'skipped',
311
- error: 'destructive — rerun with --yes',
312
- });
313
- out.summary.skipped++;
314
- if (!isJsonMode())
315
- console.log(` ${idx}. ⚠ skipped ${step.command} on ${resolvedDeviceId} (destructive — pass --yes)`);
316
- if (!options.continueOnError)
317
- break;
318
- continue;
319
- }
320
- try {
321
- await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
322
- out.results.push({
323
- step: idx,
324
- type: 'command',
325
- deviceId: resolvedDeviceId,
447
+ const planId = randomUUID();
448
+ const destructiveSteps = findDestructivePlanSteps(v.plan);
449
+ if (options.yes && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
450
+ exitWithError({
451
+ code: 2,
452
+ kind: 'guard',
453
+ message: `Direct destructive execution is disabled for plan run (${destructiveSteps.length} destructive step${destructiveSteps.length === 1 ? '' : 's'}).`,
454
+ hint: destructiveExecutionHint(),
455
+ context: {
456
+ planId,
457
+ destructiveSteps: destructiveSteps.map((step) => ({
458
+ step: step.index,
459
+ deviceId: step.deviceId,
460
+ deviceType: step.deviceType,
326
461
  command: step.command,
327
- status: 'ok',
328
- });
329
- out.summary.ok++;
330
- if (!isJsonMode())
331
- console.log(` ${idx}. ✓ ${step.command} on ${resolvedDeviceId}`);
332
- }
333
- catch (err) {
334
- if (err instanceof Error && err.name === 'DryRunSignal') {
335
- out.results.push({
336
- step: idx,
337
- type: 'command',
338
- deviceId: resolvedDeviceId,
339
- command: step.command,
340
- status: 'ok',
341
- });
342
- out.summary.ok++;
343
- if (!isJsonMode())
344
- console.log(` ${idx}. ◦ dry-run ${step.command} on ${resolvedDeviceId}`);
345
- continue;
346
- }
347
- const msg = err instanceof Error ? err.message : String(err);
348
- out.results.push({
349
- step: idx,
350
- type: 'command',
351
- deviceId: resolvedDeviceId,
352
- command: step.command,
353
- status: 'error',
354
- error: msg,
355
- });
356
- out.summary.error++;
357
- if (!isJsonMode())
358
- console.log(` ${idx}. ✗ ${step.command} on ${resolvedDeviceId}: ${msg}`);
359
- if (!options.continueOnError)
360
- break;
361
- }
362
- }
462
+ commandType: step.commandType,
463
+ })),
464
+ requiredWorkflow: 'plan-approval',
465
+ },
466
+ });
467
+ }
468
+ let out;
469
+ try {
470
+ out = await executePlanSteps(v.plan, planId, options);
363
471
  if (isJsonMode()) {
364
- printJson({ ran: true, ...out });
472
+ printJson({ ran: true, planId, ...out });
365
473
  }
366
474
  else {
367
475
  const { ok, error, skipped, total } = out.summary;
@@ -370,8 +478,176 @@ against the live API without executing any mutations.
370
478
  }
371
479
  catch (err) {
372
480
  handleError(err);
481
+ return;
373
482
  }
374
483
  if (out.summary.error > 0)
375
484
  process.exit(1);
376
485
  });
486
+ // ---- Plan resource-model subcommands (P0-3) --------------------------------
487
+ plan
488
+ .command('save')
489
+ .description('Save a plan JSON to ~/.switchbot/plans/ with status=pending (waiting for approval).')
490
+ .argument('[file]', 'Path to plan.json, or "-" / omit to read stdin')
491
+ .action(async (file) => {
492
+ let raw;
493
+ try {
494
+ raw = await readPlanSource(file);
495
+ }
496
+ catch (err) {
497
+ handleError(err);
498
+ return;
499
+ }
500
+ const v = validatePlan(raw);
501
+ if (!v.ok) {
502
+ exitWithError({
503
+ code: 2, kind: 'usage',
504
+ message: `Plan is invalid (${v.issues.length} issue${v.issues.length > 1 ? 's' : ''})`,
505
+ context: { issues: v.issues },
506
+ });
507
+ }
508
+ const record = savePlanRecord(v.plan);
509
+ if (isJsonMode()) {
510
+ printJson({ saved: true, planId: record.planId, status: record.status, createdAt: record.createdAt, plansDir: PLANS_DIR });
511
+ }
512
+ else {
513
+ console.log(`✓ Plan saved — planId: ${record.planId}`);
514
+ console.log(` Status: ${record.status}`);
515
+ console.log(` Path: ${PLANS_DIR}/${record.planId}.json`);
516
+ console.log(` Next: switchbot plan review ${record.planId}`);
517
+ console.log(` switchbot plan approve ${record.planId}`);
518
+ }
519
+ });
520
+ plan
521
+ .command('list')
522
+ .description('List saved plans in ~/.switchbot/plans/ with their approval status.')
523
+ .action(() => {
524
+ const records = listPlanRecords();
525
+ if (isJsonMode()) {
526
+ printJson({ plans: records.map((r) => ({ planId: r.planId, status: r.status, createdAt: r.createdAt, approvedAt: r.approvedAt ?? null, executedAt: r.executedAt ?? null, description: r.plan.description ?? null })) });
527
+ return;
528
+ }
529
+ if (records.length === 0) {
530
+ console.log('No saved plans. Use: switchbot plan save <file>');
531
+ return;
532
+ }
533
+ for (const r of records) {
534
+ const parts = [`${r.planId.slice(0, 8)}…`, r.status, r.createdAt.slice(0, 16)];
535
+ if (r.plan.description)
536
+ parts.push(`"${r.plan.description}"`);
537
+ console.log(parts.join(' '));
538
+ }
539
+ });
540
+ plan
541
+ .command('review')
542
+ .description('Show the details of a saved plan (steps, status, approval history).')
543
+ .argument('<planId>', 'Plan UUID from "plan list"')
544
+ .action((planId) => {
545
+ const record = loadPlanRecord(planId);
546
+ if (!record) {
547
+ exitWithError({ code: 2, kind: 'usage', message: `Plan ${planId} not found in ${PLANS_DIR}` });
548
+ }
549
+ if (isJsonMode()) {
550
+ printJson(record);
551
+ return;
552
+ }
553
+ console.log(`planId: ${record.planId}`);
554
+ console.log(`status: ${record.status}`);
555
+ console.log(`createdAt: ${record.createdAt}`);
556
+ if (record.approvedAt)
557
+ console.log(`approvedAt: ${record.approvedAt}`);
558
+ if (record.executedAt)
559
+ console.log(`executedAt: ${record.executedAt}`);
560
+ if (record.plan.description)
561
+ console.log(`description: ${record.plan.description}`);
562
+ console.log(`steps (${record.plan.steps.length}):`);
563
+ for (let i = 0; i < record.plan.steps.length; i++) {
564
+ const step = record.plan.steps[i];
565
+ if (step.type === 'command') {
566
+ const id = step.deviceId ?? step.deviceName ?? '?';
567
+ console.log(` ${i + 1}. command ${step.command} on ${id}${step.note ? ` # ${step.note}` : ''}`);
568
+ }
569
+ else if (step.type === 'scene') {
570
+ console.log(` ${i + 1}. scene ${step.sceneId}${step.note ? ` # ${step.note}` : ''}`);
571
+ }
572
+ else {
573
+ console.log(` ${i + 1}. wait ${step.ms}ms`);
574
+ }
575
+ }
576
+ });
577
+ plan
578
+ .command('approve')
579
+ .description('Approve a saved plan, allowing `plan execute` to run it.')
580
+ .argument('<planId>', 'Plan UUID from "plan list"')
581
+ .action((planId) => {
582
+ const record = loadPlanRecord(planId);
583
+ if (!record) {
584
+ exitWithError({ code: 2, kind: 'usage', message: `Plan ${planId} not found in ${PLANS_DIR}` });
585
+ }
586
+ if (record.status === 'executed') {
587
+ exitWithError({ code: 2, kind: 'guard', message: `Plan ${planId} has already been executed.` });
588
+ }
589
+ if (record.status === 'rejected') {
590
+ exitWithError({ code: 2, kind: 'guard', message: `Plan ${planId} was rejected. Save a new plan to start fresh.` });
591
+ }
592
+ // 'failed' plans may be re-approved and retried — intentionally no block here.
593
+ const updated = updatePlanRecord(planId, { status: 'approved', approvedAt: new Date().toISOString() });
594
+ if (isJsonMode()) {
595
+ printJson({ ok: true, planId: updated.planId, status: updated.status, approvedAt: updated.approvedAt });
596
+ }
597
+ else {
598
+ console.log(`✓ Plan ${planId.slice(0, 8)}… approved.`);
599
+ console.log(` Next: switchbot plan execute ${planId}`);
600
+ }
601
+ });
602
+ plan
603
+ .command('execute')
604
+ .description('Execute a pre-approved plan. Only runs if status=approved; audit entries are tagged with planId.')
605
+ .argument('<planId>', 'Plan UUID from "plan list" (must be in approved status)')
606
+ .option('--yes', 'Deprecated no-op: approved plans already authorize destructive steps')
607
+ .option('--require-approval', 'Prompt for each destructive step (TTY only)')
608
+ .option('--continue-on-error', 'Keep running after a failed step')
609
+ .action(async (planId, options) => {
610
+ if (options.requireApproval && isJsonMode()) {
611
+ exitWithError({ code: 1, kind: 'usage', message: '--require-approval cannot be used with --json' });
612
+ }
613
+ const record = loadPlanRecord(planId);
614
+ if (!record) {
615
+ exitWithError({ code: 2, kind: 'usage', message: `Plan ${planId} not found in ${PLANS_DIR}` });
616
+ }
617
+ if (record.status !== 'approved') {
618
+ exitWithError({
619
+ code: 2, kind: 'guard',
620
+ message: `Plan ${planId.slice(0, 8)}… cannot be executed: status is "${record.status}", expected "approved".`,
621
+ hint: record.status === 'pending' ? `Run: switchbot plan approve ${planId}` : record.status === 'failed' ? `Re-run: switchbot plan approve ${planId}` : undefined,
622
+ context: { planId, status: record.status },
623
+ });
624
+ }
625
+ let out;
626
+ try {
627
+ out = await executePlanSteps(record.plan, planId, { ...options, yes: true });
628
+ }
629
+ catch (err) {
630
+ handleError(err);
631
+ return;
632
+ }
633
+ const { ok, error, skipped } = out.summary;
634
+ const succeeded = error === 0 && skipped === 0;
635
+ const failureReason = succeeded ? undefined : [error > 0 ? `${error} error${error > 1 ? 's' : ''}` : null, skipped > 0 ? `${skipped} skipped` : null].filter(Boolean).join(', ');
636
+ if (succeeded) {
637
+ updatePlanRecord(planId, { status: 'executed', executedAt: new Date().toISOString() });
638
+ }
639
+ else {
640
+ updatePlanRecord(planId, { status: 'failed', failedAt: new Date().toISOString(), failureReason });
641
+ }
642
+ if (isJsonMode()) {
643
+ printJson({ ran: true, planId, succeeded, ...out });
644
+ }
645
+ else {
646
+ console.log(`\nsummary: ok=${ok} error=${error} skipped=${skipped} total=${out.summary.total}`);
647
+ if (!succeeded)
648
+ console.error(`Plan marked as failed (${failureReason}). Re-run after fixing to retry.`);
649
+ }
650
+ if (!succeeded)
651
+ process.exit(1);
652
+ });
377
653
  }