@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.
@@ -1,4 +1,6 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
2
4
  import readline from 'node:readline';
3
5
  import { execFileSync } from 'node:child_process';
4
6
  import { stringArg } from '../utils/arg-parsers.js';
@@ -251,6 +253,24 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
251
253
  }
252
254
  else {
253
255
  console.log(chalk.green('✓ Credentials saved'));
256
+ // Keychain-first hint: proactively suggest storing in OS keychain when
257
+ // the user is on a supported platform and saved to file (default path).
258
+ try {
259
+ const { selectCredentialStore } = await import('../credentials/keychain.js');
260
+ const store = await selectCredentialStore();
261
+ if (store.describe().backend === 'file') {
262
+ const platform = process.platform;
263
+ if (platform === 'darwin' || platform === 'win32') {
264
+ console.error(chalk.grey('Tip: Your OS supports a native keychain. Run `switchbot auth keychain store` to move credentials off the plain config file for better security.'));
265
+ }
266
+ else if (platform === 'linux') {
267
+ console.error(chalk.grey('Tip: If you have GNOME Keyring (secret-tool) installed, run `switchbot auth keychain store` to store credentials more securely.'));
268
+ }
269
+ }
270
+ }
271
+ catch {
272
+ // Keychain probe failed — silently skip the tip
273
+ }
254
274
  }
255
275
  });
256
276
  config
@@ -294,4 +314,63 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
294
314
  console.log(bits.join(' '));
295
315
  }
296
316
  });
317
+ // switchbot config agent-profile [--write]
318
+ config
319
+ .command('agent-profile')
320
+ .description('Emit (or write) an agent-safe profile template with conservative rate limits and audit logging.')
321
+ .option('--write', 'Write the template to ~/.switchbot/profiles/agent.json (requires --profile agent config set-token to add credentials).')
322
+ .option('--force', 'Overwrite an existing agent.json when used with --write.')
323
+ .addHelpText('after', `
324
+ Outputs a starter profile.json suitable for AI agent / MCP integration:
325
+ - dailyCap: 100 (conservative; prevents runaway automation)
326
+ - label: "agent"
327
+ - description: "AI agent profile — conservative limits + audit enabled"
328
+ - defaults: { auditLog: true } (enables audit logging by default)
329
+
330
+ After writing, add credentials:
331
+ $ switchbot --profile agent config set-token <token> <secret>
332
+
333
+ Then use the profile:
334
+ $ switchbot --profile agent devices list
335
+ `)
336
+ .action((opts) => {
337
+ const template = {
338
+ label: 'agent',
339
+ description: 'AI agent profile — conservative limits + audit enabled',
340
+ limits: {
341
+ dailyCap: 100,
342
+ },
343
+ defaults: {
344
+ auditLog: true,
345
+ },
346
+ };
347
+ if (opts.write) {
348
+ const dir = path.join(os.homedir(), '.switchbot', 'profiles');
349
+ const dest = path.join(dir, 'agent.json');
350
+ if (!opts.force && fs.existsSync(dest)) {
351
+ exitWithError({ code: 2, kind: 'usage', message: `Agent profile already exists: ${dest}. Use --force to overwrite.` });
352
+ }
353
+ if (!fs.existsSync(dir)) {
354
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
355
+ }
356
+ fs.writeFileSync(dest, JSON.stringify(template, null, 2), { mode: 0o600 });
357
+ if (isJsonMode()) {
358
+ printJson({ ok: true, path: dest, template });
359
+ }
360
+ else {
361
+ console.log(`Agent profile written: ${dest}`);
362
+ console.log(`Next: switchbot --profile agent config set-token <token> <secret>`);
363
+ }
364
+ }
365
+ else {
366
+ if (isJsonMode()) {
367
+ printJson(template);
368
+ }
369
+ else {
370
+ console.log(JSON.stringify(template, null, 2));
371
+ console.log('');
372
+ console.log('Write with: switchbot config agent-profile --write');
373
+ }
374
+ }
375
+ });
297
376
  }
