@switchbot/openapi-cli 3.0.0 → 3.1.1

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,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { execSync } from 'node:child_process';
4
5
  import { printJson, isJsonMode, exitWithError } from '../utils/output.js';
5
6
  import { getEffectiveCatalog } from '../devices/catalog.js';
6
7
  import { configFilePath, listProfiles, readProfileMeta } from '../config.js';
@@ -13,6 +14,8 @@ import { resolvePolicyPath, loadPolicyFile, PolicyFileNotFoundError, PolicyYamlP
13
14
  import { validateLoadedPolicy } from '../policy/validate.js';
14
15
  import { selectCredentialStore } from '../credentials/keychain.js';
15
16
  import { getActiveProfile } from '../lib/request-context.js';
17
+ import { readDaemonState } from '../lib/daemon-state.js';
18
+ import { isPidAlive } from '../rules/pid-file.js';
16
19
  export const DOCTOR_SCHEMA_VERSION = 1;
17
20
  async function checkCredentials() {
18
21
  const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET);
@@ -480,6 +483,46 @@ function checkPolicy() {
480
483
  };
481
484
  }
482
485
  }
486
+ async function checkKeychain() {
487
+ try {
488
+ const { selectCredentialStore } = await import('../credentials/keychain.js');
489
+ const store = await selectCredentialStore();
490
+ const desc = store.describe();
491
+ const isNative = desc.backend !== 'file';
492
+ if (!isNative) {
493
+ // Native keychain not available or not detected
494
+ return {
495
+ name: 'keychain',
496
+ status: 'warn',
497
+ detail: {
498
+ backend: desc.backend,
499
+ message: 'OS native keychain not detected — credentials stored in plain file (~/.switchbot/config.json). Consider installing a keychain backend for better security.',
500
+ hint: process.platform === 'linux'
501
+ ? 'Install libsecret (secret-tool) for GNOME Keyring support.'
502
+ : process.platform === 'darwin'
503
+ ? 'macOS Keychain is available — re-run `switchbot config set-token` to store credentials there.'
504
+ : 'Windows Credential Manager is available — re-run `switchbot config set-token` to use it.',
505
+ },
506
+ };
507
+ }
508
+ return {
509
+ name: 'keychain',
510
+ status: 'ok',
511
+ detail: {
512
+ backend: desc.backend,
513
+ writable: desc.writable,
514
+ message: `Credentials stored in OS keychain (${desc.backend}).`,
515
+ },
516
+ };
517
+ }
518
+ catch (err) {
519
+ return {
520
+ name: 'keychain',
521
+ status: 'warn',
522
+ detail: { message: `Keychain probe failed: ${err instanceof Error ? err.message : String(err)}` },
523
+ };
524
+ }
525
+ }
483
526
  function checkNodeVersion() {
484
527
  const major = Number(process.versions.node.split('.')[0]);
485
528
  if (Number.isFinite(major) && major < 18) {
@@ -487,6 +530,181 @@ function checkNodeVersion() {
487
530
  }
488
531
  return { name: 'node', status: 'ok', detail: `Node ${process.versions.node}` };
489
532
  }
533
+ function detectShellFlavor() {
534
+ const shell = (process.env.SHELL ?? '').toLowerCase();
535
+ const comspec = (process.env.COMSPEC ?? '').toLowerCase();
536
+ if (shell.includes('pwsh') || shell.includes('powershell'))
537
+ return 'powershell';
538
+ if (comspec.includes('powershell') || comspec.includes('pwsh'))
539
+ return 'powershell';
540
+ if (comspec.endsWith('cmd.exe'))
541
+ return 'cmd';
542
+ if (shell.endsWith('/fish') || shell === 'fish')
543
+ return 'fish';
544
+ if (shell.endsWith('/zsh') || shell === 'zsh')
545
+ return 'zsh';
546
+ if (shell.endsWith('/bash') || shell === 'bash')
547
+ return 'bash';
548
+ return process.platform === 'win32' ? 'powershell' : 'unknown';
549
+ }
550
+ function buildPathFix(shell, missingSegment, npmBinDir) {
551
+ if (!npmBinDir) {
552
+ return process.platform === 'win32'
553
+ ? 'Run: npm prefix -g and add that directory to your PATH.'
554
+ : 'Run: npm prefix -g and add <prefix>/bin to your PATH.';
555
+ }
556
+ switch (shell) {
557
+ case 'powershell':
558
+ return `$env:Path = "${missingSegment};" + $env:Path # persist via your PowerShell profile or System Properties`;
559
+ case 'cmd':
560
+ return `set PATH=${missingSegment};%PATH% && setx PATH "${missingSegment};%PATH%"`;
561
+ case 'fish':
562
+ return `fish_add_path "${missingSegment}"`;
563
+ case 'zsh':
564
+ return `export PATH="${missingSegment}:$PATH" # add to ~/.zshrc`;
565
+ case 'bash':
566
+ return `export PATH="${missingSegment}:$PATH" # add to ~/.bashrc`;
567
+ default:
568
+ return `export PATH="${missingSegment}:$PATH"`;
569
+ }
570
+ }
571
+ function checkPathDiscoverability() {
572
+ // Detect whether the `switchbot` binary is reachable on PATH.
573
+ // This catches the common "npm install -g worked but PATH not updated" failure.
574
+ const isWindows = process.platform === 'win32';
575
+ const binaryName = isWindows ? 'switchbot.cmd' : 'switchbot';
576
+ // Find where npm puts global bins.
577
+ let npmBinDir = null;
578
+ try {
579
+ const prefix = execSync('npm prefix -g', { timeout: 4000, encoding: 'utf-8' }).trim();
580
+ npmBinDir = isWindows ? prefix : path.join(prefix, 'bin');
581
+ }
582
+ catch {
583
+ // npm not on PATH or other error; fall through.
584
+ }
585
+ // Check whether `switchbot` resolves via PATH.
586
+ let binaryOnPath = false;
587
+ let resolvedPath = null;
588
+ try {
589
+ const which = execSync(isWindows ? `where ${binaryName}` : `which ${binaryName}`, { timeout: 3000, encoding: 'utf-8' }).trim().split(/\r?\n/)[0];
590
+ if (which) {
591
+ binaryOnPath = true;
592
+ resolvedPath = which;
593
+ }
594
+ }
595
+ catch {
596
+ binaryOnPath = false;
597
+ }
598
+ if (binaryOnPath) {
599
+ return {
600
+ name: 'path',
601
+ status: 'ok',
602
+ detail: {
603
+ binaryOnPath: true,
604
+ resolvedPath,
605
+ npmBinDir,
606
+ message: `switchbot is reachable at ${resolvedPath}`,
607
+ },
608
+ };
609
+ }
610
+ // Not on PATH — figure out what the user should add.
611
+ const currentPath = process.env.PATH ?? '';
612
+ const missingSegment = npmBinDir && !currentPath.split(path.delimiter).includes(npmBinDir)
613
+ ? npmBinDir
614
+ : null;
615
+ const currentShell = detectShellFlavor();
616
+ const shellFix = buildPathFix(currentShell, missingSegment, npmBinDir);
617
+ return {
618
+ name: 'path',
619
+ status: 'warn',
620
+ detail: {
621
+ binaryOnPath: false,
622
+ resolvedPath: null,
623
+ npmBinDir,
624
+ missingPathSegment: missingSegment,
625
+ currentShell,
626
+ fix: shellFix,
627
+ message: `'switchbot' is not on PATH. ${shellFix}`,
628
+ },
629
+ };
630
+ }
631
+ function checkDaemon() {
632
+ const state = readDaemonState();
633
+ if (!state) {
634
+ return {
635
+ name: 'daemon',
636
+ status: 'warn',
637
+ detail: {
638
+ present: false,
639
+ message: 'No daemon state file found. Start one with `switchbot daemon start` if you want long-running automation.',
640
+ },
641
+ };
642
+ }
643
+ const pid = state.pid;
644
+ const running = pid !== null && (pid === process.pid || isPidAlive(pid));
645
+ return {
646
+ name: 'daemon',
647
+ status: running ? 'ok' : 'warn',
648
+ detail: {
649
+ present: true,
650
+ status: running ? 'running' : state.status,
651
+ pid: running ? pid : null,
652
+ stateFile: state.stateFile,
653
+ pidFile: state.pidFile,
654
+ logFile: state.logFile,
655
+ startedAt: state.startedAt ?? null,
656
+ stoppedAt: state.stoppedAt ?? null,
657
+ lastReloadAt: state.lastReloadAt ?? null,
658
+ lastReloadStatus: state.lastReloadStatus ?? null,
659
+ healthConfigured: typeof state.healthzPort === 'number',
660
+ healthzPort: state.healthzPort ?? null,
661
+ message: running
662
+ ? 'daemon running'
663
+ : 'daemon not running; use `switchbot daemon start` for long-running automation',
664
+ },
665
+ };
666
+ }
667
+ async function checkHealthEndpoint() {
668
+ const state = readDaemonState();
669
+ if (!state || typeof state.healthzPort !== 'number') {
670
+ return {
671
+ name: 'health',
672
+ status: 'warn',
673
+ detail: {
674
+ present: false,
675
+ message: 'No health endpoint configured. Start the daemon with `--healthz-port <n>` to enable it.',
676
+ },
677
+ };
678
+ }
679
+ const url = `http://127.0.0.1:${state.healthzPort}/healthz`;
680
+ try {
681
+ const res = await fetch(url);
682
+ const body = await res.json();
683
+ const overall = body?.data?.overall ?? 'unknown';
684
+ return {
685
+ name: 'health',
686
+ status: res.ok && overall !== 'down' ? 'ok' : 'warn',
687
+ detail: {
688
+ present: true,
689
+ url,
690
+ httpStatus: res.status,
691
+ overall,
692
+ message: res.ok ? `health endpoint reachable at ${url}` : `health endpoint returned HTTP ${res.status}`,
693
+ },
694
+ };
695
+ }
696
+ catch (err) {
697
+ return {
698
+ name: 'health',
699
+ status: 'warn',
700
+ detail: {
701
+ present: true,
702
+ url,
703
+ message: `health endpoint probe failed: ${err instanceof Error ? err.message : String(err)}`,
704
+ },
705
+ };
706
+ }
707
+ }
490
708
  function checkMqtt() {
491
709
  // MQTT credentials are auto-provisioned from the SwitchBot API using the
492
710
  // account's token+secret — no extra env vars needed. Report availability
@@ -603,7 +821,9 @@ function checkMcp() {
603
821
  }
604
822
  const CHECK_REGISTRY = [
605
823
  { name: 'node', description: 'Node.js version compatibility', run: () => checkNodeVersion() },
824
+ { name: 'path', description: 'switchbot binary reachable on PATH', run: () => checkPathDiscoverability() },
606
825
  { name: 'credentials', description: 'credentials file present and parseable', run: () => checkCredentials() },
826
+ { name: 'keychain', description: 'OS keychain backend availability and usage', run: () => checkKeychain() },
607
827
  { name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() },
608
828
  { name: 'catalog', description: 'catalog loads', run: () => checkCatalog() },
609
829
  { name: 'catalog-schema', description: 'catalog vs agent-bootstrap version aligned', run: () => checkCatalogSchema() },
@@ -618,6 +838,8 @@ const CHECK_REGISTRY = [
618
838
  { name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() },
619
839
  { name: 'policy', description: 'policy.yaml present + schema-valid (if configured)', run: () => checkPolicy() },
620
840
  { name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() },
841
+ { name: 'daemon', description: 'daemon state file + runtime status', run: () => checkDaemon() },
842
+ { name: 'health', description: 'health endpoint availability (daemon --healthz-port)', run: () => checkHealthEndpoint() },
621
843
  ];
622
844
  function applyFixes(checks, writeOk) {
623
845
  const results = [];
@@ -668,7 +890,7 @@ function applyFixes(checks, writeOk) {
668
890
  export function registerDoctorCommand(program) {
669
891
  program
670
892
  .command('doctor')
671
- .description('Self-check the SwitchBot CLI setup: credentials, catalog, cache, quota, MQTT, MCP')
893
+ .description('Self-check the SwitchBot CLI setup: credentials, catalog, cache, quota, MQTT, daemon, health, MCP')
672
894
  .option('--section <names>', 'Comma-separated list of checks to run (see --list for names)')
673
895
  .option('--list', 'Print the registered check names and exit 0 without running any check')
674
896
  .option('--fix', 'Apply safe, reversible remediations for failing checks (e.g. clear stale cache)')
@@ -683,6 +905,7 @@ Examples:
683
905
  $ switchbot --json doctor | jq '.checks[] | select(.status != "ok")'
684
906
  $ switchbot doctor --list
685
907
  $ switchbot doctor --section credentials,mcp --json
908
+ $ switchbot doctor --section daemon,health --json
686
909
  $ switchbot doctor --probe --json
687
910
  $ switchbot doctor --fix --yes --json
688
911
  `)
@@ -734,6 +957,13 @@ Examples:
734
957
  };
735
958
  const overallFail = summary.fail > 0;
736
959
  const overall = overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok';
960
+ const total = summary.ok + summary.warn + summary.fail;
961
+ const rawScore = total > 0 ? Math.round(((summary.ok + summary.warn * 0.5) / total) * 100) : 100;
962
+ const maturityScore = Math.min(100, Math.max(0, rawScore));
963
+ const maturityLabel = maturityScore >= 90 ? 'production-ready'
964
+ : maturityScore >= 70 ? 'mostly-ready'
965
+ : maturityScore >= 40 ? 'needs-work'
966
+ : 'not-ready';
737
967
  let fixes;
738
968
  if (opts.fix) {
739
969
  fixes = applyFixes(checks, Boolean(opts.yes));
@@ -747,6 +977,8 @@ Examples:
747
977
  const payload = {
748
978
  ok: overall === 'ok',
749
979
  overall,
980
+ maturityScore,
981
+ maturityLabel,
750
982
  generatedAt: new Date().toISOString(),
751
983
  schemaVersion: DOCTOR_SCHEMA_VERSION,
752
984
  summary,
@@ -0,0 +1,113 @@
1
+ import http from 'node:http';
2
+ import { printJson, isJsonMode, printTable, handleError } from '../utils/output.js';
3
+ import { getHealthReport, toPrometheusText } from '../utils/health.js';
4
+ import { intArg } from '../utils/arg-parsers.js';
5
+ const HEALTHZ_SCHEMA_VERSION = '1.1';
6
+ /**
7
+ * Create an HTTP request handler for the health endpoints. Exposed separately
8
+ * so integration tests can call it directly without binding a port.
9
+ */
10
+ export function createHealthHandler(auditLogPath) {
11
+ return (req, res) => {
12
+ const url = (req.url ?? '/').split('?')[0];
13
+ if (url === '/healthz') {
14
+ const report = getHealthReport(auditLogPath);
15
+ const statusCode = report.overall === 'down' ? 503 : 200;
16
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
17
+ res.end(JSON.stringify({ schemaVersion: HEALTHZ_SCHEMA_VERSION, data: report }));
18
+ }
19
+ else if (url === '/metrics') {
20
+ const report = getHealthReport(auditLogPath);
21
+ res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' });
22
+ res.end(toPrometheusText(report));
23
+ }
24
+ else {
25
+ res.writeHead(404, { 'Content-Type': 'application/json' });
26
+ res.end(JSON.stringify({ error: 'Not found', paths: ['/healthz', '/metrics'] }));
27
+ }
28
+ };
29
+ }
30
+ export function registerHealthCommand(program) {
31
+ const health = program
32
+ .command('health')
33
+ .description('Report process health: quota, audit error rate, circuit breaker state.');
34
+ health
35
+ .command('check')
36
+ .description('Print a one-shot health report.')
37
+ .option('--prometheus', 'Emit Prometheus text format.')
38
+ .option('--audit-log <path>', 'Audit log path (default: ~/.switchbot/audit.log).')
39
+ .action((opts) => {
40
+ const report = getHealthReport(opts.auditLog);
41
+ if (opts.prometheus) {
42
+ process.stdout.write(toPrometheusText(report));
43
+ return;
44
+ }
45
+ if (isJsonMode()) {
46
+ printJson(report);
47
+ return;
48
+ }
49
+ const statusEmoji = report.overall === 'ok' ? '✓' : report.overall === 'degraded' ? '⚠' : '✗';
50
+ console.log(`${statusEmoji} overall: ${report.overall} (${report.generatedAt})`);
51
+ console.log('');
52
+ printTable(['Component', 'Status', 'Detail'], [
53
+ ['quota', report.quota.status,
54
+ `${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`],
55
+ ['audit', report.audit.status,
56
+ report.audit.present
57
+ ? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)`
58
+ : 'log not present'],
59
+ ['circuit', report.circuit.status,
60
+ `${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`],
61
+ ['process', 'ok',
62
+ `pid ${report.process.pid} · uptime ${report.process.uptimeSeconds}s · mem ${report.process.memoryMb}MB`],
63
+ ]);
64
+ if (report.overall !== 'ok')
65
+ process.exit(1);
66
+ });
67
+ // switchbot health serve [--port <n>]
68
+ health
69
+ .command('serve')
70
+ .description('Start an HTTP server exposing /healthz (JSON) and /metrics (Prometheus).')
71
+ .option('--port <n>', 'Port to listen on.', intArg('--port'), '3100')
72
+ .option('--host <host>', 'Bind address.', '127.0.0.1')
73
+ .option('--audit-log <path>', 'Audit log path.')
74
+ .addHelpText('after', `
75
+ Endpoints:
76
+ GET /healthz JSON health report (HTTP 200 ok/degraded, 503 when circuit is open).
77
+ GET /metrics Prometheus text metrics.
78
+
79
+ Example:
80
+ $ switchbot health serve --port 3100
81
+ $ curl http://127.0.0.1:3100/healthz
82
+ `)
83
+ .action((opts) => {
84
+ const port = parseInt(opts.port, 10);
85
+ const handler = createHealthHandler(opts.auditLog);
86
+ const server = http.createServer(handler);
87
+ server.on('error', (err) => {
88
+ if (err.code === 'EADDRINUSE') {
89
+ handleError(Object.assign(new Error(`Port ${port} is already in use. Choose a different port with --port.`), { code: err.code }));
90
+ }
91
+ else {
92
+ handleError(err);
93
+ }
94
+ });
95
+ server.listen(port, opts.host, () => {
96
+ const addr = server.address();
97
+ const boundPort = typeof addr === 'object' && addr !== null ? addr.port : port;
98
+ if (isJsonMode()) {
99
+ printJson({ status: 'listening', host: opts.host, port: boundPort, endpoints: ['/healthz', '/metrics'] });
100
+ }
101
+ else {
102
+ console.log(`health server listening on ${opts.host}:${boundPort}`);
103
+ console.log(' GET /healthz — JSON health report');
104
+ console.log(' GET /metrics — Prometheus text metrics');
105
+ }
106
+ });
107
+ function shutdown() {
108
+ server.close(() => process.exit(0));
109
+ }
110
+ process.on('SIGTERM', shutdown);
111
+ process.on('SIGINT', shutdown);
112
+ });
113
+ }
@@ -25,6 +25,7 @@ import { planMigration } from '../policy/migrate.js';
25
25
  import { suggestPlan } from './plan.js';
26
26
  import { suggestRule } from '../rules/suggest.js';
27
27
  import { addRuleToPolicyFile, AddRuleError } from '../policy/add-rule.js';
28
+ import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js';
28
29
  import { writeFileSync } from 'node:fs';
29
30
  import { readAudit } from '../utils/audit.js';
30
31
  import { parseDurationToMs } from '../utils/flags.js';
@@ -135,6 +136,33 @@ function topNFromMap(counts, n) {
135
136
  .slice(0, n)
136
137
  .map(([key, count]) => ({ key, count }));
137
138
  }
139
+ /**
140
+ * Compute per-action risk metadata from the device catalog.
141
+ * `idempotencyHint` is sourced from CommandSpec.idempotent when available;
142
+ * falls back to "safe" only for unknown commands on non-destructive paths.
143
+ */
144
+ function buildRiskProfile(typeName, command, commandType, isDestructive) {
145
+ // Look up the catalog spec to get the authoritative idempotent flag.
146
+ let idempotencyHint = isDestructive ? 'non-idempotent' : 'safe';
147
+ if (typeName && commandType === 'command') {
148
+ const entry = findCatalogEntry(typeName);
149
+ const entries = Array.isArray(entry) ? entry : entry ? [entry] : [];
150
+ for (const e of entries) {
151
+ const spec = e.commands.find((c) => c.command === command);
152
+ if (spec !== undefined) {
153
+ idempotencyHint = spec.idempotent === true ? 'safe' : 'non-idempotent';
154
+ break;
155
+ }
156
+ }
157
+ }
158
+ return {
159
+ riskLevel: isDestructive ? 'high' : commandType === 'command' ? 'medium' : 'low',
160
+ requiresConfirmation: isDestructive,
161
+ supportsDryRun: true,
162
+ idempotencyHint,
163
+ recommendedMode: isDestructive ? 'review-before-execute' : 'plan',
164
+ };
165
+ }
138
166
  export function createSwitchBotMcpServer(options) {
139
167
  const eventManager = options?.eventManager;
140
168
  const server = new McpServer({
@@ -301,7 +329,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
301
329
  // ---- send_command ---------------------------------------------------------
302
330
  server.registerTool('send_command', {
303
331
  title: 'Send a control command to a device',
304
- description: 'Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands (Smart Lock unlock, Garage Door open, Keypad createKey/deleteKey) require confirm:true to proceed; otherwise rejected. Commands are validated offline against the device catalog. Use idempotencyKey to safely deduplicate retries within 60 seconds.',
332
+ description: 'Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands require confirm:true and are still blocked in the default safety profile; use the reviewed plan workflow unless an explicit dev profile allows direct execution. Commands are validated offline against the device catalog. Use idempotencyKey to safely deduplicate retries within 60 seconds.',
305
333
  _meta: { agentSafetyTier: 'action' },
306
334
  inputSchema: z.object({
307
335
  deviceId: z.string().describe('Device ID from list_devices'),
@@ -334,6 +362,16 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
334
362
  command: z.string().optional(),
335
363
  deviceId: z.string().optional(),
336
364
  result: z.unknown().optional().describe('API response body from SwitchBot (absent on dryRun)'),
365
+ riskProfile: z
366
+ .object({
367
+ riskLevel: z.enum(['high', 'medium', 'low']),
368
+ requiresConfirmation: z.boolean(),
369
+ supportsDryRun: z.literal(true),
370
+ idempotencyHint: z.enum(['safe', 'non-idempotent']),
371
+ recommendedMode: z.enum(['review-before-execute', 'plan', 'direct']),
372
+ })
373
+ .optional()
374
+ .describe('Device+command-specific risk metadata. riskLevel:"high" means confirm:true was required. Always present on dryRun responses so agents can preview risk before committing.'),
337
375
  verification: z
338
376
  .object({
339
377
  verifiable: z.boolean(),
@@ -404,7 +442,9 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
404
442
  parameter: effectiveParameter ?? 'default',
405
443
  commandType: effectiveType,
406
444
  };
407
- const structured = { ok: true, dryRun: true, wouldSend };
445
+ const dryIsDestructive = isDestructiveCommand(cached.type, effectiveCommand, effectiveType);
446
+ const dryRiskProfile = buildRiskProfile(cached.type, effectiveCommand, effectiveType, dryIsDestructive);
447
+ const structured = { ok: true, dryRun: true, riskProfile: dryRiskProfile, wouldSend };
408
448
  return {
409
449
  content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
410
450
  structuredContent: structured,
@@ -425,7 +465,21 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
425
465
  }
426
466
  typeName = physical ? physical.deviceType : ir.remoteType;
427
467
  }
428
- if (isDestructiveCommand(typeName, effectiveCommand, effectiveType) && !confirm) {
468
+ const destructive = isDestructiveCommand(typeName, effectiveCommand, effectiveType);
469
+ if (destructive && !allowsDirectDestructiveExecution()) {
470
+ const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
471
+ return mcpError('guard', 3, `Direct destructive execution is disabled for command "${effectiveCommand}" on device type "${typeName}".`, {
472
+ hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
473
+ context: {
474
+ command: effectiveCommand,
475
+ deviceType: typeName,
476
+ directExecutionAllowed: false,
477
+ requiredWorkflow: 'plan-approval',
478
+ ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}),
479
+ },
480
+ });
481
+ }
482
+ if (destructive && !confirm) {
429
483
  const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
430
484
  const entry = typeName ? findCatalogEntry(typeName) : null;
431
485
  const spec = entry && !Array.isArray(entry)
@@ -490,7 +544,9 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
490
544
  return apiErrorToMcpError(err);
491
545
  }
492
546
  const isIr = getCachedDevice(deviceId)?.category === 'ir';
493
- const structured = { ok: true, command: effectiveCommand, deviceId, result };
547
+ const liveIsDestructive = destructive;
548
+ const riskProfile = buildRiskProfile(typeName, effectiveCommand, effectiveType, liveIsDestructive);
549
+ const structured = { ok: true, command: effectiveCommand, deviceId, result, riskProfile };
494
550
  if (isIr) {
495
551
  structured.verification = {
496
552
  verifiable: false,
@@ -1254,7 +1310,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
1254
1310
  // ---- plan_run -------------------------------------------------------------
1255
1311
  server.registerTool('plan_run', {
1256
1312
  title: 'Validate and execute a SwitchBot plan',
1257
- description: 'Execute a Plan JSON object (version 1.0). Destructive command steps are skipped unless yes=true. ' +
1313
+ description: 'Execute a Plan JSON object (version 1.0). Destructive command steps are skipped unless yes=true, and the default safety profile still refuses direct destructive execution in favor of the reviewed plan workflow. ' +
1258
1314
  'Scene and wait steps run in order. Returns per-step results and a summary.',
1259
1315
  _meta: { agentSafetyTier: 'action' },
1260
1316
  inputSchema: z.object({
@@ -1289,6 +1345,38 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
1289
1345
  };
1290
1346
  const continueOnError = continue_on_error === true;
1291
1347
  const allowDestructive = yes === true;
1348
+ const destructiveSteps = validated.plan.steps
1349
+ .map((step, index) => ({ step, index }))
1350
+ .filter((entry) => entry.step.type === 'command')
1351
+ .map(({ step, index }) => {
1352
+ const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
1353
+ const commandType = step.commandType ?? 'command';
1354
+ const deviceType = getCachedDevice(resolvedDeviceId)?.type;
1355
+ return {
1356
+ index: index + 1,
1357
+ deviceId: resolvedDeviceId,
1358
+ command: step.command,
1359
+ commandType,
1360
+ deviceType: deviceType ?? null,
1361
+ destructive: isDestructiveCommand(deviceType, step.command, commandType),
1362
+ };
1363
+ })
1364
+ .filter((step) => step.destructive);
1365
+ if (allowDestructive && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
1366
+ return mcpError('guard', 3, 'Direct destructive execution is disabled for plan_run.', {
1367
+ hint: destructiveExecutionHint(),
1368
+ context: {
1369
+ destructiveSteps: destructiveSteps.map((step) => ({
1370
+ step: step.index,
1371
+ deviceId: step.deviceId,
1372
+ deviceType: step.deviceType,
1373
+ command: step.command,
1374
+ commandType: step.commandType,
1375
+ })),
1376
+ requiredWorkflow: 'plan-approval',
1377
+ },
1378
+ });
1379
+ }
1292
1380
  for (let i = 0; i < validated.plan.steps.length; i++) {
1293
1381
  const step = validated.plan.steps[i];
1294
1382
  const idx = i + 1;