@switchbot/openapi-cli 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +34 -42
  2. package/dist/index.js +56945 -169
  3. package/dist/policy/schema/v0.2.json +1 -1
  4. package/package.json +3 -2
  5. package/dist/api/client.js +0 -235
  6. package/dist/auth.js +0 -20
  7. package/dist/commands/agent-bootstrap.js +0 -182
  8. package/dist/commands/auth.js +0 -354
  9. package/dist/commands/batch.js +0 -413
  10. package/dist/commands/cache.js +0 -126
  11. package/dist/commands/capabilities.js +0 -385
  12. package/dist/commands/catalog.js +0 -359
  13. package/dist/commands/completion.js +0 -385
  14. package/dist/commands/config.js +0 -376
  15. package/dist/commands/daemon.js +0 -367
  16. package/dist/commands/device-meta.js +0 -159
  17. package/dist/commands/devices.js +0 -948
  18. package/dist/commands/doctor.js +0 -1015
  19. package/dist/commands/events.js +0 -563
  20. package/dist/commands/expand.js +0 -130
  21. package/dist/commands/explain.js +0 -139
  22. package/dist/commands/health.js +0 -113
  23. package/dist/commands/history.js +0 -320
  24. package/dist/commands/identity.js +0 -59
  25. package/dist/commands/install.js +0 -246
  26. package/dist/commands/mcp.js +0 -2017
  27. package/dist/commands/plan.js +0 -653
  28. package/dist/commands/policy.js +0 -586
  29. package/dist/commands/quota.js +0 -78
  30. package/dist/commands/rules.js +0 -875
  31. package/dist/commands/scenes.js +0 -264
  32. package/dist/commands/schema.js +0 -177
  33. package/dist/commands/status-sync.js +0 -131
  34. package/dist/commands/uninstall.js +0 -237
  35. package/dist/commands/upgrade-check.js +0 -88
  36. package/dist/commands/watch.js +0 -194
  37. package/dist/commands/webhook.js +0 -182
  38. package/dist/config.js +0 -258
  39. package/dist/credentials/backends/file.js +0 -101
  40. package/dist/credentials/backends/linux.js +0 -129
  41. package/dist/credentials/backends/macos.js +0 -129
  42. package/dist/credentials/backends/windows.js +0 -215
  43. package/dist/credentials/keychain.js +0 -88
  44. package/dist/credentials/prime.js +0 -52
  45. package/dist/devices/cache.js +0 -293
  46. package/dist/devices/catalog.js +0 -767
  47. package/dist/devices/device-meta.js +0 -56
  48. package/dist/devices/history-agg.js +0 -138
  49. package/dist/devices/history-query.js +0 -181
  50. package/dist/devices/param-validator.js +0 -433
  51. package/dist/devices/resources.js +0 -270
  52. package/dist/install/default-steps.js +0 -257
  53. package/dist/install/preflight.js +0 -212
  54. package/dist/install/steps.js +0 -67
  55. package/dist/lib/command-keywords.js +0 -17
  56. package/dist/lib/daemon-state.js +0 -46
  57. package/dist/lib/destructive-mode.js +0 -12
  58. package/dist/lib/devices.js +0 -382
  59. package/dist/lib/idempotency.js +0 -106
  60. package/dist/lib/plan-store.js +0 -68
  61. package/dist/lib/request-context.js +0 -12
  62. package/dist/lib/scenes.js +0 -10
  63. package/dist/logger.js +0 -16
  64. package/dist/mcp/device-history.js +0 -145
  65. package/dist/mcp/events-subscription.js +0 -213
  66. package/dist/mqtt/client.js +0 -180
  67. package/dist/mqtt/credential.js +0 -30
  68. package/dist/policy/add-rule.js +0 -124
  69. package/dist/policy/diff.js +0 -91
  70. package/dist/policy/format.js +0 -57
  71. package/dist/policy/load.js +0 -61
  72. package/dist/policy/migrate.js +0 -67
  73. package/dist/policy/schema.js +0 -18
  74. package/dist/policy/validate.js +0 -262
  75. package/dist/rules/action.js +0 -205
  76. package/dist/rules/audit-query.js +0 -89
  77. package/dist/rules/conflict-analyzer.js +0 -203
  78. package/dist/rules/cron-scheduler.js +0 -186
  79. package/dist/rules/destructive.js +0 -52
  80. package/dist/rules/engine.js +0 -757
  81. package/dist/rules/matcher.js +0 -230
  82. package/dist/rules/pid-file.js +0 -95
  83. package/dist/rules/quiet-hours.js +0 -45
  84. package/dist/rules/suggest.js +0 -95
  85. package/dist/rules/throttle.js +0 -116
  86. package/dist/rules/types.js +0 -34
  87. package/dist/rules/webhook-listener.js +0 -223
  88. package/dist/rules/webhook-token.js +0 -90
  89. package/dist/schema/field-aliases.js +0 -131
  90. package/dist/sinks/dispatcher.js +0 -12
  91. package/dist/sinks/file.js +0 -19
  92. package/dist/sinks/format.js +0 -56
  93. package/dist/sinks/homeassistant.js +0 -44
  94. package/dist/sinks/openclaw.js +0 -33
  95. package/dist/sinks/stdout.js +0 -5
  96. package/dist/sinks/telegram.js +0 -28
  97. package/dist/sinks/types.js +0 -1
  98. package/dist/sinks/webhook.js +0 -22
  99. package/dist/status-sync/manager.js +0 -268
  100. package/dist/utils/arg-parsers.js +0 -66
  101. package/dist/utils/audit.js +0 -117
  102. package/dist/utils/filter.js +0 -189
  103. package/dist/utils/flags.js +0 -186
  104. package/dist/utils/format.js +0 -117
  105. package/dist/utils/health.js +0 -101
  106. package/dist/utils/help-json.js +0 -54
  107. package/dist/utils/name-resolver.js +0 -137
  108. package/dist/utils/output.js +0 -404
  109. package/dist/utils/quota.js +0 -227
  110. package/dist/utils/redact.js +0 -68
  111. package/dist/utils/retry.js +0 -140
  112. package/dist/utils/string.js +0 -22
  113. package/dist/version.js +0 -4