@@ -0,0 +1,367 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { isJsonMode, printJson, exitWithError } from '../utils/output.js';
6
+ import { readPidFile, writePidFile, isPidAlive, getDefaultPidFilePaths, writeReloadSentinel, sighupSupported } from '../rules/pid-file.js';
7
+ import { stringArg } from '../utils/arg-parsers.js';
8
+ import chalk from 'chalk';
9
+ import { DAEMON_LOG_FILE, DAEMON_PID_FILE, DAEMON_STATE_FILE, HEALTHZ_PID_FILE, readDaemonState, writeDaemonState, } from '../lib/daemon-state.js';
10
+ function readDaemonPid() {
11
+ return readPidFile(DAEMON_PID_FILE);
12
+ }
13
+ function readHealthPid() {
14
+ return readPidFile(HEALTHZ_PID_FILE);
15
+ }
16
+ function killIfAlive(pid, signal = 'SIGTERM') {
17
+ if (!pid)
18
+ return;
19
+ if (!isPidAlive(pid))
20
+ return;
21
+ process.kill(pid, signal);
22
+ }
23
+ function buildHealthSummary(state) {
24
+ const healthPid = readHealthPid();
25
+ const healthRunning = healthPid !== null && isPidAlive(healthPid);
26
+ const port = state?.healthzPort ?? null;
27
+ return {
28
+ configured: port !== null,
29
+ pid: healthRunning ? healthPid : null,
30
+ pidFile: HEALTHZ_PID_FILE,
31
+ port,
32
+ running: healthRunning,
33
+ url: port !== null ? `http://127.0.0.1:${port}/healthz` : null,
34
+ };
35
+ }
36
+ function getDaemonStatus() {
37
+ const state = readDaemonState();
38
+ const pid = readDaemonPid();
39
+ const running = pid !== null && isPidAlive(pid);
40
+ return {
41
+ status: running ? 'running' : 'stopped',
42
+ pid: running ? pid : null,
43
+ pidFile: DAEMON_PID_FILE,
44
+ logFile: DAEMON_LOG_FILE,
45
+ stateFile: DAEMON_STATE_FILE,
46
+ health: buildHealthSummary(state),
47
+ lastReloadAt: state?.lastReloadAt ?? null,
48
+ lastReloadStatus: state?.lastReloadStatus ?? null,
49
+ lastReloadMessage: state?.lastReloadMessage ?? null,
50
+ startedAt: state?.startedAt ?? null,
51
+ stoppedAt: state?.stoppedAt ?? null,
52
+ };
53
+ }
54
+ function persistState(partial) {
55
+ const previous = readDaemonState();
56
+ const next = {
57
+ ...(previous ?? {}),
58
+ status: partial.status ?? previous?.status ?? 'stopped',
59
+ pid: partial.pid ?? previous?.pid ?? null,
60
+ ...partial,
61
+ logFile: DAEMON_LOG_FILE,
62
+ pidFile: DAEMON_PID_FILE,
63
+ stateFile: DAEMON_STATE_FILE,
64
+ };
65
+ writeDaemonState(next);
66
+ return next;
67
+ }
68
+ function renderHumanStatus(status) {
69
+ if (status.status === 'running' && status.pid !== null) {
70
+ console.log(`${chalk.green('●')} Daemon is running (pid ${status.pid}).`);
71
+ }
72
+ else {
73
+ console.log(`${chalk.grey('○')} Daemon is not running.`);
74
+ }
75
+ if (status.startedAt)
76
+ console.log(` Started: ${status.startedAt}`);
77
+ if (status.stoppedAt)
78
+ console.log(` Stopped: ${status.stoppedAt}`);
79
+ console.log(` Log: ${status.logFile}`);
80
+ console.log(` PID: ${status.pidFile}`);
81
+ console.log(` State: ${status.stateFile}`);
82
+ if (status.lastReloadAt) {
83
+ console.log(` Reload: ${status.lastReloadAt} (${status.lastReloadStatus ?? 'unknown'})`);
84
+ if (status.lastReloadMessage)
85
+ console.log(` ${status.lastReloadMessage}`);
86
+ }
87
+ if (status.health.configured) {
88
+ if (status.health.running) {
89
+ console.log(`${chalk.green('✓')} Health server running (pid ${status.health.pid})`);
90
+ }
91
+ else {
92
+ console.log(`${chalk.yellow('!')} Health server configured but not running`);
93
+ }
94
+ if (status.health.url)
95
+ console.log(` Health: ${status.health.url}`);
96
+ }
97
+ }
98
+ export function registerDaemonCommand(program) {
99
+ const daemon = program
100
+ .command('daemon')
101
+ .description('Manage the background SwitchBot rules daemon and its health endpoint.')
102
+ .addHelpText('after', `
103
+ The daemon runs \`switchbot rules run\` as a detached background process,
104
+ tracks runtime metadata in ~/.switchbot/daemon.state.json, and can optionally
105
+ co-launch a health endpoint via \`switchbot health serve\`.
106
+
107
+ Subcommands:
108
+ start [--policy <path>] Start the daemon (no-op if already running).
109
+ stop Stop the daemon and any co-launched health server.
110
+ status Report the daemon state, log path, and health summary.
111
+ reload Trigger a hot reload for the running rules engine.
112
+
113
+ The daemon reads the same policy file as \`switchbot rules run\`.
114
+ `);
115
+ daemon
116
+ .command('start')
117
+ .description('Start the rules-engine daemon in the background.')
118
+ .option('--policy <path>', 'Policy file path (default: auto-detected)', stringArg('--policy'))
119
+ .option('--force', 'Restart even if the daemon appears to be running.')
120
+ .option('--healthz-port <n>', 'Also start a health HTTP server on this port (default: disabled).')
121
+ .action((opts) => {
122
+ const current = getDaemonStatus();
123
+ if (current.status === 'running' && !opts.force) {
124
+ if (isJsonMode()) {
125
+ printJson({ result: 'already-running', ...current });
126
+ }
127
+ else {
128
+ console.log(`Daemon is already running (pid ${current.pid}). Use --force to restart.`);
129
+ }
130
+ return;
131
+ }
132
+ if (opts.force) {
133
+ try {
134
+ killIfAlive(current.pid);
135
+ killIfAlive(current.health.pid);
136
+ }
137
+ catch {
138
+ // best effort
139
+ }
140
+ }
141
+ const thisFile = fileURLToPath(import.meta.url);
142
+ const cliEntry = path.resolve(path.dirname(thisFile), 'index.js');
143
+ const args = ['rules', 'run'];
144
+ if (opts.policy)
145
+ args.push(opts.policy);
146
+ fs.mkdirSync(path.dirname(DAEMON_PID_FILE), { recursive: true, mode: 0o700 });
147
+ persistState({
148
+ status: 'starting',
149
+ pid: null,
150
+ startedAt: new Date().toISOString(),
151
+ stoppedAt: undefined,
152
+ failedAt: undefined,
153
+ failureReason: undefined,
154
+ healthzPort: opts.healthzPort ? Number.parseInt(opts.healthzPort, 10) : null,
155
+ healthzPid: null,
156
+ healthzPidFile: HEALTHZ_PID_FILE,
157
+ });
158
+ const logFd = fs.openSync(DAEMON_LOG_FILE, 'a');
159
+ const child = spawn(process.execPath, [cliEntry, ...args], {
160
+ detached: true,
161
+ stdio: ['ignore', logFd, logFd],
162
+ env: { ...process.env },
163
+ });
164
+ child.unref();
165
+ fs.closeSync(logFd);
166
+ const newPid = child.pid;
167
+ if (!newPid) {
168
+ persistState({
169
+ status: 'failed',
170
+ pid: null,
171
+ failedAt: new Date().toISOString(),
172
+ failureReason: 'Failed to spawn daemon process.',
173
+ });
174
+ exitWithError({ code: 1, kind: 'runtime', message: 'Failed to spawn daemon process.' });
175
+ }
176
+ writePidFile(DAEMON_PID_FILE, newPid);
177
+ let healthzPid = null;
178
+ let healthzPort = opts.healthzPort ? Number.parseInt(opts.healthzPort, 10) : null;
179
+ if (healthzPort !== null) {
180
+ const healthArgs = ['health', 'serve', '--port', String(healthzPort)];
181
+ const healthLogFd = fs.openSync(DAEMON_LOG_FILE, 'a');
182
+ const healthChild = spawn(process.execPath, [cliEntry, ...healthArgs], {
183
+ detached: true,
184
+ stdio: ['ignore', healthLogFd, healthLogFd],
185
+ env: { ...process.env },
186
+ });
187
+ healthChild.unref();
188
+ fs.closeSync(healthLogFd);
189
+ if (healthChild.pid) {
190
+ healthzPid = healthChild.pid;
191
+ writePidFile(HEALTHZ_PID_FILE, healthzPid);
192
+ }
193
+ }
194
+ persistState({
195
+ status: 'running',
196
+ pid: newPid,
197
+ startedAt: new Date().toISOString(),
198
+ stoppedAt: undefined,
199
+ failedAt: undefined,
200
+ failureReason: undefined,
201
+ healthzPort,
202
+ healthzPid,
203
+ healthzPidFile: HEALTHZ_PID_FILE,
204
+ });
205
+ const status = getDaemonStatus();
206
+ if (isJsonMode()) {
207
+ printJson({ result: 'started', ...status });
208
+ }
209
+ else {
210
+ console.log(`${chalk.green('✓')} Daemon started (pid ${newPid})`);
211
+ console.log(` Log: ${DAEMON_LOG_FILE}`);
212
+ console.log(` PID: ${DAEMON_PID_FILE}`);
213
+ console.log(` State: ${DAEMON_STATE_FILE}`);
214
+ console.log(` Reload: switchbot daemon reload`);
215
+ if (status.health.running && status.health.url) {
216
+ console.log(`${chalk.green('✓')} Health server started (pid ${status.health.pid})`);
217
+ console.log(` Health: ${status.health.url}`);
218
+ }
219
+ }
220
+ });
221
+ daemon
222
+ .command('stop')
223
+ .description('Stop the background daemon by sending SIGTERM.')
224
+ .action(() => {
225
+ const status = getDaemonStatus();
226
+ if (status.status !== 'running' || status.pid === null) {
227
+ persistState({ status: 'stopped', pid: null, stoppedAt: new Date().toISOString() });
228
+ if (isJsonMode()) {
229
+ printJson({ result: 'not-running', ...getDaemonStatus() });
230
+ }
231
+ else {
232
+ console.log(`No running daemon found (pid file: ${DAEMON_PID_FILE}).`);
233
+ }
234
+ return;
235
+ }
236
+ try {
237
+ killIfAlive(status.pid);
238
+ killIfAlive(status.health.pid);
239
+ }
240
+ catch (err) {
241
+ persistState({
242
+ status: 'failed',
243
+ pid: status.pid,
244
+ failedAt: new Date().toISOString(),
245
+ failureReason: err instanceof Error ? err.message : String(err),
246
+ });
247
+ exitWithError({
248
+ code: 1,
249
+ kind: 'runtime',
250
+ message: `Failed to stop daemon (pid ${status.pid}): ${err instanceof Error ? err.message : String(err)}`,
251
+ });
252
+ }
253
+ try {
254
+ fs.unlinkSync(DAEMON_PID_FILE);
255
+ }
256
+ catch { /* best effort */ }
257
+ try {
258
+ fs.unlinkSync(HEALTHZ_PID_FILE);
259
+ }
260
+ catch { /* best effort */ }
261
+ persistState({
262
+ status: 'stopped',
263
+ pid: null,
264
+ healthzPid: null,
265
+ stoppedAt: new Date().toISOString(),
266
+ });
267
+ if (isJsonMode()) {
268
+ printJson({ result: 'stopped', ...getDaemonStatus() });
269
+ }
270
+ else {
271
+ console.log(`${chalk.green('✓')} Daemon stopped (pid ${status.pid}).`);
272
+ }
273
+ });
274
+ daemon
275
+ .command('status')
276
+ .description('Report whether the daemon is currently running.')
277
+ .action(() => {
278
+ const status = getDaemonStatus();
279
+ if (isJsonMode()) {
280
+ printJson(status);
281
+ return;
282
+ }
283
+ renderHumanStatus(status);
284
+ });
285
+ daemon
286
+ .command('reload')
287
+ .description('Trigger a hot reload on the running rules-engine daemon.')
288
+ .action(() => {
289
+ const daemonStatus = getDaemonStatus();
290
+ if (daemonStatus.status !== 'running' || daemonStatus.pid === null) {
291
+ persistState({
292
+ status: 'failed',
293
+ failedAt: new Date().toISOString(),
294
+ failureReason: 'No running daemon to reload.',
295
+ lastReloadAt: new Date().toISOString(),
296
+ lastReloadStatus: 'failed',
297
+ lastReloadMessage: 'No running daemon to reload.',
298
+ });
299
+ exitWithError({
300
+ code: 2,
301
+ kind: 'usage',
302
+ message: `No running daemon found (pid file: ${DAEMON_PID_FILE}).`,
303
+ });
304
+ }
305
+ const pidPaths = getDefaultPidFilePaths();
306
+ const rulesPid = readPidFile(pidPaths.pidFile);
307
+ if (rulesPid === null || !isPidAlive(rulesPid)) {
308
+ persistState({
309
+ status: 'failed',
310
+ failedAt: new Date().toISOString(),
311
+ failureReason: 'Rules engine PID is missing or stale.',
312
+ lastReloadAt: new Date().toISOString(),
313
+ lastReloadStatus: 'failed',
314
+ lastReloadMessage: 'Rules engine PID is missing or stale.',
315
+ });
316
+ exitWithError({
317
+ code: 2,
318
+ kind: 'usage',
319
+ message: `No running rules engine found for daemon reload (pid file: ${pidPaths.pidFile}).`,
320
+ });
321
+ }
322
+ let method;
323
+ try {
324
+ if (sighupSupported()) {
325
+ process.kill(rulesPid, 'SIGHUP');
326
+ method = 'SIGHUP';
327
+ }
328
+ else {
329
+ writeReloadSentinel(pidPaths.reloadFile);
330
+ method = 'sentinel';
331
+ }
332
+ }
333
+ catch (err) {
334
+ const message = err instanceof Error ? err.message : String(err);
335
+ persistState({
336
+ status: 'failed',
337
+ failedAt: new Date().toISOString(),
338
+ failureReason: message,
339
+ lastReloadAt: new Date().toISOString(),
340
+ lastReloadStatus: 'failed',
341
+ lastReloadMessage: message,
342
+ });
343
+ exitWithError({
344
+ code: 1,
345
+ kind: 'runtime',
346
+ message: `Failed to reload daemon: ${message}`,
347
+ });
348
+ }
349
+ persistState({
350
+ status: 'running',
351
+ lastReloadAt: new Date().toISOString(),
352
+ lastReloadStatus: 'ok',
353
+ lastReloadMessage: method === 'SIGHUP'
354
+ ? `Sent SIGHUP to rules engine pid ${rulesPid}.`
355
+ : `Wrote reload sentinel ${pidPaths.reloadFile}.`,
356
+ });
357
+ const status = getDaemonStatus();
358
+ if (isJsonMode()) {
359
+ printJson({ result: 'reloaded', method, ...status });
360
+ }
361
+ else {
362
+ console.log(`${chalk.green('✓')} Reload requested via ${method}.`);
363
+ if (status.lastReloadMessage)
364
+ console.log(` ${status.lastReloadMessage}`);
365
+ }
366
+ });
367
+ }
@@ -16,6 +16,7 @@ import { registerDevicesMetaCommand } from './device-meta.js';
16
16
  import { isDryRun } from '../utils/flags.js';
