@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
@@ -18,6 +18,25 @@ import { todayUsage } from '../utils/quota.js';
18
18
  import { describeCache } from '../devices/cache.js';
19
19
  import { withRequestContext } from '../lib/request-context.js';
20
20
  import { profileFilePath, tryLoadConfig } from '../config.js';
21
+ import { loadPolicyFile, resolvePolicyPath, PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js';
22
+ import { validateLoadedPolicy } from '../policy/validate.js';
23
+ import { CURRENT_POLICY_SCHEMA_VERSION, SUPPORTED_POLICY_SCHEMA_VERSIONS, } from '../policy/schema.js';
24
+ import { planMigration } from '../policy/migrate.js';
25
+ import { suggestPlan } from './plan.js';
26
+ import { suggestRule } from '../rules/suggest.js';
27
+ import { addRuleToPolicyFile, AddRuleError } from '../policy/add-rule.js';
28
+ import { allowsDirectDestructiveExecution, destructiveExecutionHint } from '../lib/destructive-mode.js';
29
+ import { writeFileSync } from 'node:fs';
30
+ import { readAudit } from '../utils/audit.js';
31
+ import { parseDurationToMs } from '../utils/flags.js';
32
+ import { resolveDeviceId } from '../utils/name-resolver.js';
33
+ import { validatePlan } from './plan.js';
34
+ import { parse as yamlParse } from 'yaml';
35
+ import { diffPolicyValues } from '../policy/diff.js';
36
+ const LATEST_SUPPORTED_VERSION = SUPPORTED_POLICY_SCHEMA_VERSIONS[SUPPORTED_POLICY_SCHEMA_VERSIONS.length - 1];
37
+ import { fileURLToPath } from 'node:url';
38
+ import { dirname as pathDirname, join as pathJoin } from 'node:path';
39
+ import os from 'node:os';
21
40
  import fs from 'node:fs';
22
41
  function mcpError(kind, code, message, options) {
23
42
  const obj = { code, kind, message };
@@ -57,6 +76,93 @@ function apiErrorToMcpError(err) {
57
76
  retryAfterMs: payload.retryAfterMs,
58
77
  });
59
78
  }
79
+ const DEFAULT_AUDIT_LOG_FILE = pathJoin(os.homedir(), '.switchbot', 'audit.log');
80
+ function resolveAuditRange(opts) {
81
+ if (opts.since && (opts.from || opts.to)) {
82
+ throw new Error('--since is mutually exclusive with --from/--to.');
83
+ }
84
+ if (opts.since) {
85
+ const dur = parseDurationToMs(opts.since);
86
+ if (dur === null) {
87
+ throw new Error(`Invalid --since value "${opts.since}". Expected e.g. "30s", "15m", "1h", "7d".`);
88
+ }
89
+ return { fromMs: Date.now() - dur, toMs: Number.POSITIVE_INFINITY };
90
+ }
91
+ let fromMs = Number.NEGATIVE_INFINITY;
92
+ let toMs = Number.POSITIVE_INFINITY;
93
+ if (opts.from) {
94
+ const parsed = Date.parse(opts.from);
95
+ if (!Number.isFinite(parsed)) {
96
+ throw new Error(`Invalid --from value "${opts.from}". Expected ISO-8601 timestamp.`);
97
+ }
98
+ fromMs = parsed;
99
+ }
100
+ if (opts.to) {
101
+ const parsed = Date.parse(opts.to);
102
+ if (!Number.isFinite(parsed)) {
103
+ throw new Error(`Invalid --to value "${opts.to}". Expected ISO-8601 timestamp.`);
104
+ }
105
+ toMs = parsed;
106
+ }
107
+ if (fromMs > toMs) {
108
+ throw new Error('--from must be <= --to.');
109
+ }
110
+ return { fromMs, toMs };
111
+ }
112
+ function filterAuditEntries(entries, opts) {
113
+ const { fromMs, toMs } = resolveAuditRange(opts);
114
+ return entries.filter((entry) => {
115
+ const tMs = Date.parse(entry.t);
116
+ if (!Number.isFinite(tMs))
117
+ return false;
118
+ if (tMs < fromMs || tMs > toMs)
119
+ return false;
120
+ if (opts.kinds && opts.kinds.length > 0 && !opts.kinds.includes(entry.kind))
121
+ return false;
122
+ if (opts.deviceId && entry.deviceId !== opts.deviceId)
123
+ return false;
124
+ if (opts.ruleName && entry.rule?.name !== opts.ruleName)
125
+ return false;
126
+ if (opts.results && opts.results.length > 0) {
127
+ if (!entry.result || !opts.results.includes(entry.result))
128
+ return false;
129
+ }
130
+ return true;
131
+ });
132
+ }
133
+ function topNFromMap(counts, n) {
134
+ return [...counts.entries()]
135
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
136
+ .slice(0, n)
137
+ .map(([key, count]) => ({ key, count }));
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
+ }
60
166
  export function createSwitchBotMcpServer(options) {
61
167
  const eventManager = options?.eventManager;
62
168
  const server = new McpServer({
@@ -223,7 +329,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
223
329
  // ---- send_command ---------------------------------------------------------
224
330
  server.registerTool('send_command', {
225
331
  title: 'Send a control command to a device',
226
- 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.',
227
333
  _meta: { agentSafetyTier: 'action' },
228
334
  inputSchema: z.object({
229
335
  deviceId: z.string().describe('Device ID from list_devices'),
@@ -256,6 +362,16 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
256
362
  command: z.string().optional(),
257
363
  deviceId: z.string().optional(),
258
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.'),
259
375
  verification: z
260
376
  .object({
261
377
  verifiable: z.boolean(),
@@ -326,7 +442,9 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
326
442
  parameter: effectiveParameter ?? 'default',
327
443
  commandType: effectiveType,
328
444
  };
329
- 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 };
330
448
  return {
331
449
  content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
332
450
  structuredContent: structured,
@@ -347,7 +465,21 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
347
465
  }
348
466
  typeName = physical ? physical.deviceType : ir.remoteType;
349
467
  }
350
- 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) {
351
483
  const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
352
484
  const entry = typeName ? findCatalogEntry(typeName) : null;
353
485
  const spec = entry && !Array.isArray(entry)
@@ -412,7 +544,9 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
412
544
  return apiErrorToMcpError(err);
413
545
  }
414
546
  const isIr = getCachedDevice(deviceId)?.category === 'ir';
415
- 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 };
416
550
  if (isIr) {
417
551
  structured.verification = {
418
552
  verifiable: false,
@@ -518,7 +652,6 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
518
652
  idempotent: z.boolean().optional(),
519
653
  safetyTier: z.enum(['read', 'mutation', 'ir-fire-forget', 'destructive', 'maintenance']).optional(),
520
654
  safetyReason: z.string().optional(),
521
- destructive: z.boolean().optional(),
522
655
  }).passthrough()),
523
656
  aliases: z.array(z.string()).optional(),
524
657
  statusFields: z.array(z.string()).optional(),
@@ -542,7 +675,6 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
542
675
  return {
543
676
  ...c,
544
677
  safetyTier: tier,
545
- destructive: tier === 'destructive',
546
678
  ...(reason ? { safetyReason: reason } : {}),
547
679
  };
548
680
  }),
@@ -781,6 +913,347 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
781
913
  structuredContent: overview,
782
914
  };