@@ -1,653 +0,0 @@
1
- import fs from 'node:fs';
2
- import readline from 'node:readline';
3
- import { randomUUID } from 'node:crypto';
4
- import { printJson, isJsonMode, handleError, exitWithError } from '../utils/output.js';
5
- import { executeCommand, isDestructiveCommand } from '../lib/devices.js';
6
- import { executeScene } from '../lib/scenes.js';
7
- import { getCachedDevice } from '../devices/cache.js';
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
- }
27
- const PLAN_JSON_SCHEMA = {
28
- $schema: 'https://json-schema.org/draft/2020-12/schema',
29
- $id: 'https://switchbot.dev/plan-1.0.json',
30
- title: 'SwitchBot Plan',
31
- description: 'Declarative batch of SwitchBot operations. Agent-authored; CLI validates and executes. No LLM inside the CLI — the schema is the contract.',
32
- type: 'object',
33
- required: ['version', 'steps'],
34
- properties: {
35
- version: { const: '1.0' },
36
- description: { type: 'string' },
37
- steps: {
38
- type: 'array',
39
- items: {
40
- oneOf: [
41
- {
42
- type: 'object',
43
- required: ['type', 'command'],
44
- oneOf: [
45
- { required: ['deviceId'], not: { required: ['deviceName'] } },
46
- { required: ['deviceName'], not: { required: ['deviceId'] } },
47
- ],
48
- properties: {
49
- type: { const: 'command' },
50
- deviceId: { type: 'string', minLength: 1 },
51
- deviceName: { type: 'string', minLength: 1 },
52
- command: { type: 'string', minLength: 1 },
53
- parameter: {},
54
- commandType: { enum: ['command', 'customize'] },
55
- note: { type: 'string' },
56
- },
57
- additionalProperties: false,
58
- },
59
- {
60
- type: 'object',
61
- required: ['type', 'sceneId'],
62
- properties: {
63
- type: { const: 'scene' },
64
- sceneId: { type: 'string', minLength: 1 },
65
- note: { type: 'string' },
66
- },
67
- additionalProperties: false,
68
- },
69
- {
70
- type: 'object',
71
- required: ['type', 'ms'],
72
- properties: {
73
- type: { const: 'wait' },
74
- ms: { type: 'integer', minimum: 0, maximum: 600000 },
75
- note: { type: 'string' },
76
- },
77
- additionalProperties: false,
78
- },
79
- ],
80
- },
81
- },
82
- },
83
- additionalProperties: false,
84
- };
85
- export function validatePlan(raw) {
86
- const issues = [];
87
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
88
- return { ok: false, issues: [{ path: '$', message: 'plan must be a JSON object' }] };
89
- }
90
- const p = raw;
91
- if (p.version !== '1.0') {
92
- issues.push({ path: 'version', message: 'must equal "1.0"' });
93
- }
94
- if (!Array.isArray(p.steps)) {
95
- issues.push({ path: 'steps', message: 'must be an array' });
96
- return { ok: false, issues };
97
- }
98
- p.steps.forEach((step, i) => {
99
- const at = `steps[${i}]`;
100
- if (!step || typeof step !== 'object') {
101
- issues.push({ path: at, message: 'must be an object' });
102
- return;
103
- }
104
- const s = step;
105
- switch (s.type) {
106
- case 'command':
107
- if (s.deviceId !== undefined && (typeof s.deviceId !== 'string' || !s.deviceId)) {
108
- issues.push({ path: `${at}.deviceId`, message: 'must be a non-empty string when provided' });
109
- }
110
- if (s.deviceName !== undefined && (typeof s.deviceName !== 'string' || !s.deviceName)) {
111
- issues.push({ path: `${at}.deviceName`, message: 'must be a non-empty string when provided' });
112
- }
113
- if (!s.deviceId && !s.deviceName) {
114
- issues.push({ path: `${at}`, message: 'must have either "deviceId" or "deviceName"' });
115
- }
116
- if (s.deviceId && s.deviceName) {
117
- issues.push({ path: `${at}`, message: '"deviceId" and "deviceName" cannot both be set' });
118
- }
119
- if (typeof s.command !== 'string' || !s.command) {
120
- issues.push({ path: `${at}.command`, message: 'must be a non-empty string' });
121
- }
122
- if (s.commandType !== undefined &&
123
- s.commandType !== 'command' &&
124
- s.commandType !== 'customize') {
125
- issues.push({
126
- path: `${at}.commandType`,
127
- message: 'must be "command" or "customize"',
128
- });
129
- }
130
- break;
131
- case 'scene':
132
- if (typeof s.sceneId !== 'string' || !s.sceneId) {
133
- issues.push({ path: `${at}.sceneId`, message: 'must be a non-empty string' });
134
- }
135
- break;
136
- case 'wait':
137
- if (typeof s.ms !== 'number' || !Number.isInteger(s.ms) || s.ms < 0 || s.ms > 600_000) {
138
- issues.push({
139
- path: `${at}.ms`,
140
- message: 'must be an integer in [0, 600000]',
141
- });
142
- }
143
- break;
144
- default:
145
- issues.push({
146
- path: `${at}.type`,
147
- message: 'must be one of "command" | "scene" | "wait"',
148
- });
149
- }
150
- });
151
- if (issues.length > 0)
152
- return { ok: false, issues };
153
- return { ok: true, plan: raw };
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
- }
175
- async function readPlanSource(file) {
176
- const text = file === undefined || file === '-'
177
- ? await readStdin()
178
- : fs.readFileSync(file, 'utf8');
179
- if (!text.trim()) {
180
- throw new Error(file === undefined || file === '-'
181
- ? 'no plan received on stdin'
182
- : `plan file is empty: ${file}`);
183
- }
184
- try {
185
- return JSON.parse(text);
186
- }
187
- catch (err) {
188
- throw new Error(`plan is not valid JSON: ${err.message}`);
189
- }
190
- }
191
- function readStdin() {
192
- return new Promise((resolve, reject) => {
193
- let buf = '';
194
- process.stdin.setEncoding('utf8');
195
- process.stdin.on('data', (chunk) => (buf += chunk));
196
- process.stdin.on('end', () => resolve(buf));
197
- process.stdin.on('error', reject);
198
- });
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
- }
305
- export function registerPlanCommand(program) {
306
- const plan = program
307
- .command('plan')
308
- .description('Author, validate, and run SwitchBot batch plans (JSON schema for AI agents)')
309
- .addHelpText('after', `
310
- A "plan" is a JSON document describing a sequence of commands/scenes/waits.
311
- The schema is fixed — agents emit plans, the CLI executes them. No LLM inside.
312
-
313
- { "version": "1.0", "description": "...", "steps": [
314
- { "type": "command", "deviceId": "...", "command": "turnOff" },
315
- { "type": "wait", "ms": 500 },
316
- { "type": "scene", "sceneId": "..." }
317
- ]}
318
-
319
- Workflow:
320
- $ switchbot plan schema > plan.schema.json # export the contract
321
- $ switchbot plan validate my-plan.json # check shape without running
322
- $ switchbot --dry-run plan run my-plan.json # preview (mutations skipped)
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>
327
- $ cat plan.json | switchbot plan run - # or stream via stdin
328
- `);
329
- plan
330
- .command('schema')
331
- .description('Print the JSON Schema for the plan format')
332
- .action(() => {
333
- printJson({
334
- ...PLAN_JSON_SCHEMA,
335
- agentNotes: {
336
- deviceNameStrategy: "Plan step `deviceName` fields are resolved with the `require-unique` strategy (same default as `devices command`). Plans that expect a specific device should pin `deviceId` instead.",
337
- },
338
- });
339
- });
340
- plan
341
- .command('validate')
342
- .description('Validate a plan file (or stdin) against the schema (structural only; does not verify device or scene existence)')
343
- .argument('[file]', 'Path to plan.json, or "-" / omit to read stdin')
344
- .addHelpText('after', `
345
- To check semantic validity (e.g., that deviceIds and sceneIds actually exist),
346
- use 'plan run --dry-run' which exercises name resolution and device lookup
347
- against the live API without executing any mutations.
348
- `)
349
- .action(async (file) => {
350
- let raw;
351
- try {
352
- raw = await readPlanSource(file);
353
- }
354
- catch (err) {
355
- handleError(err);
356
- }
357
- const result = validatePlan(raw);
358
- if (!result.ok) {
359
- if (isJsonMode()) {
360
- printJson({ valid: false, issues: result.issues });
361
- }
362
- else {
363
- console.error('✗ plan invalid:');
364
- for (const i of result.issues) {
365
- console.error(` ${i.path}: ${i.message}`);
366
- }
367
- }
368
- process.exit(2);
369
- }
370
- if (isJsonMode()) {
371
- const out = { valid: true, steps: result.plan.steps.length };
372
- if (result.plan.steps.length === 0)
373
- out.warning = 'plan has no steps — nothing will execute';
374
- printJson(out);
375
- }
376
- else {
377
- if (result.plan.steps.length === 0) {
378
- console.log('✓ plan valid — but 0 steps: nothing will execute');
379
- }
380
- else {
381
- console.log(`✓ plan valid (${result.plan.steps.length} step${result.plan.steps.length === 1 ? '' : 's'})`);
382
- }
383
- }
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
- });
416
- plan
417
- .command('run')
418
- .description('Validate + preview/execute a plan. Respects --dry-run; destructive steps require the reviewed plan flow by default')
419
- .argument('[file]', 'Path to plan.json, or "-" / omit to read stdin')
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)')
422
- .option('--continue-on-error', 'Keep running after a failed step (default: stop at first error)')
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
- }
428
- let raw;
429
- try {
430
- raw = await readPlanSource(file);
431
- }
432
- catch (err) {
433
- handleError(err);
434
- }
435
- const v = validatePlan(raw);
436
- if (!v.ok) {
437
- if (isJsonMode()) {
438
- printJson({ ran: false, issues: v.issues });
439
- }
440
- else {
441
- console.error('✗ plan invalid, refusing to run:');
442
- for (const i of v.issues)
443
- console.error(` ${i.path}: ${i.message}`);
444
- }
445
- process.exit(2);
446
- }
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,
461
- command: step.command,
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);
471
- if (isJsonMode()) {
472
- printJson({ ran: true, planId, ...out });
473
- }
474
- else {
475
- const { ok, error, skipped, total } = out.summary;
476
- console.log(`\nsummary: ok=${ok} error=${error} skipped=${skipped} total=${total}`);
477
- }
478
- }
479
- catch (err) {
480
- handleError(err);
481
- return;
482
- }
483
- if (out.summary.error > 0)
484
- process.exit(1);
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
- });
653
- }