17
17
  import { DryRunSignal } from '../api/client.js';
18
18
  import { resolveField, resolveFieldList, listSupportedFieldInputs } from '../schema/field-aliases.js';
19
+ import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js';
19
20
  const EXPAND_HINTS = {
20
21
  'Air Conditioner': { command: 'setAll', flags: '--temp 26 --mode cool --fan low --power on' },
21
22
  'Curtain': { command: 'setPosition', flags: '--position 50 --mode silent' },
@@ -355,7 +356,8 @@ Examples:
355
356
  .option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
356
357
  .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
357
358
  .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
358
- .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
359
+ .option('--yes', 'Confirm a destructive command in an explicit dev profile. --dry-run is always allowed without --yes.')
360
+ .option('--explain', 'Print a human-readable summary of what this command would do (risk level, device type, idempotency) then exit without executing.')
359
361
  .option('--allow-unknown-device', 'Allow targeting a deviceId that is not in the local cache. By default unknown IDs exit 2 so --dry-run is a reliable pre-flight gate; use this flag for scripted pass-through.')
360
362
  .option('--skip-param-validation', 'Skip client-side parameter validation (escape hatch — prefer fixing the argument over using this).')
361
363
  .option('--idempotency-key <key>', 'Client-supplied key to dedupe retries. 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'))