783
915
  });
916
+ // ---- policy_validate -----------------------------------------------------
917
+ server.registerTool('policy_validate', {
918
+ title: 'Validate a policy.yaml file',
919
+ description: 'Check a policy file against the embedded JSON Schema (supports v0.1 and v0.2). ' +
920
+ 'Returns the validation result with per-error line/col and a hint. ' +
921
+ 'When no path is given, reads the resolved default (${SWITCHBOT_POLICY_PATH} or ~/.config/openclaw/switchbot/policy.yaml). ' +
922
+ 'Use before relying on aliases/quiet_hours/confirmations so the agent never acts on a broken policy.',
923
+ _meta: { agentSafetyTier: 'read' },
924
+ inputSchema: z.object({
925
+ path: z.string().optional().describe('Optional policy file path; defaults to the resolved default path'),
926
+ }).strict(),
927
+ outputSchema: {
928
+ policyPath: z.string(),
929
+ schemaVersion: z.string(),
930
+ present: z.boolean().describe('false when the file does not exist'),
931
+ valid: z.boolean().nullable().describe('null when present=false'),
932
+ errors: z.array(z.object({
933
+ path: z.string(),
934
+ line: z.number().optional(),
935
+ col: z.number().optional(),
936
+ keyword: z.string(),
937
+ message: z.string(),
938
+ hint: z.string().optional(),
939
+ schemaPath: z.string(),
940
+ })).describe('Empty when valid or when the file is missing'),
941
+ },
942
+ }, async ({ path: pathArg }) => {
943
+ const policyPath = resolvePolicyPath({ flag: pathArg });
944
+ try {
945
+ const loaded = loadPolicyFile(policyPath);
946
+ const result = validateLoadedPolicy(loaded);
947
+ const structured = {
948
+ policyPath: result.policyPath,
949
+ schemaVersion: result.schemaVersion,
950
+ present: true,
951
+ valid: result.valid,
952
+ errors: result.errors,
953
+ };
954
+ return {
955
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
956
+ structuredContent: structured,
957
+ };
958
+ }
959
+ catch (err) {
960
+ if (err instanceof PolicyFileNotFoundError) {
961
+ const structured = {
962
+ policyPath,
963
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
964
+ present: false,
965
+ valid: null,
966
+ errors: [],
967
+ };
968
+ return {
969
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
970
+ structuredContent: structured,
971
+ };
972
+ }
973
+ if (err instanceof PolicyYamlParseError) {
974
+ const structured = {
975
+ policyPath,
976
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
977
+ present: true,
978
+ valid: false,
979
+ errors: err.yamlErrors.map((e) => ({
980
+ path: '',
981
+ line: e.line,
982
+ col: e.col,
983
+ keyword: 'yaml-parse',
984
+ message: e.message,
985
+ schemaPath: '',
986
+ })),
987
+ };
988
+ return {
989
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
990
+ structuredContent: structured,
991
+ };
992
+ }
993
+ throw err;
994
+ }
995
+ });
996
+ // ---- policy_new ----------------------------------------------------------
997
+ server.registerTool('policy_new', {
998
+ title: 'Scaffold a starter policy.yaml',
999
+ description: 'Write a starter policy file to the resolved default path (or a given path). Refuses to overwrite unless force=true. ' +
1000
+ 'This is a write action: the agent should only call it after confirming with the user.',
1001
+ _meta: { agentSafetyTier: 'action' },
1002
+ inputSchema: z.object({
1003
+ path: z.string().optional().describe('Optional target path; defaults to the resolved default'),
1004
+ force: z.boolean().optional().describe('When true, overwrite an existing file'),
1005
+ }).strict(),
1006
+ outputSchema: {
1007
+ policyPath: z.string(),
1008
+ schemaVersion: z.string(),
1009
+ bytesWritten: z.number(),
1010
+ overwritten: z.boolean(),
1011
+ },
1012
+ }, async ({ path: pathArg, force }) => {
1013
+ const policyPath = resolvePolicyPath({ flag: pathArg });
1014
+ const doForce = force === true;
1015
+ if (fs.existsSync(policyPath) && !doForce) {
1016
+ return mcpError('guard', 5, `refusing to overwrite existing policy at ${policyPath}`, {
1017
+ hint: 'pass force=true to overwrite, or choose a different path',
1018
+ context: { policyPath },
1019
+ });
1020
+ }
1021
+ const templateUrl = new URL('../policy/examples/policy.example.yaml', import.meta.url);
1022
+ const template = fs.readFileSync(fileURLToPath(templateUrl), 'utf-8');
1023
+ fs.mkdirSync(pathDirname(policyPath), { recursive: true });
1024
+ fs.writeFileSync(policyPath, template, { encoding: 'utf-8' });
1025
+ const structured = {
1026
+ policyPath,
1027
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
1028
+ bytesWritten: Buffer.byteLength(template, 'utf-8'),
1029
+ overwritten: doForce,
1030
+ };
1031
+ return {
1032
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1033
+ structuredContent: structured,
1034
+ };
1035
+ });
1036
+ // ---- policy_migrate ------------------------------------------------------
1037
+ server.registerTool('policy_migrate', {
1038
+ title: 'Migrate a policy file to the latest supported schema',
1039
+ description: 'Upgrades the policy file\'s schema version in place while preserving comments. ' +
1040
+ 'Safe by default: if the migrated document would fail schema validation, the file is NOT rewritten ' +
1041
+ 'and the tool returns status="precheck-failed" with the list of errors. ' +
1042
+ 'Pass dryRun=true to preview without touching the file. ' +
1043
+ 'Currently the only supported upgrade path is v0.1 → v0.2.',
1044
+ _meta: { agentSafetyTier: 'action' },
1045
+ inputSchema: z.object({
1046
+ path: z.string().optional().describe('Optional policy file path; defaults to the resolved default path'),
1047
+ dryRun: z.boolean().optional().describe('When true, report what would change without writing'),
1048
+ to: z.string().optional().describe(`Target schema version (default: latest supported, "${LATEST_SUPPORTED_VERSION}")`),
1049
+ }).strict(),
1050
+ outputSchema: {
1051
+ policyPath: z.string(),
1052
+ fileVersion: z.string().optional(),
1053
+ targetVersion: z.string(),
1054
+ supportedVersions: z.array(z.string()),
1055
+ status: z.enum([
1056
+ 'already-current',
1057
+ 'migrated',
1058
+ 'dry-run',
1059
+ 'no-version-field',
1060
+ 'unsupported',
1061
+ 'precheck-failed',
1062
+ 'file-not-found',
1063
+ ]),
1064
+ from: z.string().optional(),
1065
+ to: z.string().optional(),
1066
+ bytesWritten: z.number().optional(),
1067
+ message: z.string(),
1068
+ errors: z
1069
+ .array(z.object({ path: z.string(), keyword: z.string(), message: z.string() }))
1070
+ .optional(),
1071
+ },
1072
+ }, async ({ path: pathArg, dryRun, to }) => {
1073
+ const policyPath = resolvePolicyPath({ flag: pathArg });
1074
+ const target = (to ?? LATEST_SUPPORTED_VERSION);
1075
+ let loaded;
1076
+ try {
1077
+ loaded = loadPolicyFile(policyPath);
1078
+ }
1079
+ catch (err) {
1080
+ if (err instanceof PolicyFileNotFoundError) {
1081
+ const structured = {
1082
+ policyPath,
1083
+ targetVersion: target,
1084
+ supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
1085
+ status: 'file-not-found',
1086
+ message: `policy file not found: ${policyPath}`,
1087
+ };
1088
+ return {
1089
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1090
+ structuredContent: structured,
1091
+ };
1092
+ }
1093
+ throw err;
1094
+ }
1095
+ const data = loaded.data;
1096
+ const fileVersion = typeof data?.version === 'string' ? data.version : undefined;
1097
+ const base = {
1098
+ policyPath,
1099
+ fileVersion,
1100
+ targetVersion: target,
1101
+ supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
1102
+ };
1103
+ if (!fileVersion) {
1104
+ const structured = {
1105
+ ...base,
1106
+ status: 'no-version-field',
1107
+ message: `policy has no \`version\` field — add \`version: "${CURRENT_POLICY_SCHEMA_VERSION}"\``,
1108
+ };
1109
+ return {
1110
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1111
+ structuredContent: structured,
1112
+ };
1113
+ }
1114
+ if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
1115
+ const structured = {
1116
+ ...base,
1117
+ status: 'unsupported',
1118
+ message: `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(', ')})`,
1119
+ };
1120
+ return {
1121
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1122
+ structuredContent: structured,
1123
+ };
1124
+ }
1125
+ if (fileVersion === target) {
1126
+ const structured = {
1127
+ ...base,
1128
+ status: 'already-current',
1129
+ message: `already on schema v${target}; no migration needed`,
1130
+ bytesWritten: 0,
1131
+ };
1132
+ return {
1133
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1134
+ structuredContent: structured,
1135
+ };
1136
+ }
1137
+ const plan = planMigration(loaded, fileVersion, target);
1138
+ if (!plan.precheck.valid) {
1139
+ const structured = {
1140
+ ...base,
1141
+ status: 'precheck-failed',
1142
+ message: `migrated policy fails schema v${target} precheck; file not written`,
1143
+ errors: plan.precheck.errors.map((e) => ({ path: e.path, keyword: e.keyword, message: e.message })),
1144
+ };
1145
+ return {
1146
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1147
+ structuredContent: structured,
1148
+ };
1149
+ }
1150
+ const bytes = Buffer.byteLength(plan.nextSource, 'utf-8');
1151
+ if (dryRun) {
1152
+ const structured = {
1153
+ ...base,
1154
+ status: 'dry-run',
1155
+ from: plan.fromVersion,
1156
+ to: plan.toVersion,
1157
+ bytesWritten: 0,
1158
+ message: `dry-run: would upgrade v${plan.fromVersion} → v${plan.toVersion} (${bytes} bytes)`,
1159
+ };
1160
+ return {
1161
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1162
+ structuredContent: structured,
1163
+ };
1164
+ }
1165
+ writeFileSync(policyPath, plan.nextSource, { encoding: 'utf-8' });
1166
+ const structured = {
1167
+ ...base,
1168
+ status: 'migrated',
1169
+ from: plan.fromVersion,
1170
+ to: plan.toVersion,
1171
+ bytesWritten: bytes,
1172
+ message: `migrated ${policyPath} to schema v${plan.toVersion} (from v${plan.fromVersion})`,
1173
+ };
1174
+ return {
1175
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
1176
+ structuredContent: structured,
1177
+ };
1178
+ });
1179
+ // ---- policy_diff ---------------------------------------------------------
1180
+ server.registerTool('policy_diff', {
1181
+ title: 'Compare two policy files',
1182
+ description: 'Compare two policy YAML files and return the same contract as `switchbot --json policy diff`: ' +
1183
+ '{ leftPath, rightPath, equal, changeCount, truncated, stats, changes, diff }.',
1184
+ _meta: { agentSafetyTier: 'read' },
1185
+ inputSchema: z.object({
1186
+ left_path: z.string().min(1).describe('Path to the baseline policy file.'),
1187
+ right_path: z.string().min(1).describe('Path to the candidate policy file.'),
1188
+ }).strict(),
1189
+ outputSchema: {
1190
+ leftPath: z.string(),
1191
+ rightPath: z.string(),
1192
+ equal: z.boolean(),
1193
+ changeCount: z.number().int(),
1194
+ truncated: z.boolean(),
1195
+ stats: z.object({
1196
+ added: z.number().int(),
1197
+ removed: z.number().int(),
1198
+ changed: z.number().int(),
1199
+ }),
1200
+ changes: z.array(z.object({
1201
+ path: z.string(),
1202
+ kind: z.enum(['added', 'removed', 'changed']),
1203
+ before: z.unknown().optional(),
1204
+ after: z.unknown().optional(),
1205
+ })),
1206
+ diff: z.string(),
1207
+ },
1208
+ }, ({ left_path, right_path }) => {
1209
+ let leftSource = '';
1210
+ let rightSource = '';
1211
+ try {
1212
+ leftSource = fs.readFileSync(left_path, 'utf-8');
1213
+ }
1214
+ catch (err) {
1215
+ if (err?.code === 'ENOENT') {
1216
+ return mcpError('usage', 2, `policy file not found: ${left_path}`, {
1217
+ context: { policyPath: left_path },
1218
+ });
1219
+ }
1220
+ return mcpError('runtime', 1, `failed to read ${left_path}: ${String(err)}`);
1221
+ }
1222
+ try {
1223
+ rightSource = fs.readFileSync(right_path, 'utf-8');
1224
+ }
1225
+ catch (err) {
1226
+ if (err?.code === 'ENOENT') {
1227
+ return mcpError('usage', 2, `policy file not found: ${right_path}`, {
1228
+ context: { policyPath: right_path },
1229
+ });
1230
+ }
1231
+ return mcpError('runtime', 1, `failed to read ${right_path}: ${String(err)}`);
1232
+ }
1233
+ let leftDoc;
1234
+ let rightDoc;
1235
+ try {
1236
+ leftDoc = yamlParse(leftSource);
1237
+ }
1238
+ catch (err) {
1239
+ return mcpError('usage', 2, `YAML parse error in ${left_path}: ${err.message}`);
1240
+ }
1241
+ try {
1242
+ rightDoc = yamlParse(rightSource);
1243
+ }
1244
+ catch (err) {
1245
+ return mcpError('usage', 2, `YAML parse error in ${right_path}: ${err.message}`);
1246
+ }
1247
+ const result = {
1248
+ leftPath: left_path,
1249
+ rightPath: right_path,
1250
+ ...diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource),
1251
+ };
1252
+ return {
1253
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1254
+ structuredContent: result,
1255
+ };
1256
+ });
784
1257
  // switchbot://events resource — snapshot of recent shadow events from the ring buffer.
785
1258
  // Returns up to 100 recent events. When MQTT is disabled, returns an empty list with a state note.
786
1259
  // URI: switchbot://events (optional query: ?filter=<expression> ?limit=<n>)
@@ -802,6 +1275,404 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
802
1275
  };
803
1276
  });
804
1277
  }
1278
+ // ---- plan_suggest ---------------------------------------------------------
1279
+ server.registerTool('plan_suggest', {
1280
+ title: 'Draft a SwitchBot execution plan from intent',
1281
+ description: 'Generate a candidate Plan JSON from a natural language intent and a list of device IDs. ' +
1282
+ 'Uses keyword heuristics (no LLM) to pick the command. The returned plan is ready to pass to ' +
1283
+ '`plan run` — review and edit before executing. Recognised commands: turnOn, turnOff, press, ' +
1284
+ 'lock, unlock, open, close, pause. Falls back to turnOn with a warning when intent is unclear.',
1285
+ _meta: { agentSafetyTier: 'read' },
1286
+ inputSchema: z.object({
1287
+ intent: z.string().min(1).describe('Natural language description of what to do (e.g. "turn off all lights").'),
1288
+ device_ids: z.array(z.string().min(1)).min(1).describe('Device IDs to act on.'),
1289
+ }).strict(),
1290
+ outputSchema: {
1291
+ plan: z.unknown().describe('Candidate Plan JSON (version 1.0) ready to pass to plan run.'),
1292
+ warnings: z.array(z.string()).describe('Informational warnings (e.g. unrecognized intent defaulted to turnOn).'),
1293
+ },
1294
+ }, ({ intent, device_ids }) => {
1295
+ const devices = device_ids.map((id) => {
1296
+ const cached = getCachedDevice(id);
1297
+ return { id, name: cached?.name, type: cached?.type };
1298
+ });
1299
+ try {
1300
+ const { plan, warnings } = suggestPlan({ intent, devices });
1301
+ return {
1302
+ content: [{ type: 'text', text: JSON.stringify({ plan, warnings }, null, 2) }],
1303
+ structuredContent: { plan, warnings },
1304
+ };
1305
+ }
1306
+ catch (err) {
1307
+ return apiErrorToMcpError(err);
1308
+ }
1309
+ });
1310
+ // ---- plan_run -------------------------------------------------------------
1311
+ server.registerTool('plan_run', {
1312
+ title: 'Validate and execute a SwitchBot plan',
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. ' +
1314
+ 'Scene and wait steps run in order. Returns per-step results and a summary.',
1315
+ _meta: { agentSafetyTier: 'action' },
1316
+ inputSchema: z.object({
1317
+ plan: z.unknown().describe('Plan JSON object (same schema as `switchbot plan run`).'),
1318
+ yes: z.boolean().optional().describe('Authorize destructive command steps.'),
1319
+ continue_on_error: z.boolean().optional().describe('Keep executing later steps after a failed step.'),
1320
+ }).strict(),
1321
+ outputSchema: {
1322
+ ran: z.boolean(),
1323
+ plan: z.unknown(),
1324
+ results: z.array(z.unknown()),
1325
+ summary: z.object({
1326
+ total: z.number().int(),
1327
+ ok: z.number().int(),
1328
+ error: z.number().int(),
1329
+ skipped: z.number().int(),
1330
+ }),
1331
+ },
1332
+ }, async ({ plan, yes, continue_on_error }) => {
1333
+ const validated = validatePlan(plan);
1334
+ if (!validated.ok) {
1335
+ return mcpError('usage', 2, 'plan invalid', {
1336
+ context: { issues: validated.issues },
1337
+ hint: 'Fix the reported issues and retry plan_run.',
1338
+ });
1339
+ }
1340
+ const out = {
1341
+ ran: true,
1342
+ plan: validated.plan,
1343
+ results: [],
1344
+ summary: { total: validated.plan.steps.length, ok: 0, error: 0, skipped: 0 },
1345
+ };
1346
+ const continueOnError = continue_on_error === true;
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
+ }
1380
+ for (let i = 0; i < validated.plan.steps.length; i++) {
1381
+ const step = validated.plan.steps[i];
1382
+ const idx = i + 1;
1383
+ if (step.type === 'wait') {
1384
+ await new Promise((resolve) => setTimeout(resolve, step.ms));
1385
+ out.results.push({ step: idx, type: 'wait', ms: step.ms, status: 'ok' });
1386
+ out.summary.ok++;
1387
+ continue;
1388
+ }
1389
+ if (step.type === 'scene') {
1390
+ try {
1391
+ await executeScene(step.sceneId);
1392
+ out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'ok' });
1393
+ out.summary.ok++;
1394
+ }
1395
+ catch (err) {
1396
+ const msg = err instanceof Error ? err.message : String(err);
1397
+ out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'error', error: msg });
1398
+ out.summary.error++;
1399
+ if (!continueOnError)
1400
+ break;
1401
+ }
1402
+ continue;
1403
+ }
1404
+ let resolvedDeviceId = '';
1405
+ try {
1406
+ resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
1407
+ const commandType = step.commandType ?? 'command';
1408
+ const deviceType = getCachedDevice(resolvedDeviceId)?.type;
1409
+ const destructive = isDestructiveCommand(deviceType, step.command, commandType);
1410
+ if (destructive && !allowDestructive) {
1411
+ out.results.push({
1412
+ step: idx,
1413
+ type: 'command',
1414
+ deviceId: resolvedDeviceId,
1415
+ command: step.command,
1416
+ status: 'skipped',
1417
+ error: 'destructive — rerun with yes=true',
1418
+ });
1419
+ out.summary.skipped++;
1420
+ if (!continueOnError)
1421
+ break;
1422
+ continue;
1423
+ }
1424
+ await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
1425
+ out.results.push({
1426
+ step: idx,
1427
+ type: 'command',
1428
+ deviceId: resolvedDeviceId,
1429
+ command: step.command,
1430
+ status: 'ok',
1431
+ });
1432
+ out.summary.ok++;
1433
+ }
1434
+ catch (err) {
1435
+ if (err instanceof Error && err.name === 'DryRunSignal') {
1436
+ out.results.push({
1437
+ step: idx,
1438
+ type: 'command',
1439
+ deviceId: resolvedDeviceId || step.deviceId || 'unknown',
1440
+ command: step.command,
1441
+ status: 'ok',
1442
+ });
1443
+ out.summary.ok++;
1444
+ continue;
1445
+ }
1446
+ const msg = err instanceof Error ? err.message : String(err);
1447
+ out.results.push({
1448
+ step: idx,
1449
+ type: 'command',
1450
+ deviceId: resolvedDeviceId || step.deviceId || 'unknown',
1451
+ command: step.command,
1452
+ status: 'error',
1453
+ error: msg,
1454
+ });
1455
+ out.summary.error++;
1456
+ if (!continueOnError)
1457
+ break;
1458
+ }
1459
+ }
1460
+ return {
1461
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1462
+ structuredContent: out,
1463
+ };
1464
+ });
1465
+ // ---- audit_query ----------------------------------------------------------
1466
+ server.registerTool('audit_query', {
1467
+ title: 'Query command/rule audit log entries',
1468
+ description: 'Filter entries from the local audit log (default ~/.switchbot/audit.log) by time range, kind, device, rule, and result. ' +
1469
+ 'Useful for review flows and rule-fire inspection without leaving MCP.',
1470
+ _meta: { agentSafetyTier: 'read' },
1471
+ inputSchema: z.object({
1472
+ file: z.string().optional().describe('Optional audit log path; defaults to ~/.switchbot/audit.log.'),
1473
+ since: z.string().optional().describe('Relative window ending now (e.g. "30m", "24h"). Mutually exclusive with from/to.'),
1474
+ from: z.string().optional().describe('Range start (ISO-8601).'),
1475
+ to: z.string().optional().describe('Range end (ISO-8601).'),
1476
+ kinds: z.array(z.enum(['command', 'rule-fire', 'rule-fire-dry', 'rule-throttled', 'rule-webhook-rejected'])).optional().describe('Filter by entry kind.'),
1477
+ device_id: z.string().optional().describe('Filter by deviceId.'),
1478
+ rule_name: z.string().optional().describe('Filter by rule.name (rule-engine entries).'),
1479
+ results: z.array(z.enum(['ok', 'error'])).optional().describe('Filter by execution result.'),
1480
+ limit: z.number().int().min(1).max(5000).optional().describe('Max entries returned from the tail of the filtered set (default 200).'),
1481
+ }).strict(),
1482
+ outputSchema: {
1483
+ file: z.string(),
1484
+ totalMatched: z.number().int(),
1485
+ returned: z.number().int(),
1486
+ entries: z.array(z.unknown()),
1487
+ },
1488
+ }, ({ file, since, from, to, kinds, device_id, rule_name, results, limit }) => {
1489
+ const filePath = file ?? DEFAULT_AUDIT_LOG_FILE;
1490
+ const entries = readAudit(filePath);
1491
+ try {
1492
+ const filtered = filterAuditEntries(entries, {
1493
+ since,
1494
+ from,
1495
+ to,
1496
+ kinds,
1497
+ deviceId: device_id,
1498
+ ruleName: rule_name,
1499
+ results,
1500
+ });
1501
+ const bounded = filtered.slice(-Math.max(1, limit ?? 200));
1502
+ const out = {
1503
+ file: filePath,
1504
+ totalMatched: filtered.length,
1505
+ returned: bounded.length,
1506
+ entries: bounded,
1507
+ };
1508
+ return {
1509
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1510
+ structuredContent: out,
1511
+ };
1512
+ }
1513
+ catch (err) {
1514
+ return mcpError('usage', 2, err instanceof Error ? err.message : 'invalid audit query options');
1515
+ }
1516
+ });
1517
+ // ---- audit_stats ----------------------------------------------------------
1518
+ server.registerTool('audit_stats', {
1519
+ title: 'Aggregate audit log counts for review dashboards',
1520
+ description: 'Compute summary counters over the local audit log: by kind, by result, top devices, and top rules. ' +
1521
+ 'Supports the same filters as audit_query.',
1522
+ _meta: { agentSafetyTier: 'read' },
1523
+ inputSchema: z.object({
1524
+ file: z.string().optional().describe('Optional audit log path; defaults to ~/.switchbot/audit.log.'),
1525
+ since: z.string().optional().describe('Relative window ending now (e.g. "6h"). Mutually exclusive with from/to.'),
1526
+ from: z.string().optional().describe('Range start (ISO-8601).'),
1527
+ to: z.string().optional().describe('Range end (ISO-8601).'),
1528
+ kinds: z.array(z.enum(['command', 'rule-fire', 'rule-fire-dry', 'rule-throttled', 'rule-webhook-rejected'])).optional().describe('Filter by entry kind.'),
1529
+ device_id: z.string().optional().describe('Filter by deviceId.'),
1530
+ rule_name: z.string().optional().describe('Filter by rule.name (rule-engine entries).'),
1531
+ results: z.array(z.enum(['ok', 'error'])).optional().describe('Filter by execution result.'),
1532
+ top_n: z.number().int().min(1).max(100).optional().describe('Number of top device/rule rows to return (default 10).'),
1533
+ }).strict(),
1534
+ outputSchema: {
1535
+ file: z.string(),
1536
+ totalMatched: z.number().int(),
1537
+ byKind: z.record(z.string(), z.number().int()),
1538
+ byResult: z.record(z.string(), z.number().int()),
1539
+ topDevices: z.array(z.object({ deviceId: z.string(), count: z.number().int() })),
1540
+ topRules: z.array(z.object({ ruleName: z.string(), count: z.number().int() })),
1541
+ },
1542
+ }, ({ file, since, from, to, kinds, device_id, rule_name, results, top_n }) => {
1543
+ const filePath = file ?? DEFAULT_AUDIT_LOG_FILE;
1544
+ const entries = readAudit(filePath);
1545
+ try {
1546
+ const filtered = filterAuditEntries(entries, {
1547
+ since,
1548
+ from,
1549
+ to,
1550
+ kinds,
1551
+ deviceId: device_id,
1552
+ ruleName: rule_name,
1553
+ results,
1554
+ });
1555
+ const byKind = new Map();
1556
+ const byResult = new Map();
1557
+ const byDevice = new Map();
1558
+ const byRule = new Map();
1559
+ for (const entry of filtered) {
1560
+ byKind.set(entry.kind, (byKind.get(entry.kind) ?? 0) + 1);
1561
+ if (entry.result)
1562
+ byResult.set(entry.result, (byResult.get(entry.result) ?? 0) + 1);
1563
+ if (entry.deviceId)
1564
+ byDevice.set(entry.deviceId, (byDevice.get(entry.deviceId) ?? 0) + 1);
1565
+ if (entry.rule?.name)
1566
+ byRule.set(entry.rule.name, (byRule.get(entry.rule.name) ?? 0) + 1);
1567
+ }
1568
+ const topN = top_n ?? 10;
1569
+ const out = {
1570
+ file: filePath,
1571
+ totalMatched: filtered.length,
1572
+ byKind: Object.fromEntries([...byKind.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
1573
+ byResult: Object.fromEntries([...byResult.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
1574
+ topDevices: topNFromMap(byDevice, topN).map((item) => ({ deviceId: item.key, count: item.count })),
1575
+ topRules: topNFromMap(byRule, topN).map((item) => ({ ruleName: item.key, count: item.count })),
1576
+ };
1577
+ return {
1578
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1579
+ structuredContent: out,
1580
+ };
1581
+ }
1582
+ catch (err) {
1583
+ return mcpError('usage', 2, err instanceof Error ? err.message : 'invalid audit stats options');
1584
+ }
1585
+ });
1586
+ // ---- rules_suggest --------------------------------------------------------
1587
+ server.registerTool('rules_suggest', {
1588
+ title: 'Draft a SwitchBot automation rule from intent',
1589
+ description: 'Generate a candidate automation rule YAML from a natural language intent. ' +
1590
+ 'Uses keyword heuristics (no LLM) to infer trigger, schedule, and command. ' +
1591
+ 'Always emits dry_run: true — the rule must be reviewed before arming. ' +
1592
+ 'Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.',
1593
+ _meta: { agentSafetyTier: 'read' },
1594
+ inputSchema: z.object({
1595
+ intent: z.string().min(1).describe('Natural language description (e.g. "turn off lights at 10pm").'),
1596
+ trigger: z.enum(['mqtt', 'cron', 'webhook']).optional().describe('Trigger type (inferred from intent if omitted).'),
1597
+ device_ids: z.array(z.string().min(1)).optional().describe('Device IDs; first is sensor for mqtt triggers, rest are action targets.'),
1598
+ event: z.string().optional().describe('MQTT event name override (e.g. motion.detected).'),
1599
+ schedule: z.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
1600
+ days: z.array(z.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
1601
+ webhook_path: z.string().optional().describe('Webhook path override (default /action).'),
1602
+ }).strict(),
1603
+ outputSchema: {
1604
+ rule: z.unknown().describe('Rule object matching the v0.2 policy schema.'),
1605
+ rule_yaml: z.string().describe('YAML string ready to pipe to policy_add_rule.'),
1606
+ warnings: z.array(z.string()).describe('Informational warnings (e.g. unrecognized intent defaulted).'),
1607
+ },
1608
+ }, ({ intent, trigger, device_ids, event, schedule, days, webhook_path }) => {
1609
+ const devices = (device_ids ?? []).map((id) => {
1610
+ const cached = getCachedDevice(id);
1611
+ return { id, name: cached?.name, type: cached?.type };
1612
+ });
1613
+ try {
1614
+ const { rule, ruleYaml, warnings } = suggestRule({
1615
+ intent,
1616
+ trigger,
1617
+ devices,
1618
+ event,
1619
+ schedule,
1620
+ days,
1621
+ webhookPath: webhook_path,
1622
+ });
1623
+ return {
1624
+ content: [{ type: 'text', text: ruleYaml }],
1625
+ structuredContent: { rule, rule_yaml: ruleYaml, warnings },
1626
+ };
1627
+ }
1628
+ catch (err) {
1629
+ return apiErrorToMcpError(err);
1630
+ }
1631
+ });
1632
+ // ---- policy_add_rule ------------------------------------------------------
1633
+ server.registerTool('policy_add_rule', {
1634
+ title: 'Append a rule to automation.rules[] in policy.yaml',
1635
+ description: 'Inject a rule YAML snippet (as produced by rules_suggest) into the automation.rules[] ' +
1636
+ 'array in policy.yaml. Preserves existing comments and formatting. ' +
1637
+ 'Always run with dry_run: true first so the agent can show the diff for user approval. ' +
1638
+ 'Never set enable_automation: true without explicitly informing the user.',
1639
+ _meta: { agentSafetyTier: 'action' },
1640
+ inputSchema: z.object({
1641
+ rule_yaml: z.string().min(1).describe('YAML string of a single rule object (e.g. from rules_suggest).'),
1642
+ policy_path: z.string().optional().describe('Path to policy.yaml (defaults to $SWITCHBOT_POLICY_PATH or ~/.switchbot/policy.yaml).'),
1643
+ enable_automation: z.boolean().default(false).describe('If true, sets automation.enabled: true after inserting the rule.'),
1644
+ dry_run: z.boolean().default(false).describe('If true, compute and return the diff without writing to disk.'),
1645
+ force: z.boolean().default(false).describe('If true, overwrite an existing rule with the same name.'),
1646
+ }).strict(),
1647
+ outputSchema: {
1648
+ policyPath: z.string().describe('Resolved path to the policy file.'),
1649
+ ruleName: z.string().describe('Name of the rule that was (or would be) inserted.'),
1650
+ written: z.boolean().describe('True when the file was actually written.'),
1651
+ diff: z.string().describe('Unified-style diff showing lines added/removed.'),
1652
+ },
1653
+ }, ({ rule_yaml, policy_path, enable_automation, dry_run, force }) => {
1654
+ const policyPath = resolvePolicyPath({ flag: policy_path });
1655
+ try {
1656
+ const result = addRuleToPolicyFile({
1657
+ ruleYaml: rule_yaml,
1658
+ policyPath,
1659
+ enableAutomation: enable_automation,
1660
+ dryRun: dry_run,
1661
+ force,
1662
+ });
1663
+ const out = { policyPath, ruleName: result.ruleName, written: result.written, diff: result.diff };
1664
+ return {
1665
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
1666
+ structuredContent: out,
1667
+ };
1668
+ }
1669
+ catch (err) {
1670
+ if (err instanceof AddRuleError) {
1671
+ return apiErrorToMcpError(new Error(`${err.code}: ${err.message}`));
1672
+ }
1673
+ return apiErrorToMcpError(err);
1674
+ }
1675
+ });
805
1676
  return server;
806
1677
  }
807
1678
  /**
@@ -821,7 +1692,7 @@ export function registerMcpCommand(program) {
821
1692
  .command('mcp')
822
1693
  .description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
823
1694
  .addHelpText('after', `
824
- The MCP server exposes eleven tools:
1695
+ The MCP server exposes twenty-one tools:
825
1696
  - list_devices fetch all physical + IR devices
826
1697
  - get_device_status live status for a physical device
827
1698
  - send_command control a device (destructive commands need confirm:true)
@@ -833,6 +1704,16 @@ The MCP server exposes eleven tools:
833
1704
  - get_device_history fetch raw JSONL history records for a device
834
1705
  - query_device_history filter + page history records with field/time predicates
835
1706
  - aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records
1707
+ - policy_validate check policy.yaml against the embedded schema (v0.1 / v0.2)
1708
+ - policy_new scaffold a starter policy.yaml (action — confirm first)
1709
+ - policy_migrate upgrade policy.yaml to the latest schema (action — preserves comments)
1710
+ - policy_diff compare two policy files with structural + line diff output
1711
+ - plan_suggest draft a Plan JSON from intent + device IDs (heuristic, no LLM)
1712
+ - plan_run validate + execute a Plan JSON document
1713
+ - audit_query filter audit log entries by time/device/rule/result
1714
+ - audit_stats aggregate audit counts by kind/result/device/rule
1715
+ - rules_suggest draft an automation rule YAML from intent (heuristic, no LLM)
1716
+ - policy_add_rule append a rule into automation.rules[] in policy.yaml
836
1717
 
837
1718
  Resource (read-only):
838
1719
  - switchbot://events snapshot of recent MQTT shadow events from the ring buffer