@@ -393,8 +395,8 @@ Common errors:
393
395
 
394
396
  Safety:
395
397
  Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
396
- Keypad createKey/deleteKey, …) are blocked by default. Pass --yes to confirm,
397
- or --dry-run to preview without sending.
398
+ Keypad createKey/deleteKey, …) are blocked by default. Use the reviewed plan
399
+ flow instead, or --dry-run to preview without sending.
398
400
 
399
401
  Examples:
400
402
  $ switchbot devices command ABC123 turnOn
@@ -402,7 +404,7 @@ Examples:
402
404
  $ switchbot devices command ABC123 setAll "26,1,3,on"
403
405
  $ switchbot devices command ABC123 startClean '{"action":"sweep","param":{"fanLevel":2,"times":1}}'
404
406
  $ switchbot devices command ABC123 "MyButton" --type customize
405
- $ switchbot devices command <lockId> unlock --yes
407
+ $ switchbot devices command <lockId> unlock --dry-run
406
408
  `)
407
409
  .action(async (deviceIdArg, cmdArg, parameter, options) => {
408
410
  // Declared outside try so the DryRunSignal catch branch can reference them.
@@ -507,22 +509,72 @@ Examples:
507
509
  parameter = paramCheck.normalized;
508
510
  }
509
511
  const cachedForGuard = getCachedDevice(deviceId);
510
- if (!options.yes &&
511
- !isDryRun() &&
512
- isDestructiveCommand(cachedForGuard?.type, cmd, options.type)) {
512
+ // --explain: print intent + risk metadata without executing
513
+ if (options.explain) {
514
+ const isDestructive = isDestructiveCommand(cachedForGuard?.type, cmd, options.type);
515
+ const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
516
+ const riskLevel = isDestructive ? 'high' : options.type === 'command' ? 'medium' : 'low';
517
+ const recommendedMode = isDestructive ? 'review-before-execute' : 'direct';
518
+ if (isJsonMode()) {
519
+ printJson({
520
+ intent: `Send command "${cmd}" to device ${deviceId}`,
521
+ deviceType: cachedForGuard?.type ?? 'unknown',
522
+ deviceName: cachedForGuard?.name ?? null,
523
+ command: cmd,
524
+ parameter: parameter ?? null,
525
+ commandType: options.type,
526
+ riskLevel,
527
+ requiresConfirmation: isDestructive,
528
+ safetyReason: reason ?? null,
529
+ recommendedMode,
530
+ note: 'This is a dry explanation only — command was NOT executed.',
531
+ });
532
+ }
533
+ else {
534
+ console.log(`Command: ${cmd} on device ${deviceId}`);
535
+ console.log(`Device type: ${cachedForGuard?.type ?? 'unknown'}${cachedForGuard?.name ? ` (${cachedForGuard.name})` : ''}`);
536
+ console.log(`Parameter: ${parameter ?? '(none)'}`);
537
+ console.log(`Risk level: ${riskLevel}`);
538
+ if (reason)
539
+ console.log(`Safety reason: ${reason}`);
540
+ if (isDestructive)
541
+ console.log(`Requires plan approval by default. ${destructiveExecutionHint()}`);
542
+ console.log('(not executed — remove --explain to run)');
543
+ }
544
+ process.exit(0);
545
+ }
546
+ const destructive = isDestructiveCommand(cachedForGuard?.type, cmd, options.type);
547
+ if (!isDryRun() && destructive && !options.yes && !allowsDirectDestructiveExecution()) {
548
+ const typeLabel = cachedForGuard?.type ?? 'unknown';
549
+ const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
550
+ exitWithError({
551
+ kind: 'guard',
552
+ message: `Direct destructive execution disabled — destructive command "${cmd}" on ${typeLabel}.`,
553
+ hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
554
+ context: {
555
+ command: cmd,
556
+ deviceType: typeLabel,
557
+ deviceId,
558
+ directExecutionAllowed: false,
559
+ requiredWorkflow: 'plan-approval',
560
+ ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}),
561
+ },
562
+ });
563
+ }
564
+ if (!options.yes && !isDryRun() && destructive) {
513
565
  const typeLabel = cachedForGuard?.type ?? 'unknown';
514
566
  const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
515
567
  exitWithError({
516
568
  kind: 'guard',
517
569
  message: `Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`,
518
570
  hint: reason
519
- ? `Re-run with --yes to confirm. Reason: ${reason}`
520
- : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
571
+ ? `Re-run with --yes only from an explicit dev profile, or use the reviewed plan flow. Reason: ${reason}`
572
+ : `Re-run with --yes only from an explicit dev profile, use the reviewed plan flow, or --dry-run to preview without sending.`,
521
573
  context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}) },
522
574
  });
523
575
  }
524
576
  // Warn when --yes is given but the command is not destructive (no-op flag)
525
- if (options.yes && !isDestructiveCommand(cachedForGuard?.type, cmd, options.type) && !isDryRun()) {
577
+ if (options.yes && !destructive && !isDryRun()) {
526
578
  console.error(`Note: --yes has no effect; "${cmd}" is not a destructive command.`);
527
579
  }
528
580
  // parameter may be a JSON object string (e.g. S10 startClean) or a plain string