@switchbot/openapi-cli 3.4.1 → 3.6.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 (3) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +2444 -1597
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7622,6 +7622,15 @@ function deriveStatusQueries(entry) {
7622
7622
  safetyTier: "read"
7623
7623
  }));
7624
7624
  }
7625
+ function canonicalizeDeviceType(deviceType) {
7626
+ const catalog = getEffectiveCatalog();
7627
+ const lower = deviceType.toLowerCase();
7628
+ for (const entry of catalog) {
7629
+ if (entry.type.toLowerCase() === lower) return entry.type;
7630
+ if (entry.aliases?.some((a) => a.toLowerCase() === lower)) return entry.type;
7631
+ }
7632
+ return deviceType;
7633
+ }
7625
7634
  function findCatalogEntry(query) {
7626
7635
  const q = query.trim().toLowerCase();
7627
7636
  if (!q) return null;
@@ -7735,7 +7744,7 @@ function getEffectiveCatalog() {
7735
7744
  }
7736
7745
  return Array.from(byType.values());
7737
7746
  }
7738
- var CATALOG_SCHEMA_VERSION, STATUS_FIELD_DESCRIPTIONS, onOff, onOffToggle, lightControls, DEVICE_CATALOG, overlayCache;
7747
+ var CATALOG_SCHEMA_VERSION, STATUS_FIELD_DESCRIPTIONS, onOff, onOffToggle, lightControls, rgbLightControls0To100, rgbOnlyLightControls0To100, DEVICE_CATALOG, overlayCache;
7739
7748
  var init_catalog = __esm({
7740
7749
  "src/devices/catalog.ts"() {
7741
7750
  "use strict";
@@ -7803,6 +7812,15 @@ var init_catalog = __esm({
7803
7812
  { command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] },
7804
7813
  { command: "setColorTemperature", parameter: "2700-6500", description: "Set color temperature (Kelvin)", idempotent: true, exampleParams: ["2700", "4000", "6500"] }
7805
7814
  ];
7815
+ rgbLightControls0To100 = [
7816
+ { command: "setBrightness", parameter: "0-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["0", "50", "100"] },
7817
+ { command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] },
7818
+ { command: "setColorTemperature", parameter: "2700-6500", description: "Set color temperature (Kelvin)", idempotent: true, exampleParams: ["2700", "4000", "6500"] }
7819
+ ];
7820
+ rgbOnlyLightControls0To100 = [
7821
+ { command: "setBrightness", parameter: "0-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["0", "50", "100"] },
7822
+ { command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] }
7823
+ ];
7806
7824
  DEVICE_CATALOG = [
7807
7825
  // ---------- Physical devices ----------
7808
7826
  {
@@ -7835,7 +7853,7 @@ var init_catalog = __esm({
7835
7853
  category: "physical",
7836
7854
  description: "Bluetooth/Wi-Fi electronic deadbolt that locks and unlocks a door via cloud API.",
7837
7855
  role: "security",
7838
- aliases: ["Smart Lock Pro"],
7856
+ aliases: ["Lock", "Smart Lock Pro", "Lock Pro"],
7839
7857
  commands: [
7840
7858
  { command: "lock", parameter: "\u2014", description: "Lock the door", idempotent: true },
7841
7859
  { command: "unlock", parameter: "\u2014", description: "Unlock the door", idempotent: true, safetyTier: "destructive", safetyReason: "Physically unlocks the door \u2014 anyone nearby can open it." },
@@ -7848,6 +7866,7 @@ var init_catalog = __esm({
7848
7866
  category: "physical",
7849
7867
  description: "Compact electronic deadbolt with lock and unlock control; no deadbolt mode.",
7850
7868
  role: "security",
7869
+ aliases: ["Lock Lite"],
7851
7870
  commands: [
7852
7871
  { command: "lock", parameter: "\u2014", description: "Lock the door", idempotent: true },
7853
7872
  { command: "unlock", parameter: "\u2014", description: "Unlock the door", idempotent: true, safetyTier: "destructive", safetyReason: "Physically unlocks the door \u2014 anyone nearby can open it." }
@@ -7859,6 +7878,7 @@ var init_catalog = __esm({
7859
7878
  category: "physical",
7860
7879
  description: "Premium electronic deadbolt with full lock, unlock, and deadbolt control.",
7861
7880
  role: "security",
7881
+ aliases: ["Lock Ultra"],
7862
7882
  commands: [
7863
7883
  { command: "lock", parameter: "\u2014", description: "Lock the door", idempotent: true },
7864
7884
  { command: "unlock", parameter: "\u2014", description: "Unlock the door", idempotent: true, safetyTier: "destructive", safetyReason: "Physically unlocks the door \u2014 anyone nearby can open it." },
@@ -7869,9 +7889,9 @@ var init_catalog = __esm({
7869
7889
  {
7870
7890
  type: "Plug",
7871
7891
  category: "physical",
7872
- description: "Smart wall outlet plug with on/off/toggle control and basic power status.",
7892
+ description: "Smart wall outlet plug with on/off control and basic power status.",
7873
7893
  role: "power",
7874
- commands: onOffToggle,
7894
+ commands: onOff,
7875
7895
  statusFields: ["power", "version"]
7876
7896
  },
7877
7897
  {
@@ -7879,7 +7899,7 @@ var init_catalog = __esm({
7879
7899
  category: "physical",
7880
7900
  description: "Compact smart plug with voltage, current, and daily energy consumption reporting.",
7881
7901
  role: "power",
7882
- aliases: ["Plug Mini (JP)"],
7902
+ aliases: ["Plug Mini (JP)", "Plug Mini (EU)"],
7883
7903
  commands: onOffToggle,
7884
7904
  statusFields: ["voltage", "weight", "electricityOfDay", "electricCurrent", "power", "version"]
7885
7905
  },
@@ -7957,12 +7977,63 @@ var init_catalog = __esm({
7957
7977
  {
7958
7978
  type: "Strip Light",
7959
7979
  category: "physical",
7960
- description: "Addressable LED strip with on/off, brightness, RGB color, and color temperature control.",
7980
+ description: "Addressable LED strip with on/off, brightness, and RGB color control.",
7961
7981
  role: "lighting",
7962
- aliases: ["Strip Light 3"],
7963
- commands: [...onOffToggle, ...lightControls],
7982
+ commands: [
7983
+ ...onOffToggle,
7984
+ { command: "setBrightness", parameter: "1-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["50", "80"] },
7985
+ { command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] }
7986
+ ],
7987
+ statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
7988
+ },
7989
+ {
7990
+ type: "Floor Lamp",
7991
+ category: "physical",
7992
+ description: "Smart floor lamp with 0-100 brightness, RGB color, and color temperature control.",
7993
+ role: "lighting",
7994
+ commands: [...onOffToggle, ...rgbLightControls0To100],
7964
7995
  statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
7965
7996
  },
7997
+ {
7998
+ type: "Strip Light 3",
7999
+ category: "physical",
8000
+ description: "Third-generation strip light with 0-100 brightness, RGB color, and color temperature control.",
8001
+ role: "lighting",
8002
+ commands: [...onOffToggle, ...rgbLightControls0To100],
8003
+ statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
8004
+ },
8005
+ {
8006
+ type: "RGBICWW Strip Light",
8007
+ category: "physical",
8008
+ description: "RGBICWW strip light with 0-100 brightness, RGB color, and color temperature control.",
8009
+ role: "lighting",
8010
+ commands: [...onOffToggle, ...rgbLightControls0To100],
8011
+ statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
8012
+ },
8013
+ {
8014
+ type: "RGBICWW Floor Lamp",
8015
+ category: "physical",
8016
+ description: "RGBICWW floor lamp with 0-100 brightness, RGB color, and color temperature control.",
8017
+ role: "lighting",
8018
+ commands: [...onOffToggle, ...rgbLightControls0To100],
8019
+ statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
8020
+ },
8021
+ {
8022
+ type: "RGBIC Neon Wire Rope Light",
8023
+ category: "physical",
8024
+ description: "RGBIC neon wire rope light with 0-100 brightness and RGB color control.",
8025
+ role: "lighting",
8026
+ commands: [...onOffToggle, ...rgbOnlyLightControls0To100],
8027
+ statusFields: ["power", "brightness", "color", "version"]
8028
+ },
8029
+ {
8030
+ type: "RGBIC Neon Rope Light",
8031
+ category: "physical",
8032
+ description: "RGBIC neon rope light with 0-100 brightness and RGB color control.",
8033
+ role: "lighting",
8034
+ commands: [...onOffToggle, ...rgbOnlyLightControls0To100],
8035
+ statusFields: ["power", "brightness", "color", "version"]
8036
+ },
7966
8037
  {
7967
8038
  type: "Ceiling Light",
7968
8039
  category: "physical",
@@ -7984,7 +8055,7 @@ var init_catalog = __esm({
7984
8055
  commands: [
7985
8056
  ...onOff,
7986
8057
  { command: "setMode", parameter: "0=schedule 1=manual 2=off 3=eco 4=comfort 5=quickHeat", description: "Operating mode", idempotent: true, exampleParams: ["1", "3"] },
7987
- { command: "setManualModeTemperature", parameter: "5-30 (\xB0C)", description: "Target temperature in manual mode", idempotent: true, exampleParams: ["20", "22"] }
8058
+ { command: "setManualModeTemperature", parameter: "4-35 (\xB0C)", description: "Target temperature in manual mode", idempotent: true, exampleParams: ["20", "22"] }
7988
8059
  ],
7989
8060
  statusFields: ["power", "temperature", "humidity", "battery", "version", "mode", "targetTemperature"]
7990
8061
  },
@@ -7993,7 +8064,7 @@ var init_catalog = __esm({
7993
8064
  category: "physical",
7994
8065
  description: "Entry-level robot vacuum with start/stop/dock and four suction power levels.",
7995
8066
  role: "cleaning",
7996
- aliases: ["Robot Vacuum", "Robot Vacuum Cleaner S1 Plus", "K10+"],
8067
+ aliases: ["Robot Vacuum", "Robot Vacuum Cleaner S1 Plus", "K10+", "K10+ Pro"],
7997
8068
  commands: [
7998
8069
  { command: "start", parameter: "\u2014", description: "Start cleaning", idempotent: true },
7999
8070
  { command: "stop", parameter: "\u2014", description: "Stop cleaning", idempotent: true },
@@ -8003,17 +8074,17 @@ var init_catalog = __esm({
8003
8074
  statusFields: ["workingStatus", "onlineStatus", "battery", "version"]
8004
8075
  },
8005
8076
  {
8006
- type: "K10+ Pro Combo",
8077
+ type: "Robot Vacuum Cleaner K10+ Pro Combo",
8007
8078
  category: "physical",
8008
8079
  description: "Compact robot vacuum and mop combo with sweep/mop sessions, fan level, and water level.",
8009
8080
  role: "cleaning",
8010
- aliases: ["K20+ Pro"],
8081
+ aliases: ["K10+ Pro Combo", "K20+ Pro", "K11+", "Robot Vacuum Cleaner K11+"],
8011
8082
  commands: [
8012
8083
  { command: "startClean", parameter: `'{"action":"sweep"|"mop","param":{"fanLevel":1-4,"times":1-2639999}}'`, description: "Begin a cleaning session", idempotent: false, exampleParams: ['{"action":"sweep","param":{"fanLevel":2,"times":1}}'] },
8013
8084
  { command: "pause", parameter: "\u2014", description: "Pause cleaning", idempotent: true },
8014
8085
  { command: "dock", parameter: "\u2014", description: "Return to dock", idempotent: true },
8015
8086
  { command: "setVolume", parameter: "0-100", description: "Set voice volume", idempotent: true, exampleParams: ["0", "50", "100"] },
8016
- { command: "changeParam", parameter: `'{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}'`, description: "Change parameters mid-run", idempotent: true, exampleParams: ['{"fanLevel":3,"waterLevel":1,"times":1}'] }
8087
+ { command: "changeParam", parameter: `'{"fanLevel":1-4,"times":1-2639999}'`, description: "Change parameters mid-run", idempotent: true, exampleParams: ['{"fanLevel":3,"times":1}'] }
8017
8088
  ],
8018
8089
  statusFields: ["workingStatus", "onlineStatus", "battery", "taskType"]
8019
8090
  },
@@ -8022,7 +8093,7 @@ var init_catalog = __esm({
8022
8093
  category: "physical",
8023
8094
  description: "Advanced floor cleaning robot with sweep/mop modes, self-wash dock, and humidifier refill.",
8024
8095
  role: "cleaning",
8025
- aliases: ["Robot Vacuum Cleaner S10", "Robot Vacuum Cleaner S20"],
8096
+ aliases: ["Robot Vacuum Cleaner S10", "Robot Vacuum Cleaner S20", "S20"],
8026
8097
  commands: [
8027
8098
  { command: "startClean", parameter: `'{"action":"sweep"|"sweep_mop","param":{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}}'`, description: "Begin a cleaning session", idempotent: false, exampleParams: ['{"action":"sweep","param":{"fanLevel":2,"waterLevel":1,"times":1}}'] },
8028
8099
  { command: "pause", parameter: "\u2014", description: "Pause cleaning", idempotent: true },
@@ -8039,7 +8110,7 @@ var init_catalog = __esm({
8039
8110
  category: "physical",
8040
8111
  description: "Rechargeable table/floor fan with wind modes, speed control, night-light, and auto-off timer.",
8041
8112
  role: "fan",
8042
- aliases: ["Circulator Fan"],
8113
+ aliases: ["Circulator Fan", "Standing Circulator Fan"],
8043
8114
  commands: [
8044
8115
  ...onOffToggle,
8045
8116
  { command: "setNightLightMode", parameter: "off | 1 | 2", description: "Night-light mode", idempotent: true, exampleParams: ["off", "1", "2"] },
@@ -8101,7 +8172,7 @@ var init_catalog = __esm({
8101
8172
  category: "physical",
8102
8173
  description: "PIN-pad access controller that creates and deletes door passcodes for a Smart Lock.",
8103
8174
  role: "security",
8104
- aliases: ["Keypad Touch"],
8175
+ aliases: ["Keypad Touch", "Keypad Vision", "Keypad Vision Pro"],
8105
8176
  commands: [
8106
8177
  { command: "createKey", parameter: `'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":<s>,"endTime":<s>}'`, description: "Create a passcode (async; result via webhook)", idempotent: false, safetyTier: "destructive", safetyReason: "Provisions a new access credential \u2014 anyone with this passcode can unlock the door." },
8107
8178
  { command: "deleteKey", parameter: `'{"id":<passcode_id>}'`, description: "Delete a passcode (async; result via webhook)", idempotent: true, safetyTier: "destructive", safetyReason: "Permanently removes a passcode \u2014 the holder immediately loses door access." }
@@ -8111,15 +8182,25 @@ var init_catalog = __esm({
8111
8182
  {
8112
8183
  type: "Candle Warmer Lamp",
8113
8184
  category: "physical",
8114
- description: "Decorative candle-warmer lamp with adjustable brightness and color temperature.",
8185
+ description: "Decorative candle-warmer lamp with adjustable 0-100 brightness.",
8115
8186
  role: "lighting",
8116
8187
  commands: [
8117
8188
  ...onOffToggle,
8118
- { command: "setBrightness", parameter: "1-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["50", "80"] },
8119
- { command: "setColorTemperature", parameter: "2700-6500", description: "Set color temperature (Kelvin)", idempotent: true, exampleParams: ["2700", "4000"] }
8189
+ { command: "setBrightness", parameter: "0-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["0", "50", "100"] }
8120
8190
  ],
8121
8191
  statusFields: ["power", "brightness", "colorTemperature", "version"]
8122
8192
  },
8193
+ {
8194
+ type: "AI Art Frame",
8195
+ category: "physical",
8196
+ description: "Digital art frame that can switch to the next or previous image.",
8197
+ role: "other",
8198
+ commands: [
8199
+ { command: "next", parameter: "\u2014", description: "Switch to the next image", idempotent: false },
8200
+ { command: "previous", parameter: "\u2014", description: "Switch to the previous image", idempotent: false }
8201
+ ],
8202
+ statusFields: ["version"]
8203
+ },
8123
8204
  // Status-only devices (no commands)
8124
8205
  {
8125
8206
  type: "Meter",
@@ -8155,6 +8236,7 @@ var init_catalog = __esm({
8155
8236
  description: "Water sensor that reports leak status; read-only, no control commands.",
8156
8237
  role: "sensor",
8157
8238
  readOnly: true,
8239
+ aliases: ["Water Detector"],
8158
8240
  commands: [],
8159
8241
  statusFields: ["battery", "version", "status"]
8160
8242
  },
@@ -8380,9 +8462,8 @@ function updateCacheFromDeviceList(body) {
8380
8462
  for (const d of body.deviceList) {
8381
8463
  if (!d.deviceId) continue;
8382
8464
  devices[d.deviceId] = {
8383
- // Some real devices omit deviceType entirely (for example AI accessories).
8384
- // Keep them in cache with an empty type string rather than dropping the row.
8385
- type: d.deviceType ?? "",
8465
+ type: d.deviceType || d.controlType || "Unknown Device",
8466
+ typeSource: d.deviceType ? "deviceType" : d.controlType ? "controlType" : "deviceType",
8386
8467
  name: d.deviceName,
8387
8468
  category: "physical",
8388
8469
  hubDeviceId: d.hubDeviceId,
@@ -8397,6 +8478,7 @@ function updateCacheFromDeviceList(body) {
8397
8478
  if (!d.deviceId) continue;
8398
8479
  devices[d.deviceId] = {
8399
8480
  type: d.remoteType,
8481
+ typeSource: "remoteType",
8400
8482
  name: d.deviceName,
8401
8483
  category: "ir",
8402
8484
  hubDeviceId: d.hubDeviceId,
@@ -8793,10 +8875,11 @@ async function fetchDeviceList(client, options = {}) {
8793
8875
  const infraredRemoteList = [];
8794
8876
  for (const [deviceId, entry] of Object.entries(cached2.devices)) {
8795
8877
  if (entry.category === "physical") {
8878
+ const cachedDeviceType = entry.typeSource === "deviceType" ? entry.type : entry.typeSource === void 0 && entry.type !== entry.controlType ? entry.type : void 0;
8796
8879
  deviceList.push({
8797
8880
  deviceId,
8798
8881
  deviceName: entry.name,
8799
- ...entry.type ? { deviceType: entry.type } : {},
8882
+ ...cachedDeviceType && cachedDeviceType !== "Unknown Device" ? { deviceType: cachedDeviceType } : {},
8800
8883
  enableCloudService: entry.enableCloudService ?? true,
8801
8884
  hubDeviceId: entry.hubDeviceId ?? "",
8802
8885
  roomID: entry.roomID,
@@ -8867,10 +8950,12 @@ async function executeCommand(deviceId, cmd, parameter, commandType, client, opt
8867
8950
  if (err instanceof Error && err.name === "DryRunSignal") {
8868
8951
  writeAudit({ ...baseAudit, result: "dry-run" });
8869
8952
  } else {
8953
+ const statusCode = err instanceof ApiError ? err.code : void 0;
8870
8954
  writeAudit({
8871
8955
  ...baseAudit,
8872
8956
  result: "error",
8873
- error: err instanceof Error ? err.message : String(err)
8957
+ error: err instanceof Error ? err.message : String(err),
8958
+ ...statusCode !== void 0 ? { statusCode } : {}
8874
8959
  });
8875
8960
  }
8876
8961
  throw err;
@@ -8991,7 +9076,23 @@ async function describeDevice(deviceId, options = {}, client) {
8991
9076
  ir = infraredRemoteList.find((d) => d.deviceId === deviceId);
8992
9077
  }
8993
9078
  if (!physical && !ir) throw new DeviceNotFoundError(deviceId);
8994
- const typeName = physical ? physical.deviceType ?? "" : ir.remoteType;
9079
+ let typeName;
9080
+ let typeSource;
9081
+ if (physical) {
9082
+ if (physical.deviceType) {
9083
+ typeName = physical.deviceType;
9084
+ typeSource = "deviceType";
9085
+ } else if (physical.controlType) {
9086
+ typeName = physical.controlType;
9087
+ typeSource = "controlType";
9088
+ } else {
9089
+ typeName = "Unknown Device";
9090
+ typeSource = "deviceType";
9091
+ }
9092
+ } else {
9093
+ typeName = ir.remoteType;
9094
+ typeSource = "remoteType";
9095
+ }
8995
9096
  const match = typeName ? findCatalogEntry(typeName) : null;
8996
9097
  const catalogEntry = !match || Array.isArray(match) ? null : match;
8997
9098
  let liveStatus;
@@ -9027,6 +9128,7 @@ async function describeDevice(deviceId, options = {}, client) {
9027
9128
  device: selectedDevice,
9028
9129
  isPhysical: Boolean(physical),
9029
9130
  typeName,
9131
+ typeSource,
9030
9132
  controlType: physical?.controlType ?? ir?.controlType ?? null,
9031
9133
  catalog: catalogEntry,
9032
9134
  capabilities,
@@ -31710,6 +31812,162 @@ function applyFilter(clauses, deviceList, infraredRemoteList, hubLocation) {
31710
31812
  // src/devices/param-validator.ts
31711
31813
  init_cjs_shim();
31712
31814
  init_output();
31815
+
31816
+ // src/devices/css-colors.ts
31817
+ init_cjs_shim();
31818
+ var CSS_COLORS = {
31819
+ aliceblue: [240, 248, 255],
31820
+ antiquewhite: [250, 235, 215],
31821
+ aqua: [0, 255, 255],
31822
+ aquamarine: [127, 255, 212],
31823
+ azure: [240, 255, 255],
31824
+ beige: [245, 245, 220],
31825
+ bisque: [255, 228, 196],
31826
+ black: [0, 0, 0],
31827
+ blanchedalmond: [255, 235, 205],
31828
+ blue: [0, 0, 255],
31829
+ blueviolet: [138, 43, 226],
31830
+ brown: [165, 42, 42],
31831
+ burlywood: [222, 184, 135],
31832
+ cadetblue: [95, 158, 160],
31833
+ chartreuse: [127, 255, 0],
31834
+ chocolate: [210, 105, 30],
31835
+ coral: [255, 127, 80],
31836
+ cornflowerblue: [100, 149, 237],
31837
+ cornsilk: [255, 248, 220],
31838
+ crimson: [220, 20, 60],
31839
+ cyan: [0, 255, 255],
31840
+ darkblue: [0, 0, 139],
31841
+ darkcyan: [0, 139, 139],
31842
+ darkgoldenrod: [184, 134, 11],
31843
+ darkgray: [169, 169, 169],
31844
+ darkgreen: [0, 100, 0],
31845
+ darkgrey: [169, 169, 169],
31846
+ darkkhaki: [189, 183, 107],
31847
+ darkmagenta: [139, 0, 139],
31848
+ darkolivegreen: [85, 107, 47],
31849
+ darkorange: [255, 140, 0],
31850
+ darkorchid: [153, 50, 204],
31851
+ darkred: [139, 0, 0],
31852
+ darksalmon: [233, 150, 122],
31853
+ darkseagreen: [143, 188, 143],
31854
+ darkslateblue: [72, 61, 139],
31855
+ darkslategray: [47, 79, 79],
31856
+ darkslategrey: [47, 79, 79],
31857
+ darkturquoise: [0, 206, 209],
31858
+ darkviolet: [148, 0, 211],
31859
+ deeppink: [255, 20, 147],
31860
+ deepskyblue: [0, 191, 255],
31861
+ dimgray: [105, 105, 105],
31862
+ dimgrey: [105, 105, 105],
31863
+ dodgerblue: [30, 144, 255],
31864
+ firebrick: [178, 34, 34],
31865
+ floralwhite: [255, 250, 240],
31866
+ forestgreen: [34, 139, 34],
31867
+ fuchsia: [255, 0, 255],
31868
+ gainsboro: [220, 220, 220],
31869
+ ghostwhite: [248, 248, 255],
31870
+ gold: [255, 215, 0],
31871
+ goldenrod: [218, 165, 32],
31872
+ gray: [128, 128, 128],
31873
+ green: [0, 128, 0],
31874
+ greenyellow: [173, 255, 47],
31875
+ grey: [128, 128, 128],
31876
+ honeydew: [240, 255, 240],
31877
+ hotpink: [255, 105, 180],
31878
+ indianred: [205, 92, 92],
31879
+ indigo: [75, 0, 130],
31880
+ ivory: [255, 255, 240],
31881
+ khaki: [240, 230, 140],
31882
+ lavender: [230, 230, 250],
31883
+ lavenderblush: [255, 240, 245],
31884
+ lawngreen: [124, 252, 0],
31885
+ lemonchiffon: [255, 250, 205],
31886
+ lightblue: [173, 216, 230],
31887
+ lightcoral: [240, 128, 128],
31888
+ lightcyan: [224, 255, 255],
31889
+ lightgoldenrodyellow: [250, 250, 210],
31890
+ lightgray: [211, 211, 211],
31891
+ lightgreen: [144, 238, 144],
31892
+ lightgrey: [211, 211, 211],
31893
+ lightpink: [255, 182, 193],
31894
+ lightsalmon: [255, 160, 122],
31895
+ lightseagreen: [32, 178, 170],
31896
+ lightskyblue: [135, 206, 250],
31897
+ lightslategray: [119, 136, 153],
31898
+ lightslategrey: [119, 136, 153],
31899
+ lightsteelblue: [176, 196, 222],
31900
+ lightyellow: [255, 255, 224],
31901
+ lime: [0, 255, 0],
31902
+ limegreen: [50, 205, 50],
31903
+ linen: [250, 240, 230],
31904
+ magenta: [255, 0, 255],
31905
+ maroon: [128, 0, 0],
31906
+ mediumaquamarine: [102, 205, 170],
31907
+ mediumblue: [0, 0, 205],
31908
+ mediumorchid: [186, 85, 211],
31909
+ mediumpurple: [147, 111, 219],
31910
+ mediumseagreen: [60, 179, 113],
31911
+ mediumslateblue: [123, 104, 238],
31912
+ mediumspringgreen: [0, 250, 154],
31913
+ mediumturquoise: [72, 209, 204],
31914
+ mediumvioletred: [199, 21, 133],
31915
+ midnightblue: [25, 25, 112],
31916
+ mintcream: [245, 255, 250],
31917
+ mistyrose: [255, 228, 225],
31918
+ moccasin: [255, 228, 181],
31919
+ navajowhite: [255, 222, 173],
31920
+ navy: [0, 0, 128],
31921
+ oldlace: [253, 245, 230],
31922
+ olive: [128, 128, 0],
31923
+ olivedrab: [107, 142, 35],
31924
+ orange: [255, 165, 0],
31925
+ orangered: [255, 69, 0],
31926
+ orchid: [218, 112, 214],
31927
+ palegoldenrod: [238, 232, 170],
31928
+ palegreen: [152, 251, 152],
31929
+ paleturquoise: [175, 238, 238],
31930
+ palevioletred: [219, 112, 147],
31931
+ papayawhip: [255, 239, 213],
31932
+ peachpuff: [255, 218, 185],
31933
+ peru: [205, 133, 63],
31934
+ pink: [255, 192, 203],
31935
+ plum: [221, 160, 221],
31936
+ powderblue: [176, 224, 230],
31937
+ purple: [128, 0, 128],
31938
+ rebeccapurple: [102, 51, 153],
31939
+ red: [255, 0, 0],
31940
+ rosybrown: [188, 143, 143],
31941
+ royalblue: [65, 105, 225],
31942
+ saddlebrown: [139, 69, 19],
31943
+ salmon: [250, 128, 114],
31944
+ sandybrown: [244, 164, 96],
31945
+ seagreen: [46, 139, 87],
31946
+ seashell: [255, 245, 238],
31947
+ sienna: [160, 82, 45],
31948
+ silver: [192, 192, 192],
31949
+ skyblue: [135, 206, 235],
31950
+ slateblue: [106, 90, 205],
31951
+ slategray: [112, 128, 144],
31952
+ slategrey: [112, 128, 144],
31953
+ snow: [255, 250, 250],
31954
+ springgreen: [0, 255, 127],
31955
+ steelblue: [70, 130, 180],
31956
+ tan: [210, 180, 140],
31957
+ teal: [0, 128, 128],
31958
+ thistle: [216, 191, 216],
31959
+ tomato: [255, 99, 71],
31960
+ turquoise: [64, 224, 208],
31961
+ violet: [238, 130, 238],
31962
+ wheat: [245, 222, 179],
31963
+ white: [255, 255, 255],
31964
+ whitesmoke: [245, 245, 245],
31965
+ yellow: [255, 255, 0],
31966
+ yellowgreen: [154, 205, 50]
31967
+ };
31968
+
31969
+ // src/devices/param-validator.ts
31970
+ init_catalog();
31713
31971
  var AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
31714
31972
  var AC_FAN_MAP = { auto: 1, low: 2, mid: 3, high: 4 };
31715
31973
  var CURTAIN_MODE_MAP = { default: "ff", performance: "0", silent: "1" };
@@ -31761,6 +32019,9 @@ function buildBlindTiltSetPosition(opts) {
31761
32019
  if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
31762
32020
  throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
31763
32021
  }
32022
+ if (angle % 2 !== 0) {
32023
+ throw new UsageError(`--angle must be a multiple of 2 (got "${opts.angle}"). Example: --angle 50`);
32024
+ }
31764
32025
  return `${dir};${angle}`;
31765
32026
  }
31766
32027
  function buildRelaySetMode(opts) {
@@ -31776,11 +32037,12 @@ function buildRelaySetMode(opts) {
31776
32037
  }
31777
32038
  return `${ch};${modeInt}`;
31778
32039
  }
31779
- function buildBrightnessSet(opts) {
31780
- if (!opts.brightness) throw new UsageError("--brightness is required (1-100)");
32040
+ function buildBrightnessSet(opts, deviceType) {
32041
+ const [min, max] = deviceType && brightnessRange(deviceType) || [1, 100];
32042
+ if (!opts.brightness) throw new UsageError(`--brightness is required (${min}-${max})`);
31781
32043
  const b2 = parseInt(opts.brightness, 10);
31782
- if (!Number.isFinite(b2) || b2 < 1 || b2 > 100) {
31783
- throw new UsageError(`--brightness must be an integer between 1 and 100 (got "${opts.brightness}")`);
32044
+ if (!Number.isFinite(b2) || b2 < min || b2 > max) {
32045
+ throw new UsageError(`--brightness must be an integer between ${min} and ${max} (got "${opts.brightness}")`);
31784
32046
  }
31785
32047
  return String(b2);
31786
32048
  }
@@ -31796,94 +32058,203 @@ function buildColorTemperatureSet(opts) {
31796
32058
  if (!result.ok) throw new UsageError(result.error);
31797
32059
  return result.normalized ?? opts.colorTemp;
31798
32060
  }
32061
+ function parseParameterForWire(parameter) {
32062
+ if (parameter === void 0) return "default";
32063
+ const trimmed = parameter.trim();
32064
+ if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
32065
+ try {
32066
+ return JSON.parse(parameter);
32067
+ } catch {
32068
+ return parameter;
32069
+ }
32070
+ }
32071
+ return parameter;
32072
+ }
31799
32073
  function validateParameter(deviceType, command, raw) {
31800
32074
  if (!deviceType) return { ok: true };
31801
- if (deviceType === "Air Conditioner" && command === "setAll") {
32075
+ const dt = canonicalizeDeviceType(deviceType);
32076
+ if (dt === "Air Conditioner" && command === "setAll") {
31802
32077
  return validateAcSetAll(raw);
31803
32078
  }
31804
- if (deviceType.startsWith("Curtain") && command === "setPosition") {
32079
+ if (dt.startsWith("Curtain") && command === "setPosition") {
31805
32080
  return validateCurtainSetPosition(raw);
31806
32081
  }
31807
- if (deviceType.startsWith("Blind Tilt") && command === "setPosition") {
32082
+ if (dt.startsWith("Blind Tilt") && command === "setPosition") {
31808
32083
  return validateBlindTiltSetPosition(raw);
31809
32084
  }
31810
- if (deviceType.startsWith("Relay Switch") && command === "setMode") {
31811
- return validateRelaySetMode(raw);
32085
+ if ((dt === "Relay Switch 1" || dt === "Relay Switch 1PM") && command === "setMode") {
32086
+ return validateIntRange(raw, "setMode", 0, 3, "Relay Switch mode (0=toggle 1=edge 2=detached 3=momentary)");
32087
+ }
32088
+ if (dt === "Relay Switch 2PM" && command === "setMode") {
32089
+ return validateRelay2PmSetMode(raw);
32090
+ }
32091
+ if (dt === "Relay Switch 2PM" && (command === "turnOn" || command === "turnOff" || command === "toggle")) {
32092
+ return validateRelayChannel(raw);
32093
+ }
32094
+ if (dt === "Relay Switch 2PM" && command === "setPosition") {
32095
+ return validateIntRange(raw, "setPosition", 0, 100, "Relay Switch 2PM roller-shade percentage");
31812
32096
  }
31813
- if (command === "setBrightness" && isBrightnessDevice(deviceType)) {
31814
- return validateSetBrightness(raw);
32097
+ if (command === "setBrightness" && isBrightnessDevice(dt)) {
32098
+ return validateSetBrightness(raw, dt);
31815
32099
  }
31816
- if (command === "setColor" && isColorDevice(deviceType)) {
32100
+ if (command === "setColor" && isColorDevice(dt)) {
31817
32101
  return validateSetColor(raw);
31818
32102
  }
31819
- if (command === "setColorTemperature" && isBrightnessDevice(deviceType)) {
32103
+ if (command === "setColorTemperature" && isColorTemperatureDevice(dt)) {
31820
32104
  return validateSetColorTemperature(raw);
31821
32105
  }
32106
+ if (dt === "Humidifier" && command === "setMode") {
32107
+ return validateHumidifierSetMode(raw);
32108
+ }
32109
+ if (dt === "Humidifier2" && command === "setMode") {
32110
+ return validateHumidifier2SetMode(raw);
32111
+ }
32112
+ if (dt === "Humidifier2" && command === "setChildLock") {
32113
+ return validateEnum(raw, "setChildLock", ["true", "false"]);
32114
+ }
32115
+ if (isAirPurifierDevice(dt) && command === "setMode") {
32116
+ return validateAirPurifierSetMode(raw);
32117
+ }
32118
+ if (isAirPurifierDevice(dt) && command === "setChildLock") {
32119
+ return validateEnum(raw, "setChildLock", ["0", "1"]);
32120
+ }
32121
+ if (isPowLevelVacuum(dt) && command === "PowLevel") {
32122
+ return validateIntRange(raw, "PowLevel", 0, 3, "suction level (0=Quiet 1=Standard 2=Strong 3=Max)");
32123
+ }
32124
+ if ((isComboVacuum(dt) || isFloorCleaningVacuum(dt)) && command === "startClean") {
32125
+ return validateVacuumStartClean(raw, dt);
32126
+ }
32127
+ if ((isComboVacuum(dt) || isFloorCleaningVacuum(dt)) && command === "setVolume") {
32128
+ return validateIntRange(raw, "setVolume", 0, 100, "volume percentage");
32129
+ }
32130
+ if ((isComboVacuum(dt) || isFloorCleaningVacuum(dt)) && command === "changeParam") {
32131
+ return validateVacuumChangeParam(raw, dt);
32132
+ }
32133
+ if (isFloorCleaningVacuum(dt) && command === "selfClean") {
32134
+ return validateEnum(raw, "selfClean", ["1", "2", "3"], "1=wash mop, 2=dry, 3=terminate");
32135
+ }
32136
+ if (isCirculatorFan(dt) && command === "setNightLightMode") {
32137
+ return validateEnum(raw, "setNightLightMode", ["off", "1", "2"]);
32138
+ }
32139
+ if (isCirculatorFan(dt) && command === "setWindMode") {
32140
+ return validateEnum(raw, "setWindMode", ["direct", "natural", "sleep", "baby"]);
32141
+ }
32142
+ if (isCirculatorFan(dt) && command === "setWindSpeed") {
32143
+ return validateIntRange(raw, "setWindSpeed", 1, 100, "fan speed percentage");
32144
+ }
32145
+ if (isCirculatorFan(dt) && command === "closeDelay") {
32146
+ return validateIntRange(raw, "closeDelay", 1, 36e3, "auto-off delay in seconds");
32147
+ }
32148
+ if (dt === "Smart Radiator Thermostat" && command === "setMode") {
32149
+ return validateIntRange(raw, "setMode", 0, 5, "mode (0=schedule 1=manual 2=off 3=eco 4=comfort 5=quickHeat)");
32150
+ }
32151
+ if (dt === "Smart Radiator Thermostat" && command === "setManualModeTemperature") {
32152
+ return validateIntRange(raw, "setManualModeTemperature", 4, 35, "temperature in \xB0C");
32153
+ }
32154
+ if (dt.startsWith("Keypad") && command === "createKey") {
32155
+ return validateKeypadCreateKey(raw);
32156
+ }
32157
+ if (dt.startsWith("Keypad") && command === "deleteKey") {
32158
+ return validateKeypadDeleteKey(raw);
32159
+ }
32160
+ if (dt === "TV" && command === "SetChannel") {
32161
+ return validateIntRange(raw, "SetChannel", 1, 999, "channel number");
32162
+ }
32163
+ if (dt === "Roller Shade" && command === "setPosition") {
32164
+ return validateIntRange(raw, "setPosition", 0, 100, "position percentage (0=open, 100=closed)");
32165
+ }
31822
32166
  return { ok: true };
31823
32167
  }
31824
32168
  function isBrightnessDevice(deviceType) {
31825
- return deviceType === "Color Bulb" || deviceType === "Strip Light" || deviceType === "Strip Light 3" || deviceType === "Ceiling Light" || deviceType === "Ceiling Light Pro" || deviceType === "Floor Lamp" || deviceType === "Light Strip" || deviceType === "Dimmer" || deviceType === "Fill Light";
32169
+ return brightnessRange(deviceType) !== null;
32170
+ }
32171
+ function brightnessRange(deviceType) {
32172
+ if (deviceType === "Color Bulb" || deviceType === "Strip Light" || deviceType === "Ceiling Light" || deviceType === "Ceiling Light Pro") {
32173
+ return [1, 100];
32174
+ }
32175
+ if (deviceType === "Floor Lamp" || deviceType === "Strip Light 3" || deviceType === "RGBICWW Strip Light" || deviceType === "RGBICWW Floor Lamp" || deviceType === "RGBIC Neon Wire Rope Light" || deviceType === "RGBIC Neon Rope Light" || deviceType === "Candle Warmer Lamp") {
32176
+ return [0, 100];
32177
+ }
32178
+ if (deviceType === "Light Strip" || deviceType === "Dimmer" || deviceType === "Fill Light") {
32179
+ return [1, 100];
32180
+ }
32181
+ return null;
31826
32182
  }
31827
32183
  function isColorDevice(deviceType) {
31828
- return deviceType === "Color Bulb" || deviceType === "Strip Light" || deviceType === "Strip Light 3" || deviceType === "Floor Lamp" || deviceType === "Light Strip" || deviceType === "Fill Light";
32184
+ return deviceType === "Color Bulb" || deviceType === "Strip Light" || deviceType === "Strip Light 3" || deviceType === "Floor Lamp" || deviceType === "RGBICWW Strip Light" || deviceType === "RGBICWW Floor Lamp" || deviceType === "RGBIC Neon Wire Rope Light" || deviceType === "RGBIC Neon Rope Light" || deviceType === "Light Strip" || deviceType === "Fill Light";
32185
+ }
32186
+ function isColorTemperatureDevice(deviceType) {
32187
+ return deviceType === "Color Bulb" || deviceType === "Floor Lamp" || deviceType === "Strip Light 3" || deviceType === "Ceiling Light" || deviceType === "Ceiling Light Pro" || deviceType === "RGBICWW Strip Light" || deviceType === "RGBICWW Floor Lamp" || deviceType === "Light Strip" || deviceType === "Dimmer" || deviceType === "Fill Light";
32188
+ }
32189
+ function isAirPurifierDevice(deviceType) {
32190
+ return deviceType === "Air Purifier VOC" || deviceType === "Air Purifier Table VOC" || deviceType === "Air Purifier PM2.5" || deviceType === "Air Purifier Table PM2.5";
32191
+ }
32192
+ function isPowLevelVacuum(deviceType) {
32193
+ return deviceType === "Robot Vacuum Cleaner S1" || deviceType === "Robot Vacuum Cleaner S1 Plus" || deviceType === "K10+" || deviceType === "K10+ Pro";
32194
+ }
32195
+ function isComboVacuum(deviceType) {
32196
+ return deviceType === "K10+ Pro Combo" || deviceType === "Robot Vacuum Cleaner K10+ Pro Combo" || deviceType === "K20+ Pro" || deviceType === "K11+" || deviceType === "Robot Vacuum Cleaner K11+";
32197
+ }
32198
+ function isFloorCleaningVacuum(deviceType) {
32199
+ return deviceType === "Floor Cleaning Robot S10" || deviceType === "S20" || deviceType === "Robot Vacuum Cleaner S20";
32200
+ }
32201
+ function isCirculatorFan(deviceType) {
32202
+ return deviceType === "Battery Circulator Fan" || deviceType === "Circulator Fan" || deviceType === "Standing Circulator Fan";
31829
32203
  }
31830
32204
  function isLightingCommandSupported(deviceType, command) {
31831
- if (command === "setBrightness" || command === "setColorTemperature") return isBrightnessDevice(deviceType);
31832
- if (command === "setColor") return isColorDevice(deviceType);
32205
+ const dt = canonicalizeDeviceType(deviceType);
32206
+ if (command === "setBrightness") return isBrightnessDevice(dt);
32207
+ if (command === "setColorTemperature") return isColorTemperatureDevice(dt);
32208
+ if (command === "setColor") return isColorDevice(dt);
32209
+ return false;
32210
+ }
32211
+ function isNumericish(v2) {
32212
+ if (typeof v2 === "number") return true;
32213
+ if (typeof v2 === "string" && v2.trim() !== "") return true;
31833
32214
  return false;
31834
32215
  }
31835
- function validateSetBrightness(raw) {
32216
+ function validateSetBrightness(raw, deviceType) {
32217
+ const [min, max] = brightnessRange(deviceType) ?? [1, 100];
31836
32218
  if (raw === void 0 || raw === "" || raw === "default") {
31837
32219
  return {
31838
32220
  ok: false,
31839
- error: `setBrightness requires an integer 1-100 (percent). Example: "50".`
32221
+ error: `setBrightness requires an integer ${min}-${max} (percent). Example: "50".`
31840
32222
  };
31841
32223
  }
31842
- const trimmed = raw.trim();
32224
+ const trimmed = stripQuotes(raw.trim());
31843
32225
  if (!/^-?\d+$/.test(trimmed)) {
31844
32226
  return {
31845
32227
  ok: false,
31846
- error: `setBrightness must be an integer 1-100, got ${JSON.stringify(raw)}. ${hintBrightnessRetry()}`
32228
+ error: `setBrightness must be an integer ${min}-${max}, got ${JSON.stringify(raw)}. ${hintBrightnessRetry(min, max)}`
31847
32229
  };
31848
32230
  }
31849
32231
  const n = Number(trimmed);
31850
- if (!Number.isInteger(n) || n < 1 || n > 100) {
32232
+ if (!Number.isInteger(n) || n < min || n > max) {
31851
32233
  return {
31852
32234
  ok: false,
31853
- error: `setBrightness must be an integer 1-100, got "${raw}". ${hintBrightnessRetry()}`
32235
+ error: `setBrightness must be an integer ${min}-${max}, got "${raw}". ${hintBrightnessRetry(min, max)}`
31854
32236
  };
31855
32237
  }
31856
32238
  return { ok: true, normalized: String(n) };
31857
32239
  }
31858
- function hintBrightnessRetry() {
31859
- return `Ask the user whether they meant a percentage (1-100). Example: "50".`;
32240
+ function hintBrightnessRetry(min = 1, max = 100) {
32241
+ return `Ask the user whether they meant a percentage (${min}-${max}). Example: "50".`;
31860
32242
  }
31861
- var NAMED_COLORS = {
31862
- red: [255, 0, 0],
31863
- green: [0, 128, 0],
31864
- lime: [0, 255, 0],
31865
- blue: [0, 0, 255],
31866
- yellow: [255, 255, 0],
31867
- cyan: [0, 255, 255],
31868
- magenta: [255, 0, 255],
31869
- white: [255, 255, 255],
31870
- black: [0, 0, 0],
31871
- orange: [255, 165, 0],
31872
- purple: [128, 0, 128],
31873
- pink: [255, 192, 203],
31874
- brown: [165, 42, 42],
31875
- grey: [128, 128, 128],
31876
- gray: [128, 128, 128],
32243
+ var CUSTOM_COLORS = {
31877
32244
  warm: [255, 180, 100]
31878
32245
  };
32246
+ var NAMED_COLORS = {
32247
+ ...CSS_COLORS,
32248
+ ...CUSTOM_COLORS
32249
+ };
31879
32250
  function validateSetColor(raw) {
31880
32251
  if (raw === void 0 || raw === "" || raw === "default") {
31881
32252
  return {
31882
32253
  ok: false,
31883
- error: `setColor requires a color. Expected one of: "R:G:B" (e.g. "255:0:0"), "#RRGGBB" (e.g. "#FF0000"), "#RGB", "R,G,B", or a named color (${Object.keys(NAMED_COLORS).slice(0, 8).join(", ")}, ...).`
32254
+ error: `setColor requires a color. Use a CSS color name (e.g. coral, teal, salmon), hex (#RRGGBB / #RGB), or R:G:B format.`
31884
32255
  };
31885
32256
  }
31886
- const trimmed = raw.trim();
32257
+ const trimmed = stripQuotes(raw.trim());
31887
32258
  const named = NAMED_COLORS[trimmed.toLowerCase()];
31888
32259
  if (named) {
31889
32260
  return { ok: true, normalized: `${named[0]}:${named[1]}:${named[2]}` };
@@ -31950,7 +32321,7 @@ function validateSetColorTemperature(raw) {
31950
32321
  error: `setColorTemperature requires an integer Kelvin value 2700-6500. Example: "4000".`
31951
32322
  };
31952
32323
  }
31953
- const trimmed = raw.trim();
32324
+ const trimmed = stripQuotes(raw.trim());
31954
32325
  if (!/^-?\d+$/.test(trimmed)) {
31955
32326
  return {
31956
32327
  ok: false,
@@ -31973,13 +32344,14 @@ function validateAcSetAll(raw) {
31973
32344
  error: `setAll requires a parameter "<temp>,<mode>,<fan>,<on|off>". Example: "26,2,2,on".`
31974
32345
  };
31975
32346
  }
31976
- if (raw.startsWith("{") || raw.startsWith("[")) {
32347
+ const stripped = stripQuotes(raw.trim());
32348
+ if (stripped.startsWith("{") || stripped.startsWith("[")) {
31977
32349
  return {
31978
32350
  ok: false,
31979
32351
  error: `setAll parameter must be a CSV string like "26,2,2,on", not JSON (got ${JSON.stringify(raw)}).`
31980
32352
  };
31981
32353
  }
31982
- const parts = raw.split(",");
32354
+ const parts = stripped.split(",");
31983
32355
  if (parts.length !== 4) {
31984
32356
  return {
31985
32357
  ok: false,
@@ -32024,8 +32396,9 @@ function validateCurtainSetPosition(raw) {
32024
32396
  error: `setPosition requires a parameter. Expected: "<0-100>" or "<index>,<ff|0|1>,<0-100>". Example: "50" or "0,ff,50".`
32025
32397
  };
32026
32398
  }
32027
- if (!raw.includes(",")) {
32028
- const pos2 = Number(raw);
32399
+ const stripped = stripQuotes(raw.trim());
32400
+ if (!stripped.includes(",")) {
32401
+ const pos2 = Number(stripped);
32029
32402
  if (!Number.isInteger(pos2) || pos2 < 0 || pos2 > 100) {
32030
32403
  return {
32031
32404
  ok: false,
@@ -32034,7 +32407,7 @@ function validateCurtainSetPosition(raw) {
32034
32407
  }
32035
32408
  return { ok: true, normalized: String(pos2) };
32036
32409
  }
32037
- const parts = raw.split(",").map((s2) => s2.trim());
32410
+ const parts = stripped.split(",").map((s2) => s2.trim());
32038
32411
  if (parts.length !== 3) {
32039
32412
  return {
32040
32413
  ok: false,
@@ -32072,7 +32445,8 @@ function validateBlindTiltSetPosition(raw) {
32072
32445
  error: `Blind Tilt setPosition requires a parameter. Expected: "<up|down>;<0-100>". Example: "up;50".`
32073
32446
  };
32074
32447
  }
32075
- const parts = raw.split(";");
32448
+ const stripped = stripQuotes(raw.trim());
32449
+ const parts = stripped.split(";");
32076
32450
  if (parts.length !== 2) {
32077
32451
  return {
32078
32452
  ok: false,
@@ -32093,16 +32467,23 @@ function validateBlindTiltSetPosition(raw) {
32093
32467
  error: `Blind Tilt setPosition angle must be an integer 0-100, got "${parts[1]}".`
32094
32468
  };
32095
32469
  }
32470
+ if (angle % 2 !== 0) {
32471
+ return {
32472
+ ok: false,
32473
+ error: `Blind Tilt setPosition angle must be a multiple of 2, got "${parts[1]}". Example: "up;48".`
32474
+ };
32475
+ }
32096
32476
  return { ok: true, normalized: `${dir};${angle}` };
32097
32477
  }
32098
- function validateRelaySetMode(raw) {
32478
+ function validateRelay2PmSetMode(raw) {
32099
32479
  if (raw === void 0 || raw === "" || raw === "default") {
32100
32480
  return {
32101
32481
  ok: false,
32102
32482
  error: `Relay Switch setMode requires a parameter. Expected: "<1|2>;<0|1|2|3>". Example: "1;1" (channel 1, edge mode).`
32103
32483
  };
32104
32484
  }
32105
- const parts = raw.split(";");
32485
+ const stripped = stripQuotes(raw.trim());
32486
+ const parts = stripped.split(";");
32106
32487
  if (parts.length !== 2) {
32107
32488
  return {
32108
32489
  ok: false,
@@ -32125,6 +32506,342 @@ function validateRelaySetMode(raw) {
32125
32506
  }
32126
32507
  return { ok: true, normalized: `${ch};${mode}` };
32127
32508
  }
32509
+ function validateRelayChannel(raw) {
32510
+ if (raw === void 0 || raw === "" || raw === "default") {
32511
+ return {
32512
+ ok: false,
32513
+ error: `Relay Switch 2PM turnOn/turnOff/toggle requires a channel parameter: "1" or "2". Example: turnOff 1`
32514
+ };
32515
+ }
32516
+ const n = stripQuotes(raw.trim());
32517
+ if (n !== "1" && n !== "2") {
32518
+ return {
32519
+ ok: false,
32520
+ error: `Relay Switch 2PM channel must be "1" or "2", got ${JSON.stringify(raw)}.`
32521
+ };
32522
+ }
32523
+ return { ok: true, normalized: n };
32524
+ }
32525
+ function stripQuotes(s2) {
32526
+ if (s2.length >= 2 && s2.startsWith('"') && s2.endsWith('"')) {
32527
+ return s2.slice(1, -1);
32528
+ }
32529
+ return s2;
32530
+ }
32531
+ function validateIntRange(raw, command, min, max, label) {
32532
+ if (raw === void 0 || raw === "" || raw === "default") {
32533
+ return {
32534
+ ok: false,
32535
+ error: `${command} requires an integer ${min}-${max} (${label}). Example: "${Math.round((min + max) / 2)}".`
32536
+ };
32537
+ }
32538
+ const trimmed = stripQuotes(raw.trim());
32539
+ if (!/^-?\d+$/.test(trimmed)) {
32540
+ return {
32541
+ ok: false,
32542
+ error: `${command} must be an integer ${min}-${max} (${label}), got ${JSON.stringify(raw)}.`
32543
+ };
32544
+ }
32545
+ const n = Number(trimmed);
32546
+ if (n < min || n > max) {
32547
+ return {
32548
+ ok: false,
32549
+ error: `${command} must be an integer ${min}-${max} (${label}), got ${n}.`
32550
+ };
32551
+ }
32552
+ return { ok: true, normalized: String(n) };
32553
+ }
32554
+ function validateEnum(raw, command, allowed, hint) {
32555
+ if (raw === void 0 || raw === "" || raw === "default") {
32556
+ return {
32557
+ ok: false,
32558
+ error: `${command} requires a parameter: ${allowed.join(" | ")}${hint ? ` (${hint})` : ""}. Example: "${allowed[0]}".`
32559
+ };
32560
+ }
32561
+ const trimmed = stripQuotes(raw.trim()).toLowerCase();
32562
+ const match = allowed.find((a) => a.toLowerCase() === trimmed);
32563
+ if (!match) {
32564
+ return {
32565
+ ok: false,
32566
+ error: `${command} must be one of: ${allowed.join(", ")}. Got ${JSON.stringify(raw)}.`
32567
+ };
32568
+ }
32569
+ return { ok: true, normalized: match };
32570
+ }
32571
+ function validateHumidifierSetMode(raw) {
32572
+ if (raw === void 0 || raw === "" || raw === "default") {
32573
+ return {
32574
+ ok: false,
32575
+ error: `Humidifier setMode requires a parameter: "auto", "101", "102", "103", or 0-100 (humidity %). Example: "auto".`
32576
+ };
32577
+ }
32578
+ const trimmed = stripQuotes(raw.trim()).toLowerCase();
32579
+ if (trimmed === "auto") return { ok: true, normalized: "auto" };
32580
+ if (["101", "102", "103"].includes(trimmed)) return { ok: true, normalized: trimmed };
32581
+ if (/^\d+$/.test(trimmed)) {
32582
+ const n = Number(trimmed);
32583
+ if (n >= 0 && n <= 100) return { ok: true, normalized: String(n) };
32584
+ }
32585
+ return {
32586
+ ok: false,
32587
+ error: `Humidifier setMode must be "auto", "101" (34%), "102" (67%), "103" (100%), or 0-100. Got ${JSON.stringify(raw)}.`
32588
+ };
32589
+ }
32590
+ function validateHumidifier2SetMode(raw) {
32591
+ if (raw === void 0 || raw === "" || raw === "default") {
32592
+ return {
32593
+ ok: false,
32594
+ error: `Humidifier2 setMode requires a JSON parameter: {"mode":1-8,"targetHumidify":0-100}. Example: '{"mode":7,"targetHumidify":50}'.`
32595
+ };
32596
+ }
32597
+ let obj;
32598
+ try {
32599
+ obj = JSON.parse(raw);
32600
+ } catch {
32601
+ return {
32602
+ ok: false,
32603
+ error: `Humidifier2 setMode expects JSON: {"mode":1-8,"targetHumidify":0-100}. Got ${JSON.stringify(raw)}.`
32604
+ };
32605
+ }
32606
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32607
+ return { ok: false, error: `Humidifier2 setMode expects a JSON object, got ${typeof obj}.` };
32608
+ }
32609
+ const o = obj;
32610
+ if (!isNumericish(o.mode)) {
32611
+ return { ok: false, error: `Humidifier2 setMode "mode" must be a number or numeric string, got ${JSON.stringify(o.mode)}.` };
32612
+ }
32613
+ const mode = Number(o.mode);
32614
+ if (!Number.isInteger(mode) || mode < 1 || mode > 8) {
32615
+ return { ok: false, error: `Humidifier2 setMode "mode" must be 1-8, got ${JSON.stringify(o.mode)}.` };
32616
+ }
32617
+ if (!isNumericish(o.targetHumidify)) {
32618
+ return { ok: false, error: `Humidifier2 setMode "targetHumidify" must be a number or numeric string, got ${JSON.stringify(o.targetHumidify)}.` };
32619
+ }
32620
+ const hum = Number(o.targetHumidify);
32621
+ if (!Number.isInteger(hum) || hum < 0 || hum > 100) {
32622
+ return { ok: false, error: `Humidifier2 setMode "targetHumidify" must be 0-100, got ${JSON.stringify(o.targetHumidify)}.` };
32623
+ }
32624
+ return { ok: true, normalized: JSON.stringify({ mode, targetHumidify: hum }) };
32625
+ }
32626
+ function validateAirPurifierSetMode(raw) {
32627
+ if (raw === void 0 || raw === "" || raw === "default") {
32628
+ return {
32629
+ ok: false,
32630
+ error: `Air Purifier setMode requires a JSON parameter: {"mode":1-4} or {"mode":1,"fanGear":1-3}. Example: '{"mode":2}'.`
32631
+ };
32632
+ }
32633
+ let obj;
32634
+ try {
32635
+ obj = JSON.parse(raw);
32636
+ } catch {
32637
+ return {
32638
+ ok: false,
32639
+ error: `Air Purifier setMode expects JSON: {"mode":1-4,"fanGear":1-3}. Got ${JSON.stringify(raw)}.`
32640
+ };
32641
+ }
32642
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32643
+ return { ok: false, error: `Air Purifier setMode expects a JSON object, got ${typeof obj}.` };
32644
+ }
32645
+ const o = obj;
32646
+ if (!isNumericish(o.mode)) {
32647
+ return { ok: false, error: `Air Purifier setMode "mode" must be a number or numeric string, got ${JSON.stringify(o.mode)}.` };
32648
+ }
32649
+ const mode = Number(o.mode);
32650
+ if (!Number.isInteger(mode) || mode < 1 || mode > 4) {
32651
+ return { ok: false, error: `Air Purifier setMode "mode" must be 1-4 (1=normal 2=auto 3=sleep 4=pet), got ${JSON.stringify(o.mode)}.` };
32652
+ }
32653
+ const normalized = { mode };
32654
+ if (o.fanGear !== void 0) {
32655
+ if (mode !== 1) {
32656
+ return { ok: false, error: `Air Purifier setMode "fanGear" can only be set when "mode" is 1 (normal/fan mode).` };
32657
+ }
32658
+ if (!isNumericish(o.fanGear)) {
32659
+ return { ok: false, error: `Air Purifier setMode "fanGear" must be a number or numeric string, got ${JSON.stringify(o.fanGear)}.` };
32660
+ }
32661
+ const fg = Number(o.fanGear);
32662
+ if (!Number.isInteger(fg) || fg < 1 || fg > 3) {
32663
+ return { ok: false, error: `Air Purifier setMode "fanGear" must be 1-3, got ${JSON.stringify(o.fanGear)}.` };
32664
+ }
32665
+ normalized.fanGear = fg;
32666
+ }
32667
+ return { ok: true, normalized: JSON.stringify(normalized) };
32668
+ }
32669
+ function validateVacuumStartClean(raw, deviceType) {
32670
+ if (raw === void 0 || raw === "" || raw === "default") {
32671
+ const actions = isFloorCleaningVacuum(deviceType) ? "sweep | sweep_mop" : "sweep | mop";
32672
+ return {
32673
+ ok: false,
32674
+ error: `${deviceType} startClean requires a JSON parameter: {"action":"${actions.split(" | ")[0]}","param":{"fanLevel":1-4,"times":1}}. Example: '{"action":"${actions.split(" | ")[0]}","param":{"fanLevel":2,"times":1}}'.`
32675
+ };
32676
+ }
32677
+ let obj;
32678
+ try {
32679
+ obj = JSON.parse(raw);
32680
+ } catch {
32681
+ return {
32682
+ ok: false,
32683
+ error: `${deviceType} startClean expects JSON. Got ${JSON.stringify(raw)}.`
32684
+ };
32685
+ }
32686
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32687
+ return { ok: false, error: `${deviceType} startClean expects a JSON object.` };
32688
+ }
32689
+ const o = obj;
32690
+ const validActions = isFloorCleaningVacuum(deviceType) ? ["sweep", "sweep_mop"] : ["sweep", "mop"];
32691
+ if (typeof o.action !== "string" || !validActions.includes(o.action)) {
32692
+ return { ok: false, error: `${deviceType} startClean "action" must be one of: ${validActions.join(", ")}. Got ${JSON.stringify(o.action)}.` };
32693
+ }
32694
+ const normalized = { action: o.action };
32695
+ if (o.param !== void 0) {
32696
+ if (typeof o.param !== "object" || o.param === null || Array.isArray(o.param)) {
32697
+ return { ok: false, error: `${deviceType} startClean "param" must be an object.` };
32698
+ }
32699
+ const p2 = o.param;
32700
+ const normalizedParam = {};
32701
+ if (p2.fanLevel !== void 0) {
32702
+ if (!isNumericish(p2.fanLevel)) {
32703
+ return { ok: false, error: `${deviceType} startClean "param.fanLevel" must be a number or numeric string, got ${JSON.stringify(p2.fanLevel)}.` };
32704
+ }
32705
+ const fl = Number(p2.fanLevel);
32706
+ if (!Number.isInteger(fl) || fl < 1 || fl > 4) {
32707
+ return { ok: false, error: `${deviceType} startClean "param.fanLevel" must be 1-4, got ${JSON.stringify(p2.fanLevel)}.` };
32708
+ }
32709
+ normalizedParam.fanLevel = fl;
32710
+ }
32711
+ if (p2.waterLevel !== void 0) {
32712
+ if (!isFloorCleaningVacuum(deviceType)) {
32713
+ return { ok: false, error: `${deviceType} startClean "param.waterLevel" is only supported for Floor Cleaning Robot S10/S20.` };
32714
+ }
32715
+ if (!isNumericish(p2.waterLevel)) {
32716
+ return { ok: false, error: `${deviceType} startClean "param.waterLevel" must be a number or numeric string, got ${JSON.stringify(p2.waterLevel)}.` };
32717
+ }
32718
+ const wl = Number(p2.waterLevel);
32719
+ if (!Number.isInteger(wl) || wl < 1 || wl > 2) {
32720
+ return { ok: false, error: `${deviceType} startClean "param.waterLevel" must be 1-2, got ${JSON.stringify(p2.waterLevel)}.` };
32721
+ }
32722
+ normalizedParam.waterLevel = wl;
32723
+ }
32724
+ if (p2.times !== void 0) {
32725
+ if (!isNumericish(p2.times)) {
32726
+ return { ok: false, error: `${deviceType} startClean "param.times" must be a number or numeric string, got ${JSON.stringify(p2.times)}.` };
32727
+ }
32728
+ const t = Number(p2.times);
32729
+ if (!Number.isInteger(t) || t < 1 || t > 2639999) {
32730
+ return { ok: false, error: `${deviceType} startClean "param.times" must be an integer 1-2639999, got ${JSON.stringify(p2.times)}.` };
32731
+ }
32732
+ normalizedParam.times = t;
32733
+ }
32734
+ normalized.param = normalizedParam;
32735
+ }
32736
+ return { ok: true, normalized: JSON.stringify(normalized) };
32737
+ }
32738
+ function validateVacuumChangeParam(raw, deviceType) {
32739
+ if (raw === void 0 || raw === "" || raw === "default") {
32740
+ return {
32741
+ ok: false,
32742
+ error: `changeParam requires a JSON parameter: {"fanLevel":1-4,"waterLevel":1-2,"times":1}. Example: '{"fanLevel":3,"waterLevel":1,"times":1}'.`
32743
+ };
32744
+ }
32745
+ let obj;
32746
+ try {
32747
+ obj = JSON.parse(raw);
32748
+ } catch {
32749
+ return {
32750
+ ok: false,
32751
+ error: `changeParam expects JSON: {"fanLevel":1-4,"waterLevel":1-2,"times":...}. Got ${JSON.stringify(raw)}.`
32752
+ };
32753
+ }
32754
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32755
+ return { ok: false, error: `changeParam expects a JSON object.` };
32756
+ }
32757
+ const p2 = obj;
32758
+ const normalized = {};
32759
+ if (p2.fanLevel !== void 0) {
32760
+ if (!isNumericish(p2.fanLevel)) {
32761
+ return { ok: false, error: `changeParam "fanLevel" must be a number or numeric string, got ${JSON.stringify(p2.fanLevel)}.` };
32762
+ }
32763
+ const fl = Number(p2.fanLevel);
32764
+ if (!Number.isInteger(fl) || fl < 1 || fl > 4) {
32765
+ return { ok: false, error: `changeParam "fanLevel" must be 1-4, got ${JSON.stringify(p2.fanLevel)}.` };
32766
+ }
32767
+ normalized.fanLevel = fl;
32768
+ }
32769
+ if (p2.waterLevel !== void 0) {
32770
+ if (isComboVacuum(deviceType)) {
32771
+ return { ok: false, error: `${deviceType} changeParam does not support "waterLevel" according to the API docs.` };
32772
+ }
32773
+ if (!isNumericish(p2.waterLevel)) {
32774
+ return { ok: false, error: `changeParam "waterLevel" must be a number or numeric string, got ${JSON.stringify(p2.waterLevel)}.` };
32775
+ }
32776
+ const wl = Number(p2.waterLevel);
32777
+ if (!Number.isInteger(wl) || wl < 1 || wl > 2) {
32778
+ return { ok: false, error: `changeParam "waterLevel" must be 1-2, got ${JSON.stringify(p2.waterLevel)}.` };
32779
+ }
32780
+ normalized.waterLevel = wl;
32781
+ }
32782
+ if (p2.times !== void 0) {
32783
+ if (!isNumericish(p2.times)) {
32784
+ return { ok: false, error: `changeParam "times" must be a number or numeric string, got ${JSON.stringify(p2.times)}.` };
32785
+ }
32786
+ const t = Number(p2.times);
32787
+ if (!Number.isInteger(t) || t < 1 || t > 2639999) {
32788
+ return { ok: false, error: `changeParam "times" must be an integer 1-2639999, got ${JSON.stringify(p2.times)}.` };
32789
+ }
32790
+ normalized.times = t;
32791
+ }
32792
+ return { ok: true, normalized: JSON.stringify(normalized) };
32793
+ }
32794
+ function validateKeypadCreateKey(raw) {
32795
+ if (raw === void 0 || raw === "" || raw === "default") {
32796
+ return {
32797
+ ok: false,
32798
+ error: `createKey requires a JSON parameter: {"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits",...}.`
32799
+ };
32800
+ }
32801
+ let obj;
32802
+ try {
32803
+ obj = JSON.parse(raw);
32804
+ } catch {
32805
+ return { ok: false, error: `createKey expects a JSON object. Got ${JSON.stringify(raw)}.` };
32806
+ }
32807
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32808
+ return { ok: false, error: `createKey expects a JSON object.` };
32809
+ }
32810
+ const o = obj;
32811
+ if (typeof o.name !== "string" || o.name.length === 0) {
32812
+ return { ok: false, error: `createKey "name" is required and must be a non-empty string.` };
32813
+ }
32814
+ const validTypes = ["permanent", "timeLimit", "disposable", "urgent"];
32815
+ if (typeof o.type !== "string" || !validTypes.includes(o.type)) {
32816
+ return { ok: false, error: `createKey "type" must be one of: ${validTypes.join(", ")}. Got ${JSON.stringify(o.type)}.` };
32817
+ }
32818
+ if (typeof o.password !== "string" || !/^\d{6,12}$/.test(o.password)) {
32819
+ return { ok: false, error: `createKey "password" must be a 6-12 digit string. Got ${JSON.stringify(o.password)}.` };
32820
+ }
32821
+ return { ok: true };
32822
+ }
32823
+ function validateKeypadDeleteKey(raw) {
32824
+ if (raw === void 0 || raw === "" || raw === "default") {
32825
+ return {
32826
+ ok: false,
32827
+ error: `deleteKey requires a JSON parameter: {"id":<passcode_id>}. Example: '{"id":12345}'.`
32828
+ };
32829
+ }
32830
+ let obj;
32831
+ try {
32832
+ obj = JSON.parse(raw);
32833
+ } catch {
32834
+ return { ok: false, error: `deleteKey expects a JSON object: {"id":<passcode_id>}. Got ${JSON.stringify(raw)}.` };
32835
+ }
32836
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
32837
+ return { ok: false, error: `deleteKey expects a JSON object.` };
32838
+ }
32839
+ const o = obj;
32840
+ if (o.id === void 0 || typeof o.id !== "number" && typeof o.id !== "string") {
32841
+ return { ok: false, error: `deleteKey "id" is required (passcode ID). Got ${JSON.stringify(o.id)}.` };
32842
+ }
32843
+ return { ok: true };
32844
+ }
32128
32845
 
32129
32846
  // src/commands/batch.ts
32130
32847
  init_cjs_shim();
@@ -32354,13 +33071,7 @@ Examples:
32354
33071
  }
32355
33072
  });
32356
33073
  }
32357
- let parsedParam = parameter ?? "default";
32358
- if (parameter) {
32359
- try {
32360
- parsedParam = JSON.parse(parameter);
32361
- } catch {
32362
- }
32363
- }
33074
+ const parsedParam = parseParameterForWire(parameter);
32364
33075
  const maxConcurrentRaw = options.maxConcurrent ?? options.concurrency;
32365
33076
  const concurrency = Math.max(1, Number.parseInt(maxConcurrentRaw, 10) || DEFAULT_CONCURRENCY);
32366
33077
  const staggerMs = Math.max(0, Number.parseInt(options.stagger, 10) || 0);
@@ -32395,8 +33106,11 @@ Examples:
32395
33106
  }
32396
33107
  return;
32397
33108
  }
33109
+ const totalDevices = resolved.ids.length;
33110
+ const deviceIndices = new Map(resolved.ids.map((id, i) => [id, i + 1]));
32398
33111
  const startedAt = Date.now();
32399
33112
  const outcomes = await runPool(resolved.ids, concurrency, staggerMs, async (id) => {
33113
+ const stepIdx = deviceIndices.get(id);
32400
33114
  const stepStart = Date.now();
32401
33115
  const startedIso = new Date(stepStart).toISOString();
32402
33116
  try {
@@ -32408,7 +33122,7 @@ Examples:
32408
33122
  const durationMs = Date.now() - stepStart;
32409
33123
  const replayed = typeof result2 === "object" && result2 !== null && result2.replayed === true;
32410
33124
  if (!isJsonMode()) {
32411
- console.log(`\u2713 ${id}: ${cmd}${replayed ? " (replayed)" : ""}`);
33125
+ console.log(`[${stepIdx}/${totalDevices}] \u2713 ${id}: ${cmd}${replayed ? " (replayed)" : ""}`);
32412
33126
  }
32413
33127
  return {
32414
33128
  ok: true,
@@ -32431,7 +33145,7 @@ Examples:
32431
33145
  }
32432
33146
  const errorPayload = buildErrorPayload(err);
32433
33147
  if (!isJsonMode()) {
32434
- console.error(`\u2717 ${id}: ${errorPayload.message}`);
33148
+ console.error(`[${stepIdx}/${totalDevices}] \u2717 ${id}: ${errorPayload.message}`);
32435
33149
  }
32436
33150
  return {
32437
33151
  ok: false,
@@ -32681,7 +33395,7 @@ function registerWatchCommand(devices) {
32681
33395
  `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1e3}s)`,
32682
33396
  durationArg("--interval"),
32683
33397
  "30s"
32684
- ).option("--max <n>", "Stop after N ticks (default: run until Ctrl-C)", intArg("--max", { min: 1 })).option("--for <dur>", 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg("--for")).option("--include-unchanged", "Emit a tick even when no field changed").option("--initial <mode>", "How to handle the first poll: snapshot | emit | skip (default: snapshot)", enumArg("--initial", INITIAL_MODES), "snapshot").addHelpText(
33398
+ ).option("--max <n>", "Stop after N ticks (default: run until Ctrl-C)", intArg("--max", { min: 1 })).option("--once", "Stop after one tick (shorthand for --max 1)").option("--for <dur>", 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg("--for")).option("--include-unchanged", "Emit a tick even when no field changed").option("--initial <mode>", "How to handle the first poll: snapshot | emit | skip (default: snapshot)", enumArg("--initial", INITIAL_MODES), "snapshot").addHelpText(
32685
33399
  "after",
32686
33400
  `
32687
33401
  Default output is a human-readable table of field changes per tick; add --json
@@ -32710,6 +33424,12 @@ Examples:
32710
33424
  ).action(
32711
33425
  async (deviceIds, options) => {
32712
33426
  try {
33427
+ if (options.once && options.max !== void 0) {
33428
+ throw new UsageError("--once and --max are mutually exclusive.");
33429
+ }
33430
+ if (options.once) {
33431
+ options.max = "1";
33432
+ }
32713
33433
  const allIds = [...deviceIds];
32714
33434
  if (options.name) {
32715
33435
  const resolved = resolveDeviceId(void 0, options.name);
@@ -32965,7 +33685,7 @@ init_catalog();
32965
33685
  init_flags();
32966
33686
  init_client();
32967
33687
  function registerExpandCommand(devices) {
32968
- devices.command("expand").description("Send a command with semantic flags instead of raw positional parameters").argument("[deviceId]", 'Target device ID from "devices list" (or use --name)').argument("[command]", "Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2), setBrightness/setColor/setColorTemperature (lighting)").option("--name <query>", "Resolve device by fuzzy name instead of deviceId", stringArg("--name")).option("--name-strategy <s>", `Name match strategy: ${ALL_STRATEGIES.join("|")} (default: require-unique)`, stringArg("--name-strategy")).option("--name-type <type>", 'Narrow --name by device type (e.g. "Curtain", "Air Conditioner")', stringArg("--name-type")).option("--name-category <cat>", "Narrow --name by category: physical|ir", enumArg("--name-category", ["physical", "ir"])).option("--name-room <room>", "Narrow --name by room name (substring match)", stringArg("--name-room")).option("--temp <celsius>", "AC setAll: temperature in Celsius (16-30)", intArg("--temp", { min: 16, max: 30 })).option("--mode <mode>", "AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary", stringArg("--mode")).option("--fan <speed>", "AC setAll: fan speed auto|low|mid|high", stringArg("--fan")).option("--power <state>", "AC setAll: on|off", stringArg("--power")).option("--position <percent>", "Curtain setPosition: 0-100 (0=open, 100=closed)", intArg("--position", { min: 0, max: 100 })).option("--direction <dir>", "Blind Tilt setPosition: up|down", stringArg("--direction")).option("--angle <percent>", "Blind Tilt setPosition: 0-100 (0=closed, 100=open)", intArg("--angle", { min: 0, max: 100 })).option("--channel <n>", "Relay Switch 2 setMode: channel 1 or 2", intArg("--channel", { min: 1, max: 2 })).option("--brightness <percent>", "setBrightness: 1-100 percent", intArg("--brightness", { min: 1, max: 100 })).option("--color <value>", "setColor: R:G:B, #RRGGBB, or named color (red, blue, etc.)", stringArg("--color")).option("--color-temp <kelvin>", "setColorTemperature: 2700-6500 Kelvin", intArg("--color-temp", { min: 2700, max: 6500 })).option("--yes", "Confirm destructive commands").addHelpText("after", `
33688
+ devices.command("expand").description("Send a command with semantic flags instead of raw positional parameters").argument("[deviceId]", 'Target device ID from "devices list" (or use --name)').argument("[command]", "Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2), setBrightness/setColor/setColorTemperature (lighting)").option("--name <query>", "Resolve device by fuzzy name instead of deviceId", stringArg("--name")).option("--name-strategy <s>", `Name match strategy: ${ALL_STRATEGIES.join("|")} (default: require-unique)`, stringArg("--name-strategy")).option("--name-type <type>", 'Narrow --name by device type (e.g. "Curtain", "Air Conditioner")', stringArg("--name-type")).option("--name-category <cat>", "Narrow --name by category: physical|ir", enumArg("--name-category", ["physical", "ir"])).option("--name-room <room>", "Narrow --name by room name (substring match)", stringArg("--name-room")).option("--temp <celsius>", "AC setAll: temperature in Celsius (16-30)", intArg("--temp", { min: 16, max: 30 })).option("--mode <mode>", "AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary", stringArg("--mode")).option("--fan <speed>", "AC setAll: fan speed auto|low|mid|high", stringArg("--fan")).option("--power <state>", "AC setAll: on|off", stringArg("--power")).option("--position <percent>", "Curtain setPosition: 0-100 (0=open, 100=closed)", intArg("--position", { min: 0, max: 100 })).option("--direction <dir>", "Blind Tilt setPosition: up|down", stringArg("--direction")).option("--angle <percent>", "Blind Tilt setPosition: 0-100 (0=closed, 100=open)", intArg("--angle", { min: 0, max: 100 })).option("--channel <n>", "Relay Switch 2 setMode: channel 1 or 2", intArg("--channel", { min: 1, max: 2 })).option("--brightness <percent>", "setBrightness: 0-100 percent (minimum depends on device)", intArg("--brightness", { min: 0, max: 100 })).option("--color <value>", "setColor: R:G:B, #RRGGBB, or named color (red, blue, etc.)", stringArg("--color")).option("--color-temp <kelvin>", "setColorTemperature: 2700-6500 Kelvin", intArg("--color-temp", { min: 2700, max: 6500 })).option("--yes", "Confirm destructive commands").addHelpText("after", `
32969
33689
  Translates semantic flags into the wire parameter format, then sends the command.
32970
33690
 
32971
33691
  Supported expansions:
@@ -33086,7 +33806,7 @@ Examples:
33086
33806
  }
33087
33807
  }
33088
33808
  if (command === "setBrightness") {
33089
- parameter = buildBrightnessSet(options);
33809
+ parameter = buildBrightnessSet(options, cached2?.type);
33090
33810
  } else if (command === "setColor") {
33091
33811
  parameter = buildColorSet(options);
33092
33812
  } else {
@@ -33258,13 +33978,16 @@ var EXPAND_HINTS = {
33258
33978
  "Relay Switch 2PM": { command: "setMode", flags: "--channel 1 --mode edge" }
33259
33979
  };
33260
33980
  function annotateStatusPayload(deviceId, body) {
33261
- const annotated = { ...body };
33981
+ const cached2 = getCachedDevice(deviceId);
33982
+ const deviceType = cached2?.type ?? "";
33983
+ const annotated = { deviceId, deviceType, ...body };
33984
+ annotated.deviceId = deviceId;
33985
+ annotated.deviceType = deviceType;
33262
33986
  if (Object.keys(body).length === 0) {
33263
33987
  annotated.supported = false;
33264
33988
  annotated.note = "this device does not expose cloud status";
33265
33989
  return annotated;
33266
33990
  }
33267
- const cached2 = getCachedDevice(deviceId);
33268
33991
  const looksLikeMeter = cached2?.type?.toLowerCase().includes("meter") ?? false;
33269
33992
  const staleZeroReading = looksLikeMeter && !Object.prototype.hasOwnProperty.call(body, "onlineStatus") && body.battery === 0 && body.temperature === 0 && body.humidity === 0;
33270
33993
  if (staleZeroReading) {
@@ -33421,7 +34144,7 @@ Examples:
33421
34144
  rows.push([
33422
34145
  d.deviceId,
33423
34146
  d.deviceName,
33424
- d.deviceType || "\u2014",
34147
+ d.deviceType || d.controlType || "Unknown Device",
33425
34148
  "physical",
33426
34149
  d.controlType || "\u2014",
33427
34150
  d.familyName || "\u2014",
@@ -33767,13 +34490,7 @@ ${extra}` : extra;
33767
34490
  if (options.yes && !destructive && !isDryRun()) {
33768
34491
  console.error(`Note: --yes has no effect; "${cmd}" is not a destructive command.`);
33769
34492
  }
33770
- let parsedParam = parameter ?? "default";
33771
- if (parameter) {
33772
- try {
33773
- parsedParam = JSON.parse(parameter);
33774
- } catch {
33775
- }
33776
- }
34493
+ const parsedParam = parseParameterForWire(parameter);
33777
34494
  _cmd = cmd;
33778
34495
  _parsedParam = parsedParam;
33779
34496
  const body = await executeCommand(
@@ -33970,12 +34687,19 @@ Examples:
33970
34687
  });
33971
34688
  return;
33972
34689
  }
34690
+ if (result.typeSource === "controlType") {
34691
+ const deviceName2 = device.deviceName ?? deviceId;
34692
+ console.error(`warning: ${deviceName2} (${deviceId}): deviceType not reported by API, using controlType "${result.controlType}". Capabilities may be limited.`);
34693
+ } else if (typeName === "Unknown Device") {
34694
+ const deviceName2 = device.deviceName ?? deviceId;
34695
+ console.error(`warning: ${deviceName2} (${deviceId}): neither deviceType nor controlType reported by API. Capabilities may be limited.`);
34696
+ }
33973
34697
  if (isPhysical) {
33974
34698
  const physical = device;
33975
34699
  printKeyValue({
33976
34700
  deviceId: physical.deviceId,
33977
34701
  deviceName: physical.deviceName,
33978
- deviceType: physical.deviceType || "\u2014",
34702
+ deviceType: physical.deviceType || physical.controlType || "Unknown Device",
33979
34703
  controlType: physical.controlType || "\u2014",
33980
34704
  family: physical.familyName || "\u2014",
33981
34705
  roomID: physical.roomID || "\u2014",
@@ -34154,7 +34878,7 @@ Examples:
34154
34878
  handleError(error48);
34155
34879
  }
34156
34880
  });
34157
- scenes.command("execute").description("Execute a manual scene by its ID").argument("<sceneId>", 'Scene ID from "scenes list"').addHelpText("after", `
34881
+ scenes.command("execute").alias("run").description("Execute a manual scene by its ID").argument("<sceneId>", 'Scene ID from "scenes list"').addHelpText("after", `
34158
34882
  Example:
34159
34883
  $ switchbot scenes execute T12345678
34160
34884
  `).action(async (sceneId) => {
@@ -49086,6 +49810,47 @@ var EventSubscriptionManager = class {
49086
49810
  }
49087
49811
  };
49088
49812
 
49813
+ // src/mcp/tool-profiles.ts
49814
+ init_cjs_shim();
49815
+ var CORE_READ = [
49816
+ "list_devices",
49817
+ "get_device_status",
49818
+ "get_device_history",
49819
+ "query_device_history",
49820
+ "list_scenes",
49821
+ "search_catalog",
49822
+ "describe_device",
49823
+ "aggregate_device_history",
49824
+ "account_overview",
49825
+ "plan_suggest"
49826
+ ];
49827
+ var CORE_ACTION = ["send_command", "run_scene", "plan_run"];
49828
+ var ADMIN = [
49829
+ "policy_validate",
49830
+ "policy_diff",
49831
+ "policy_new",
49832
+ "policy_migrate",
49833
+ "policy_add_rule",
49834
+ "audit_query",
49835
+ "audit_stats",
49836
+ "rule_notifications",
49837
+ "rules_suggest",
49838
+ "rules_explain",
49839
+ "rules_simulate"
49840
+ ];
49841
+ var TOOL_PROFILES = {
49842
+ readonly: new Set(CORE_READ),
49843
+ default: /* @__PURE__ */ new Set([...CORE_READ, ...CORE_ACTION]),
49844
+ all: /* @__PURE__ */ new Set([...CORE_READ, ...CORE_ACTION, ...ADMIN])
49845
+ };
49846
+ var VALID_PROFILES = Object.keys(TOOL_PROFILES);
49847
+ function resolveToolProfile(name) {
49848
+ if (!name || name === "default") return "default";
49849
+ if (name === "readonly" || name === "all") return name;
49850
+ const valid = VALID_PROFILES.join(", ");
49851
+ throw new Error(`Unknown tool profile "${name}". Valid profiles: ${valid}`);
49852
+ }
49853
+
49089
49854
  // src/devices/history-query.ts
49090
49855
  init_cjs_shim();
49091
49856
  import fs10 from "node:fs";
@@ -49887,15 +50652,27 @@ against the live API without executing any mutations.
49887
50652
  handleError(err);
49888
50653
  }
49889
50654
  });
49890
- plan.command("run").description("Validate + preview/execute a plan. Respects --dry-run; destructive steps require the reviewed plan flow by default").argument("[file]", 'Path to plan.json, or "-" / omit to read stdin').option("--yes", "Authorize destructive commands (e.g. Smart Lock unlock, Garage open)").option("--require-approval", "Prompt for confirmation before each destructive step (TTY only; mutually exclusive with --json)").option("--continue-on-error", "Keep running after a failed step (default: stop at first error)").action(
50655
+ plan.command("run").description("Validate + preview/execute a plan. Respects --dry-run; destructive steps require the reviewed plan flow by default").argument("[file]", 'Path to plan.json, or "-" / omit to read stdin').option("--yes", "Authorize destructive commands (e.g. Smart Lock unlock, Garage open)").option("--require-approval", "Prompt for confirmation before each destructive step (TTY only; mutually exclusive with --json)").option("--continue-on-error", "Keep running after a failed step (default: stop at first error)").option("--plan <json>", "Inline plan JSON (alternative to file argument or stdin)").action(
49891
50656
  async (file2, options) => {
49892
50657
  if (options.requireApproval && isJsonMode()) {
49893
50658
  console.error("error: --require-approval cannot be used with --json (no TTY available for prompts)");
49894
50659
  process.exit(1);
49895
50660
  }
50661
+ if (options.plan !== void 0 && file2 !== void 0) {
50662
+ console.error("error: --plan and a file argument are mutually exclusive.");
50663
+ process.exit(2);
50664
+ }
49896
50665
  let raw;
49897
50666
  try {
49898
- raw = await readPlanSource(file2);
50667
+ if (options.plan !== void 0) {
50668
+ try {
50669
+ raw = JSON.parse(options.plan);
50670
+ } catch (err) {
50671
+ throw new UsageError(`--plan is not valid JSON: ${err.message}`);
50672
+ }
50673
+ } else {
50674
+ raw = await readPlanSource(file2);
50675
+ }
49899
50676
  } catch (err) {
49900
50677
  handleError(err);
49901
50678
  }
@@ -50944,6 +51721,8 @@ function buildRiskProfile(typeName, command, commandType, isDestructive) {
50944
51721
  }
50945
51722
  function createSwitchBotMcpServer(options) {
50946
51723
  const eventManager = options?.eventManager;
51724
+ const allowedTools = TOOL_PROFILES[options?.toolProfile ?? "default"];
51725
+ const profileName = options?.toolProfile ?? "default";
50947
51726
  const server = new McpServer(
50948
51727
  {
50949
51728
  name: "switchbot",
@@ -50967,1075 +51746,1093 @@ Recommended bootstrap sequence:
50967
51746
  2. search_catalog or describe_device \u2192 confirm supported commands offline/online
50968
51747
  3. send_command (with confirm:true for destructive commands)
50969
51748
 
50970
- API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
51749
+ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI
51750
+
51751
+ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName !== "all" ? " Use --tools all to access admin tools (policy, audit, rules)." : ""}`
50971
51752
  }
50972
51753
  );
50973
- server.registerTool(
50974
- "list_devices",
50975
- {
50976
- title: "List all devices on the account",
50977
- description: "Fetch the complete inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local metadata cache and groups devices by type. Use this as the bootstrap call to discover available deviceIds. Devices without enableCloudService cannot receive commands via API. IR remotes depend on a Hub for connectivity.",
50978
- _meta: { agentSafetyTier: "read" },
50979
- inputSchema: external_exports.object({}).strict(),
50980
- outputSchema: {
50981
- deviceList: external_exports.array(external_exports.object({
50982
- deviceId: external_exports.string(),
50983
- deviceName: external_exports.string(),
50984
- deviceType: external_exports.string().optional(),
50985
- enableCloudService: external_exports.boolean(),
50986
- hubDeviceId: external_exports.string(),
50987
- roomID: external_exports.string().optional(),
50988
- roomName: external_exports.string().nullable().optional(),
50989
- familyName: external_exports.string().optional(),
50990
- controlType: external_exports.string().optional()
50991
- }).passthrough()).describe("Physical SwitchBot devices"),
50992
- infraredRemoteList: external_exports.array(external_exports.object({
50993
- deviceId: external_exports.string(),
50994
- deviceName: external_exports.string(),
50995
- remoteType: external_exports.string(),
50996
- hubDeviceId: external_exports.string(),
50997
- controlType: external_exports.string().optional()
50998
- }).passthrough()).describe("IR remote devices")
50999
- }
51000
- },
51001
- async () => {
51002
- const body = await fetchDeviceList();
51003
- return {
51004
- content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
51005
- structuredContent: {
51006
- deviceList: body.deviceList.map(toMcpDeviceListShape),
51007
- infraredRemoteList: body.infraredRemoteList.map(toMcpIrDeviceShape)
51754
+ const skip = (name) => !allowedTools.has(name);
51755
+ if (!skip("list_devices"))
51756
+ server.registerTool(
51757
+ "list_devices",
51758
+ {
51759
+ title: "List all devices on the account",
51760
+ description: "Fetch the complete inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local metadata cache and groups devices by type. Use this as the bootstrap call to discover available deviceIds. Devices without enableCloudService cannot receive commands via API. IR remotes depend on a Hub for connectivity.",
51761
+ _meta: { agentSafetyTier: "read" },
51762
+ inputSchema: external_exports.object({}).strict(),
51763
+ outputSchema: {
51764
+ deviceList: external_exports.array(external_exports.object({
51765
+ deviceId: external_exports.string(),
51766
+ deviceName: external_exports.string(),
51767
+ deviceType: external_exports.string().optional(),
51768
+ enableCloudService: external_exports.boolean(),
51769
+ hubDeviceId: external_exports.string(),
51770
+ roomID: external_exports.string().optional(),
51771
+ roomName: external_exports.string().nullable().optional(),
51772
+ familyName: external_exports.string().optional(),
51773
+ controlType: external_exports.string().optional()
51774
+ }).passthrough()).describe("Physical SwitchBot devices"),
51775
+ infraredRemoteList: external_exports.array(external_exports.object({
51776
+ deviceId: external_exports.string(),
51777
+ deviceName: external_exports.string(),
51778
+ remoteType: external_exports.string(),
51779
+ hubDeviceId: external_exports.string(),
51780
+ controlType: external_exports.string().optional()
51781
+ }).passthrough()).describe("IR remote devices")
51008
51782
  }
51009
- };
51010
- }
51011
- );
51012
- server.registerTool(
51013
- "get_device_status",
51014
- {
51015
- title: "Get live status for a device",
51016
- description: "Query the real-time status payload for a physical device. IR remotes have no status channel and will error.",
51017
- _meta: { agentSafetyTier: "read" },
51018
- inputSchema: external_exports.object({
51019
- deviceId: external_exports.string().describe("Device ID from list_devices")
51020
- }).strict(),
51021
- outputSchema: {
51022
- status: external_exports.object({
51023
- deviceId: external_exports.string().optional(),
51024
- deviceType: external_exports.string().optional(),
51025
- hubDeviceId: external_exports.string().optional(),
51026
- connectionStatus: external_exports.string().optional()
51027
- }).passthrough().describe("Live device status (deviceId + deviceType + device-specific fields)")
51028
- }
51029
- },
51030
- async ({ deviceId }) => {
51031
- const body = await fetchDeviceStatus(deviceId);
51032
- return {
51033
- content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
51034
- structuredContent: { status: body }
51035
- };
51036
- }
51037
- );
51038
- server.registerTool(
51039
- "get_device_history",
51040
- {
51041
- title: "Get locally-persisted device state history",
51042
- description: "Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). No API call \u2014 zero quota cost. Use when you need recent historical readings or want to avoid a live API call. Omit deviceId to list all devices with stored history.",
51043
- _meta: { agentSafetyTier: "read" },
51044
- inputSchema: external_exports.object({
51045
- deviceId: external_exports.string().optional().describe("Device MAC address (deviceId). Omit to list all devices with history."),
51046
- limit: external_exports.number().int().min(1).max(100).optional().describe("Max history entries to return (default 20, max 100)")
51047
- }).strict(),
51048
- outputSchema: {
51049
- deviceId: external_exports.string().optional(),
51050
- latest: external_exports.unknown().optional(),
51051
- history: external_exports.array(external_exports.unknown()).optional(),
51052
- devices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), latest: external_exports.unknown() })).optional()
51053
- }
51054
- },
51055
- async ({ deviceId, limit }) => {
51056
- if (deviceId) {
51057
- const latest = deviceHistoryStore.getLatest(deviceId);
51058
- const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
51059
- const result2 = { deviceId, latest, history };
51783
+ },
51784
+ async () => {
51785
+ const body = await fetchDeviceList();
51060
51786
  return {
51061
- content: [{ type: "text", text: JSON.stringify(result2, null, 2) }],
51062
- structuredContent: result2
51787
+ content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
51788
+ structuredContent: {
51789
+ deviceList: body.deviceList.map(toMcpDeviceListShape),
51790
+ infraredRemoteList: body.infraredRemoteList.map(toMcpIrDeviceShape)
51791
+ }
51063
51792
  };
51064
51793
  }
51065
- const ids = deviceHistoryStore.listDevices();
51066
- const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) }));
51067
- const result = { devices };
51068
- return {
51069
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
51070
- structuredContent: result
51071
- };
51072
- }
51073
- );
51074
- server.registerTool(
51075
- "query_device_history",
51076
- {
51077
- title: "Query time-ranged device history",
51078
- description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) filtered by a relative duration (since) or absolute ISO-8601 range (from/to). No API call \u2014 zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
51079
- _meta: { agentSafetyTier: "read" },
51080
- inputSchema: external_exports.object({
51081
- deviceId: external_exports.string().describe("Device ID to query"),
51082
- since: external_exports.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
51083
- from: external_exports.string().optional().describe("Range start (ISO-8601)."),
51084
- to: external_exports.string().optional().describe("Range end (ISO-8601)."),
51085
- fields: external_exports.array(external_exports.string()).optional().describe("Project these payload fields; omit for the full payload."),
51086
- limit: external_exports.number().int().min(1).max(1e4).optional().describe("Max records to return (default 1000).")
51087
- }).strict(),
51088
- outputSchema: {
51089
- deviceId: external_exports.string(),
51090
- count: external_exports.number().int(),
51091
- records: external_exports.array(external_exports.object({
51092
- t: external_exports.string(),
51093
- topic: external_exports.string(),
51094
- deviceType: external_exports.string().optional(),
51095
- payload: external_exports.unknown()
51096
- }))
51097
- }
51098
- },
51099
- async ({ deviceId, since, from, to, fields, limit }) => {
51100
- if (since && (from || to)) {
51101
- return mcpError("usage", 2, "--since is mutually exclusive with --from/--to.");
51794
+ );
51795
+ if (!skip("get_device_status"))
51796
+ server.registerTool(
51797
+ "get_device_status",
51798
+ {
51799
+ title: "Get live status for a device",
51800
+ description: "Query the real-time status payload for a physical device. IR remotes have no status channel and will error.",
51801
+ _meta: { agentSafetyTier: "read" },
51802
+ inputSchema: external_exports.object({
51803
+ deviceId: external_exports.string().describe("Device ID from list_devices")
51804
+ }).strict(),
51805
+ outputSchema: {
51806
+ status: external_exports.object({
51807
+ deviceId: external_exports.string().optional(),
51808
+ deviceType: external_exports.string().optional(),
51809
+ hubDeviceId: external_exports.string().optional(),
51810
+ connectionStatus: external_exports.string().optional()
51811
+ }).passthrough().describe("Live device status (deviceId + deviceType + device-specific fields)")
51812
+ }
51813
+ },
51814
+ async ({ deviceId }) => {
51815
+ const body = await fetchDeviceStatus(deviceId);
51816
+ return {
51817
+ content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
51818
+ structuredContent: { status: body }
51819
+ };
51102
51820
  }
51103
- try {
51104
- const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit });
51105
- const result = { deviceId, count: records.length, records };
51821
+ );
51822
+ if (!skip("get_device_history"))
51823
+ server.registerTool(
51824
+ "get_device_history",
51825
+ {
51826
+ title: "Get locally-persisted device state history",
51827
+ description: "Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). No API call \u2014 zero quota cost. Use when you need recent historical readings or want to avoid a live API call. Omit deviceId to list all devices with stored history.",
51828
+ _meta: { agentSafetyTier: "read" },
51829
+ inputSchema: external_exports.object({
51830
+ deviceId: external_exports.string().optional().describe("Device MAC address (deviceId). Omit to list all devices with history."),
51831
+ limit: external_exports.number().int().min(1).max(100).optional().describe("Max history entries to return (default 20, max 100)")
51832
+ }).strict(),
51833
+ outputSchema: {
51834
+ deviceId: external_exports.string().optional(),
51835
+ latest: external_exports.unknown().optional(),
51836
+ history: external_exports.array(external_exports.unknown()).optional(),
51837
+ devices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), latest: external_exports.unknown() })).optional()
51838
+ }
51839
+ },
51840
+ async ({ deviceId, limit }) => {
51841
+ if (deviceId) {
51842
+ const latest = deviceHistoryStore.getLatest(deviceId);
51843
+ const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
51844
+ const result2 = { deviceId, latest, history };
51845
+ return {
51846
+ content: [{ type: "text", text: JSON.stringify(result2, null, 2) }],
51847
+ structuredContent: result2
51848
+ };
51849
+ }
51850
+ const ids = deviceHistoryStore.listDevices();
51851
+ const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) }));
51852
+ const result = { devices };
51106
51853
  return {
51107
51854
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
51108
51855
  structuredContent: result
51109
51856
  };
51110
- } catch (err) {
51111
- const msg = err instanceof Error ? err.message : "history query failed";
51112
- return mcpError("usage", 2, msg);
51113
51857
  }
51114
- }
51115
- );
51116
- server.registerTool(
51117
- "send_command",
51118
- {
51119
- title: "Send a control command to a device",
51120
- 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.",
51121
- _meta: { agentSafetyTier: "action" },
51122
- inputSchema: external_exports.object({
51123
- deviceId: external_exports.string().describe("Device ID from list_devices"),
51124
- command: external_exports.string().describe("Command name, case-sensitive (e.g. turnOn, setColor, unlock)"),
51125
- parameter: external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.record(external_exports.string(), external_exports.unknown()), external_exports.array(external_exports.unknown())]).optional().describe("Command parameter. Omit for no-arg commands."),
51126
- commandType: external_exports.enum(["command", "customize"]).optional().default("command").describe('"command" for built-in commands; "customize" for user-defined IR buttons'),
51127
- confirm: external_exports.boolean().optional().default(false).describe("Required true for destructive commands (unlock, garage open, createKey, ...)"),
51128
- idempotencyKey: external_exports.string().optional().describe(
51129
- "Deduplication key \u2014 repeat calls with the same key within 60s replay the first result (adds replayed:true). Same key + different (command, parameter) within 60s returns an idempotency_conflict guard error."
51130
- ),
51131
- dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
51132
- }).strict(),
51133
- outputSchema: {
51134
- ok: external_exports.literal(true),
51135
- command: external_exports.string().optional(),
51136
- deviceId: external_exports.string().optional(),
51137
- result: external_exports.unknown().optional().describe("API response body from SwitchBot (absent on dryRun)"),
51138
- riskProfile: external_exports.object({
51139
- riskLevel: external_exports.enum(["high", "medium", "low"]),
51140
- requiresConfirmation: external_exports.boolean(),
51141
- supportsDryRun: external_exports.literal(true),
51142
- idempotencyHint: external_exports.enum(["safe", "non-idempotent"]),
51143
- recommendedMode: external_exports.enum(["review-before-execute", "plan", "direct"])
51144
- }).optional().describe(
51145
- 'Device+command-specific risk metadata. riskLevel:"high" means confirm:true was required. Always present on dryRun responses so agents can preview risk before committing.'
51146
- ),
51147
- verification: external_exports.object({
51148
- verifiable: external_exports.boolean(),
51149
- reason: external_exports.string(),
51150
- suggestedFollowup: external_exports.string()
51151
- }).optional().describe(
51152
- 'Present when the target is an IR device. IR is unidirectional \u2014 agents should treat the success as "signal sent" not "state changed".'
51153
- ),
51154
- dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
51155
- wouldSend: external_exports.object({
51858
+ );
51859
+ if (!skip("query_device_history"))
51860
+ server.registerTool(
51861
+ "query_device_history",
51862
+ {
51863
+ title: "Query time-ranged device history",
51864
+ description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) filtered by a relative duration (since) or absolute ISO-8601 range (from/to). No API call \u2014 zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
51865
+ _meta: { agentSafetyTier: "read" },
51866
+ inputSchema: external_exports.object({
51867
+ deviceId: external_exports.string().describe("Device ID to query"),
51868
+ since: external_exports.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
51869
+ from: external_exports.string().optional().describe("Range start (ISO-8601)."),
51870
+ to: external_exports.string().optional().describe("Range end (ISO-8601)."),
51871
+ fields: external_exports.array(external_exports.string()).optional().describe("Project these payload fields; omit for the full payload."),
51872
+ limit: external_exports.number().int().min(1).max(1e4).optional().describe("Max records to return (default 1000).")
51873
+ }).strict(),
51874
+ outputSchema: {
51156
51875
  deviceId: external_exports.string(),
51157
- command: external_exports.string(),
51158
- parameter: external_exports.unknown(),
51159
- commandType: external_exports.string()
51160
- }).optional().describe("The request shape that would have been POSTed (present when dryRun:true)")
51876
+ count: external_exports.number().int(),
51877
+ records: external_exports.array(external_exports.object({
51878
+ t: external_exports.string(),
51879
+ topic: external_exports.string(),
51880
+ deviceType: external_exports.string().optional(),
51881
+ payload: external_exports.unknown()
51882
+ }))
51883
+ }
51884
+ },
51885
+ async ({ deviceId, since, from, to, fields, limit }) => {
51886
+ if (since && (from || to)) {
51887
+ return mcpError("usage", 2, "--since is mutually exclusive with --from/--to.");
51888
+ }
51889
+ try {
51890
+ const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit });
51891
+ const result = { deviceId, count: records.length, records };
51892
+ return {
51893
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
51894
+ structuredContent: result
51895
+ };
51896
+ } catch (err) {
51897
+ const msg = err instanceof Error ? err.message : "history query failed";
51898
+ return mcpError("usage", 2, msg);
51899
+ }
51161
51900
  }
51162
- },
51163
- async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
51164
- const effectiveType = commandType ?? "command";
51165
- let effectiveCommand = command;
51166
- let effectiveParameter = parameter;
51167
- const stringifiedParam = parameter === void 0 ? void 0 : typeof parameter === "string" ? parameter : JSON.stringify(parameter);
51168
- if (dryRun) {
51169
- const cached2 = getCachedDevice(deviceId);
51170
- if (!cached2) {
51171
- return mcpError("usage", 2, `Device "${deviceId}" not found in local cache.`, {
51172
- subKind: "device-not-found",
51173
- hint: "Run 'list_devices' first to warm the cache, then retry with dryRun:true.",
51174
- context: { deviceId }
51175
- });
51901
+ );
51902
+ if (!skip("send_command"))
51903
+ server.registerTool(
51904
+ "send_command",
51905
+ {
51906
+ title: "Send a control command to a device",
51907
+ 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.",
51908
+ _meta: { agentSafetyTier: "action" },
51909
+ inputSchema: external_exports.object({
51910
+ deviceId: external_exports.string().describe("Device ID from list_devices"),
51911
+ command: external_exports.string().describe("Command name, case-sensitive (e.g. turnOn, setColor, unlock)"),
51912
+ parameter: external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.record(external_exports.string(), external_exports.unknown()), external_exports.array(external_exports.unknown())]).optional().describe("Command parameter. Omit for no-arg commands."),
51913
+ commandType: external_exports.enum(["command", "customize"]).optional().default("command").describe('"command" for built-in commands; "customize" for user-defined IR buttons'),
51914
+ confirm: external_exports.boolean().optional().default(false).describe("Required true for destructive commands (unlock, garage open, createKey, ...)"),
51915
+ idempotencyKey: external_exports.string().optional().describe(
51916
+ "Deduplication key \u2014 repeat calls with the same key within 60s replay the first result (adds replayed:true). Same key + different (command, parameter) within 60s returns an idempotency_conflict guard error."
51917
+ ),
51918
+ dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
51919
+ }).strict(),
51920
+ outputSchema: {
51921
+ ok: external_exports.literal(true),
51922
+ command: external_exports.string().optional(),
51923
+ deviceId: external_exports.string().optional(),
51924
+ result: external_exports.unknown().optional().describe("API response body from SwitchBot (absent on dryRun)"),
51925
+ riskProfile: external_exports.object({
51926
+ riskLevel: external_exports.enum(["high", "medium", "low"]),
51927
+ requiresConfirmation: external_exports.boolean(),
51928
+ supportsDryRun: external_exports.literal(true),
51929
+ idempotencyHint: external_exports.enum(["safe", "non-idempotent"]),
51930
+ recommendedMode: external_exports.enum(["review-before-execute", "plan", "direct"])
51931
+ }).optional().describe(
51932
+ 'Device+command-specific risk metadata. riskLevel:"high" means confirm:true was required. Always present on dryRun responses so agents can preview risk before committing.'
51933
+ ),
51934
+ verification: external_exports.object({
51935
+ verifiable: external_exports.boolean(),
51936
+ reason: external_exports.string(),
51937
+ suggestedFollowup: external_exports.string()
51938
+ }).optional().describe(
51939
+ 'Present when the target is an IR device. IR is unidirectional \u2014 agents should treat the success as "signal sent" not "state changed".'
51940
+ ),
51941
+ dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
51942
+ wouldSend: external_exports.object({
51943
+ deviceId: external_exports.string(),
51944
+ command: external_exports.string(),
51945
+ parameter: external_exports.unknown(),
51946
+ commandType: external_exports.string()
51947
+ }).optional().describe("The request shape that would have been POSTed (present when dryRun:true)")
51176
51948
  }
51177
- const dryValidation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
51178
- if (!dryValidation.ok) {
51949
+ },
51950
+ async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
51951
+ const effectiveType = commandType ?? "command";
51952
+ let effectiveCommand = command;
51953
+ let effectiveParameter = parameter;
51954
+ const stringifiedParam = parameter === void 0 ? void 0 : typeof parameter === "string" ? parameter : JSON.stringify(parameter);
51955
+ if (dryRun) {
51956
+ const cached2 = getCachedDevice(deviceId);
51957
+ if (!cached2) {
51958
+ return mcpError("usage", 2, `Device "${deviceId}" not found in local cache.`, {
51959
+ subKind: "device-not-found",
51960
+ hint: "Run 'list_devices' first to warm the cache, then retry with dryRun:true.",
51961
+ context: { deviceId }
51962
+ });
51963
+ }
51964
+ const dryValidation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
51965
+ if (!dryValidation.ok) {
51966
+ return mcpError(
51967
+ "usage",
51968
+ 2,
51969
+ dryValidation.error.message,
51970
+ {
51971
+ hint: dryValidation.error.hint,
51972
+ context: {
51973
+ validationKind: dryValidation.error.kind,
51974
+ deviceType: cached2.type,
51975
+ command: effectiveCommand
51976
+ }
51977
+ }
51978
+ );
51979
+ }
51980
+ if (dryValidation.normalized) {
51981
+ effectiveCommand = dryValidation.normalized;
51982
+ }
51983
+ if (effectiveType !== "customize") {
51984
+ const pv = validateParameter(cached2.type, effectiveCommand, stringifiedParam);
51985
+ if (!pv.ok) {
51986
+ return mcpError("usage", 2, pv.error, {
51987
+ hint: "Dry-run rejected the parameter client-side; the API would reject it too.",
51988
+ context: { deviceType: cached2.type, command: effectiveCommand, parameter: stringifiedParam }
51989
+ });
51990
+ }
51991
+ if (pv.normalized !== void 0) {
51992
+ effectiveParameter = parseParameterForWire(pv.normalized);
51993
+ }
51994
+ }
51995
+ const wouldSend = {
51996
+ deviceId,
51997
+ command: effectiveCommand,
51998
+ parameter: effectiveParameter ?? "default",
51999
+ commandType: effectiveType
52000
+ };
52001
+ const dryIsDestructive = isDestructiveCommand(cached2.type, effectiveCommand, effectiveType);
52002
+ const dryRiskProfile = buildRiskProfile(cached2.type, effectiveCommand, effectiveType, dryIsDestructive);
52003
+ const structured2 = { ok: true, dryRun: true, riskProfile: dryRiskProfile, wouldSend };
52004
+ return {
52005
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52006
+ structuredContent: structured2
52007
+ };
52008
+ }
52009
+ let typeName = getCachedDevice(deviceId)?.type;
52010
+ if (!typeName) {
52011
+ const body = await fetchDeviceList();
52012
+ const physical = body.deviceList.find((d) => d.deviceId === deviceId);
52013
+ const ir = body.infraredRemoteList.find((d) => d.deviceId === deviceId);
52014
+ if (!physical && !ir) {
52015
+ return mcpError("runtime", 152, `Device not found: ${deviceId}`, {
52016
+ hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive)."
52017
+ });
52018
+ }
52019
+ typeName = physical ? physical.deviceType : ir.remoteType;
52020
+ }
52021
+ const destructive = isDestructiveCommand(typeName, effectiveCommand, effectiveType);
52022
+ if (destructive && !allowsDirectDestructiveExecution()) {
52023
+ const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
51179
52024
  return mcpError(
51180
- "usage",
51181
- 2,
51182
- dryValidation.error.message,
52025
+ "guard",
52026
+ 3,
52027
+ `Direct destructive execution is disabled for command "${effectiveCommand}" on device type "${typeName}".`,
51183
52028
  {
51184
- hint: dryValidation.error.hint,
52029
+ hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
51185
52030
  context: {
51186
- validationKind: dryValidation.error.kind,
51187
- deviceType: cached2.type,
51188
- command: effectiveCommand
52031
+ command: effectiveCommand,
52032
+ deviceType: typeName,
52033
+ directExecutionAllowed: false,
52034
+ requiredWorkflow: "plan-approval",
52035
+ ...reason ? { safetyReason: reason, destructiveReason: reason } : {}
51189
52036
  }
51190
52037
  }
51191
52038
  );
51192
52039
  }
51193
- if (dryValidation.normalized) {
51194
- effectiveCommand = dryValidation.normalized;
52040
+ if (destructive && !confirm) {
52041
+ const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
52042
+ const entry = typeName ? findCatalogEntry(typeName) : null;
52043
+ const spec = entry && !Array.isArray(entry) ? entry.commands.find((c) => c.command === effectiveCommand) : void 0;
52044
+ const hint = reason ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}` : "Re-issue the call with confirm:true to proceed.";
52045
+ return mcpError(
52046
+ "guard",
52047
+ 3,
52048
+ `Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`,
52049
+ {
52050
+ hint,
52051
+ context: {
52052
+ command: effectiveCommand,
52053
+ deviceType: typeName,
52054
+ description: spec?.description ?? null,
52055
+ ...reason ? { safetyReason: reason, destructiveReason: reason } : {}
52056
+ }
52057
+ }
52058
+ );
52059
+ }
52060
+ const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
52061
+ if (!validation.ok) {
52062
+ return mcpError(
52063
+ "usage",
52064
+ 2,
52065
+ validation.error.message,
52066
+ {
52067
+ hint: validation.error.hint,
52068
+ context: { validationKind: validation.error.kind, deviceType: typeName, command: effectiveCommand }
52069
+ }
52070
+ );
52071
+ }
52072
+ if (validation.normalized) {
52073
+ effectiveCommand = validation.normalized;
51195
52074
  }
51196
52075
  if (effectiveType !== "customize") {
51197
- const pv = validateParameter(cached2.type, effectiveCommand, stringifiedParam);
52076
+ const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
51198
52077
  if (!pv.ok) {
51199
52078
  return mcpError("usage", 2, pv.error, {
51200
- hint: "Dry-run rejected the parameter client-side; the API would reject it too.",
51201
- context: { deviceType: cached2.type, command: effectiveCommand, parameter: stringifiedParam }
52079
+ context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: "param-out-of-range" }
51202
52080
  });
51203
52081
  }
51204
52082
  if (pv.normalized !== void 0) {
51205
- effectiveParameter = pv.normalized;
52083
+ effectiveParameter = parseParameterForWire(pv.normalized);
51206
52084
  }
51207
52085
  }
51208
- const wouldSend = {
51209
- deviceId,
51210
- command: effectiveCommand,
51211
- parameter: effectiveParameter ?? "default",
51212
- commandType: effectiveType
51213
- };
51214
- const dryIsDestructive = isDestructiveCommand(cached2.type, effectiveCommand, effectiveType);
51215
- const dryRiskProfile = buildRiskProfile(cached2.type, effectiveCommand, effectiveType, dryIsDestructive);
51216
- const structured2 = { ok: true, dryRun: true, riskProfile: dryRiskProfile, wouldSend };
52086
+ let result;
52087
+ try {
52088
+ result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, void 0, {
52089
+ idempotencyKey
52090
+ });
52091
+ } catch (err) {
52092
+ if (err instanceof Error && err.name === "IdempotencyConflictError") {
52093
+ return mcpError("guard", 2, err.message, {
52094
+ hint: "Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).",
52095
+ context: {
52096
+ existingShape: err.existingShape,
52097
+ newShape: err.newShape
52098
+ }
52099
+ });
52100
+ }
52101
+ return apiErrorToMcpError(err);
52102
+ }
52103
+ const isIr = getCachedDevice(deviceId)?.category === "ir";
52104
+ const liveIsDestructive = destructive;
52105
+ const riskProfile = buildRiskProfile(typeName, effectiveCommand, effectiveType, liveIsDestructive);
52106
+ const structured = { ok: true, command: effectiveCommand, deviceId, result, riskProfile };
52107
+ if (isIr) {
52108
+ structured.verification = {
52109
+ verifiable: false,
52110
+ reason: "IR transmission is unidirectional; no receipt acknowledgment is possible.",
52111
+ suggestedFollowup: "Confirm visible change manually or via a paired state sensor."
52112
+ };
52113
+ }
51217
52114
  return {
51218
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
51219
- structuredContent: structured2
52115
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52116
+ structuredContent: structured
51220
52117
  };
51221
52118
  }
51222
- let typeName = getCachedDevice(deviceId)?.type;
51223
- if (!typeName) {
51224
- const body = await fetchDeviceList();
51225
- const physical = body.deviceList.find((d) => d.deviceId === deviceId);
51226
- const ir = body.infraredRemoteList.find((d) => d.deviceId === deviceId);
51227
- if (!physical && !ir) {
51228
- return mcpError("runtime", 152, `Device not found: ${deviceId}`, {
51229
- hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive)."
51230
- });
52119
+ );
52120
+ if (!skip("run_scene"))
52121
+ server.registerTool(
52122
+ "run_scene",
52123
+ {
52124
+ title: "Execute a manual scene",
52125
+ description: "Execute a manual SwitchBot scene by its sceneId (from list_scenes).",
52126
+ _meta: { agentSafetyTier: "action" },
52127
+ inputSchema: external_exports.object({
52128
+ sceneId: external_exports.string().describe("Scene ID from list_scenes"),
52129
+ dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
52130
+ }).strict(),
52131
+ outputSchema: {
52132
+ ok: external_exports.literal(true),
52133
+ sceneId: external_exports.string().optional(),
52134
+ dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
52135
+ wouldSend: external_exports.object({
52136
+ sceneId: external_exports.string()
52137
+ }).optional().describe("The request shape that would have been POSTed (present when dryRun:true)")
51231
52138
  }
51232
- typeName = physical ? physical.deviceType : ir.remoteType;
51233
- }
51234
- const destructive = isDestructiveCommand(typeName, effectiveCommand, effectiveType);
51235
- if (destructive && !allowsDirectDestructiveExecution()) {
51236
- const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
51237
- return mcpError(
51238
- "guard",
51239
- 3,
51240
- `Direct destructive execution is disabled for command "${effectiveCommand}" on device type "${typeName}".`,
51241
- {
51242
- hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
51243
- context: {
51244
- command: effectiveCommand,
51245
- deviceType: typeName,
51246
- directExecutionAllowed: false,
51247
- requiredWorkflow: "plan-approval",
51248
- ...reason ? { safetyReason: reason, destructiveReason: reason } : {}
51249
- }
51250
- }
51251
- );
51252
- }
51253
- if (destructive && !confirm) {
51254
- const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
51255
- const entry = typeName ? findCatalogEntry(typeName) : null;
51256
- const spec = entry && !Array.isArray(entry) ? entry.commands.find((c) => c.command === effectiveCommand) : void 0;
51257
- const hint = reason ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}` : "Re-issue the call with confirm:true to proceed.";
51258
- return mcpError(
51259
- "guard",
51260
- 3,
51261
- `Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`,
51262
- {
51263
- hint,
51264
- context: {
51265
- command: effectiveCommand,
51266
- deviceType: typeName,
51267
- description: spec?.description ?? null,
51268
- ...reason ? { safetyReason: reason, destructiveReason: reason } : {}
51269
- }
52139
+ },
52140
+ async ({ sceneId, dryRun }) => {
52141
+ if (dryRun) {
52142
+ let scenes = [];
52143
+ try {
52144
+ scenes = await fetchScenes();
52145
+ } catch {
51270
52146
  }
51271
- );
51272
- }
51273
- const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
51274
- if (!validation.ok) {
51275
- return mcpError(
51276
- "usage",
51277
- 2,
51278
- validation.error.message,
51279
- {
51280
- hint: validation.error.hint,
51281
- context: { validationKind: validation.error.kind, deviceType: typeName, command: effectiveCommand }
52147
+ const found = scenes.find((s2) => s2.sceneId === sceneId);
52148
+ if (scenes.length > 0 && !found) {
52149
+ return mcpError("usage", 2, `Scene not found: ${sceneId}`, {
52150
+ subKind: "scene-not-found",
52151
+ hint: "Check the sceneId with 'list_scenes' (IDs are case-sensitive).",
52152
+ context: { sceneId, candidates: scenes.map((s2) => ({ sceneId: s2.sceneId, sceneName: s2.sceneName })).slice(0, 5) }
52153
+ });
51282
52154
  }
51283
- );
51284
- }
51285
- if (validation.normalized) {
51286
- effectiveCommand = validation.normalized;
51287
- }
51288
- if (effectiveType !== "customize") {
51289
- const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
51290
- if (!pv.ok) {
51291
- return mcpError("usage", 2, pv.error, {
51292
- context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: "param-out-of-range" }
51293
- });
52155
+ const wouldSend = { sceneId, sceneName: found?.sceneName ?? null };
52156
+ const structured2 = { ok: true, dryRun: true, wouldSend };
52157
+ return {
52158
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52159
+ structuredContent: structured2
52160
+ };
51294
52161
  }
51295
- if (pv.normalized !== void 0) {
51296
- effectiveParameter = pv.normalized;
52162
+ try {
52163
+ await executeScene(sceneId);
52164
+ } catch (err) {
52165
+ return apiErrorToMcpError(err);
51297
52166
  }
52167
+ const structured = { ok: true, sceneId };
52168
+ return {
52169
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52170
+ structuredContent: structured
52171
+ };
51298
52172
  }
51299
- let result;
51300
- try {
51301
- result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, void 0, {
51302
- idempotencyKey
51303
- });
51304
- } catch (err) {
51305
- if (err instanceof Error && err.name === "IdempotencyConflictError") {
51306
- return mcpError("guard", 2, err.message, {
51307
- hint: "Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).",
51308
- context: {
51309
- existingShape: err.existingShape,
51310
- newShape: err.newShape
51311
- }
51312
- });
52173
+ );
52174
+ if (!skip("list_scenes"))
52175
+ server.registerTool(
52176
+ "list_scenes",
52177
+ {
52178
+ title: "List all manual scenes",
52179
+ description: "Fetch all manual scenes configured in the SwitchBot app.",
52180
+ _meta: { agentSafetyTier: "read" },
52181
+ inputSchema: external_exports.object({}).strict(),
52182
+ outputSchema: {
52183
+ scenes: external_exports.array(external_exports.object({ sceneId: external_exports.string(), sceneName: external_exports.string() }))
51313
52184
  }
51314
- return apiErrorToMcpError(err);
51315
- }
51316
- const isIr = getCachedDevice(deviceId)?.category === "ir";
51317
- const liveIsDestructive = destructive;
51318
- const riskProfile = buildRiskProfile(typeName, effectiveCommand, effectiveType, liveIsDestructive);
51319
- const structured = { ok: true, command: effectiveCommand, deviceId, result, riskProfile };
51320
- if (isIr) {
51321
- structured.verification = {
51322
- verifiable: false,
51323
- reason: "IR transmission is unidirectional; no receipt acknowledgment is possible.",
51324
- suggestedFollowup: "Confirm visible change manually or via a paired state sensor."
52185
+ },
52186
+ async () => {
52187
+ const scenes = await fetchScenes();
52188
+ return {
52189
+ content: [{ type: "text", text: JSON.stringify(scenes, null, 2) }],
52190
+ structuredContent: { scenes }
51325
52191
  };
51326
52192
  }
51327
- return {
51328
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51329
- structuredContent: structured
51330
- };
51331
- }
51332
- );
51333
- server.registerTool(
51334
- "run_scene",
51335
- {
51336
- title: "Execute a manual scene",
51337
- description: "Execute a manual SwitchBot scene by its sceneId (from list_scenes).",
51338
- _meta: { agentSafetyTier: "action" },
51339
- inputSchema: external_exports.object({
51340
- sceneId: external_exports.string().describe("Scene ID from list_scenes"),
51341
- dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
51342
- }).strict(),
51343
- outputSchema: {
51344
- ok: external_exports.literal(true),
51345
- sceneId: external_exports.string().optional(),
51346
- dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
51347
- wouldSend: external_exports.object({
51348
- sceneId: external_exports.string()
51349
- }).optional().describe("The request shape that would have been POSTed (present when dryRun:true)")
51350
- }
51351
- },
51352
- async ({ sceneId, dryRun }) => {
51353
- if (dryRun) {
51354
- let scenes = [];
51355
- try {
51356
- scenes = await fetchScenes();
51357
- } catch {
52193
+ );
52194
+ if (!skip("search_catalog"))
52195
+ server.registerTool(
52196
+ "search_catalog",
52197
+ {
52198
+ title: "Search the offline device catalog",
52199
+ description: "Search the built-in device catalog by type name or alias. Returns matching entries with their commands, roles, destructive flags, and status fields. No API call.",
52200
+ _meta: { agentSafetyTier: "read" },
52201
+ inputSchema: external_exports.object({
52202
+ query: external_exports.string().describe("Search query (matches type and aliases, case-insensitive). Must be non-empty; use list_catalog_types to enumerate instead."),
52203
+ limit: external_exports.number().int().min(1).max(100).optional().default(20).describe("Max entries returned (default 20)")
52204
+ }).strict(),
52205
+ outputSchema: {
52206
+ results: external_exports.array(external_exports.object({
52207
+ type: external_exports.string(),
52208
+ category: external_exports.enum(["physical", "ir"]),
52209
+ commands: external_exports.array(external_exports.object({
52210
+ command: external_exports.string(),
52211
+ parameter: external_exports.string(),
52212
+ description: external_exports.string(),
52213
+ commandType: external_exports.enum(["command", "customize"]).optional(),
52214
+ idempotent: external_exports.boolean().optional(),
52215
+ safetyTier: external_exports.enum(["read", "mutation", "ir-fire-forget", "destructive", "maintenance"]).optional(),
52216
+ safetyReason: external_exports.string().optional()
52217
+ }).passthrough()),
52218
+ aliases: external_exports.array(external_exports.string()).optional(),
52219
+ statusFields: external_exports.array(external_exports.string()).optional(),
52220
+ role: external_exports.string().optional(),
52221
+ readOnly: external_exports.boolean().optional()
52222
+ }).passthrough()).describe("Matching catalog entries"),
52223
+ total: external_exports.number().int().describe("Number of entries returned")
51358
52224
  }
51359
- const found = scenes.find((s2) => s2.sceneId === sceneId);
51360
- if (scenes.length > 0 && !found) {
51361
- return mcpError("usage", 2, `Scene not found: ${sceneId}`, {
51362
- subKind: "scene-not-found",
51363
- hint: "Check the sceneId with 'list_scenes' (IDs are case-sensitive).",
51364
- context: { sceneId, candidates: scenes.map((s2) => ({ sceneId: s2.sceneId, sceneName: s2.sceneName })).slice(0, 5) }
51365
- });
52225
+ },
52226
+ async ({ query, limit }) => {
52227
+ if (query.trim() === "") {
52228
+ return mcpError(
52229
+ "usage",
52230
+ 2,
52231
+ "search_catalog requires a non-empty query.",
52232
+ {
52233
+ hint: "Pass a search term like 'Bot' or 'Hub', or call list_catalog_types to enumerate all types without a query."
52234
+ }
52235
+ );
51366
52236
  }
51367
- const wouldSend = { sceneId, sceneName: found?.sceneName ?? null };
51368
- const structured2 = { ok: true, dryRun: true, wouldSend };
52237
+ const hits = searchCatalog(query, limit);
52238
+ const normalised = hits.map((e) => ({
52239
+ ...e,
52240
+ commands: e.commands.map((c) => {
52241
+ const tier = deriveSafetyTier(c, e);
52242
+ const reason = getCommandSafetyReason(c);
52243
+ return {
52244
+ ...c,
52245
+ safetyTier: tier,
52246
+ ...reason ? { safetyReason: reason } : {}
52247
+ };
52248
+ })
52249
+ }));
52250
+ const structured = { results: normalised, total: normalised.length };
51369
52251
  return {
51370
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
51371
- structuredContent: structured2
52252
+ content: [{ type: "text", text: JSON.stringify(normalised, null, 2) }],
52253
+ structuredContent: structured
51372
52254
  };
51373
52255
  }
51374
- try {
51375
- await executeScene(sceneId);
51376
- } catch (err) {
51377
- return apiErrorToMcpError(err);
51378
- }
51379
- const structured = { ok: true, sceneId };
51380
- return {
51381
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51382
- structuredContent: structured
51383
- };
51384
- }
51385
- );
51386
- server.registerTool(
51387
- "list_scenes",
51388
- {
51389
- title: "List all manual scenes",
51390
- description: "Fetch all manual scenes configured in the SwitchBot app.",
51391
- _meta: { agentSafetyTier: "read" },
51392
- inputSchema: external_exports.object({}).strict(),
51393
- outputSchema: {
51394
- scenes: external_exports.array(external_exports.object({ sceneId: external_exports.string(), sceneName: external_exports.string() }))
51395
- }
51396
- },
51397
- async () => {
51398
- const scenes = await fetchScenes();
51399
- return {
51400
- content: [{ type: "text", text: JSON.stringify(scenes, null, 2) }],
51401
- structuredContent: { scenes }
51402
- };
51403
- }
51404
- );
51405
- server.registerTool(
51406
- "search_catalog",
51407
- {
51408
- title: "Search the offline device catalog",
51409
- description: "Search the built-in device catalog by type name or alias. Returns matching entries with their commands, roles, destructive flags, and status fields. No API call.",
51410
- _meta: { agentSafetyTier: "read" },
51411
- inputSchema: external_exports.object({
51412
- query: external_exports.string().describe("Search query (matches type and aliases, case-insensitive). Must be non-empty; use list_catalog_types to enumerate instead."),
51413
- limit: external_exports.number().int().min(1).max(100).optional().default(20).describe("Max entries returned (default 20)")
51414
- }).strict(),
51415
- outputSchema: {
51416
- results: external_exports.array(external_exports.object({
51417
- type: external_exports.string(),
51418
- category: external_exports.enum(["physical", "ir"]),
51419
- commands: external_exports.array(external_exports.object({
51420
- command: external_exports.string(),
51421
- parameter: external_exports.string(),
51422
- description: external_exports.string(),
51423
- commandType: external_exports.enum(["command", "customize"]).optional(),
51424
- idempotent: external_exports.boolean().optional(),
51425
- safetyTier: external_exports.enum(["read", "mutation", "ir-fire-forget", "destructive", "maintenance"]).optional(),
51426
- safetyReason: external_exports.string().optional()
51427
- }).passthrough()),
51428
- aliases: external_exports.array(external_exports.string()).optional(),
51429
- statusFields: external_exports.array(external_exports.string()).optional(),
51430
- role: external_exports.string().optional(),
51431
- readOnly: external_exports.boolean().optional()
51432
- }).passthrough()).describe("Matching catalog entries"),
51433
- total: external_exports.number().int().describe("Number of entries returned")
51434
- }
51435
- },
51436
- async ({ query, limit }) => {
51437
- if (query.trim() === "") {
51438
- return mcpError(
51439
- "usage",
51440
- 2,
51441
- "search_catalog requires a non-empty query.",
51442
- {
51443
- hint: "Pass a search term like 'Bot' or 'Hub', or call list_catalog_types to enumerate all types without a query."
51444
- }
51445
- );
51446
- }
51447
- const hits = searchCatalog(query, limit);
51448
- const normalised = hits.map((e) => ({
51449
- ...e,
51450
- commands: e.commands.map((c) => {
51451
- const tier = deriveSafetyTier(c, e);
51452
- const reason = getCommandSafetyReason(c);
52256
+ );
52257
+ if (!skip("describe_device"))
52258
+ server.registerTool(
52259
+ "describe_device",
52260
+ {
52261
+ title: "Describe a specific device",
52262
+ description: "Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.",
52263
+ _meta: { agentSafetyTier: "read" },
52264
+ inputSchema: external_exports.object({
52265
+ deviceId: external_exports.string().describe("Device ID from list_devices"),
52266
+ live: external_exports.boolean().optional().default(false).describe("Also fetch live /status values (costs 1 extra API call)")
52267
+ }).strict(),
52268
+ outputSchema: {
52269
+ device: external_exports.object({
52270
+ device: external_exports.object({ deviceId: external_exports.string(), deviceName: external_exports.string() }).passthrough(),
52271
+ isPhysical: external_exports.boolean(),
52272
+ typeName: external_exports.string(),
52273
+ controlType: external_exports.string().nullable(),
52274
+ source: external_exports.enum(["catalog", "live", "catalog+live", "none"]),
52275
+ capabilities: external_exports.unknown().nullable(),
52276
+ suggestedActions: external_exports.array(external_exports.object({
52277
+ command: external_exports.string(),
52278
+ parameter: external_exports.string().optional(),
52279
+ description: external_exports.string()
52280
+ })).optional(),
52281
+ inheritedLocation: external_exports.object({
52282
+ family: external_exports.string().optional(),
52283
+ room: external_exports.string().optional()
52284
+ }).optional()
52285
+ }).passthrough().describe("Device metadata, catalog entry, capabilities, and optional live status")
52286
+ }
52287
+ },
52288
+ async ({ deviceId, live }) => {
52289
+ try {
52290
+ const result = await describeDevice(deviceId, { live });
51453
52291
  return {
51454
- ...c,
51455
- safetyTier: tier,
51456
- ...reason ? { safetyReason: reason } : {}
52292
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
52293
+ structuredContent: { device: toMcpDescribeShape(result) }
51457
52294
  };
51458
- })
51459
- }));
51460
- const structured = { results: normalised, total: normalised.length };
51461
- return {
51462
- content: [{ type: "text", text: JSON.stringify(normalised, null, 2) }],
51463
- structuredContent: structured
51464
- };
51465
- }
51466
- );
51467
- server.registerTool(
51468
- "describe_device",
51469
- {
51470
- title: "Describe a specific device",
51471
- description: "Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.",
51472
- _meta: { agentSafetyTier: "read" },
51473
- inputSchema: external_exports.object({
51474
- deviceId: external_exports.string().describe("Device ID from list_devices"),
51475
- live: external_exports.boolean().optional().default(false).describe("Also fetch live /status values (costs 1 extra API call)")
51476
- }).strict(),
51477
- outputSchema: {
51478
- device: external_exports.object({
51479
- device: external_exports.object({ deviceId: external_exports.string(), deviceName: external_exports.string() }).passthrough(),
51480
- isPhysical: external_exports.boolean(),
51481
- typeName: external_exports.string(),
51482
- controlType: external_exports.string().nullable(),
51483
- source: external_exports.enum(["catalog", "live", "catalog+live", "none"]),
51484
- capabilities: external_exports.unknown().nullable(),
51485
- suggestedActions: external_exports.array(external_exports.object({
51486
- command: external_exports.string(),
51487
- parameter: external_exports.string().optional(),
51488
- description: external_exports.string()
51489
- })).optional(),
51490
- inheritedLocation: external_exports.object({
51491
- family: external_exports.string().optional(),
51492
- room: external_exports.string().optional()
51493
- }).optional()
51494
- }).passthrough().describe("Device metadata, catalog entry, capabilities, and optional live status")
52295
+ } catch (err) {
52296
+ if (err instanceof DeviceNotFoundError) {
52297
+ return mcpError("runtime", 152, err.message, {
52298
+ hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).",
52299
+ context: { deviceId }
52300
+ });
52301
+ }
52302
+ return apiErrorToMcpError(err);
52303
+ }
51495
52304
  }
51496
- },
51497
- async ({ deviceId, live }) => {
51498
- try {
51499
- const result = await describeDevice(deviceId, { live });
52305
+ );
52306
+ if (!skip("aggregate_device_history"))
52307
+ server.registerTool(
52308
+ "aggregate_device_history",
52309
+ {
52310
+ title: "Aggregate device history",
52311
+ description: "Bucketed statistics (count/min/max/avg/sum/p50/p95) over JSONL-recorded device history. Read-only; no network calls.",
52312
+ _meta: { agentSafetyTier: "read" },
52313
+ inputSchema: external_exports.object({
52314
+ deviceId: external_exports.string().min(1).describe("Device ID to aggregate over (must exist in ~/.switchbot/device-history/)."),
52315
+ since: external_exports.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
52316
+ from: external_exports.string().optional().describe("Range start (ISO-8601). Requires `to`."),
52317
+ to: external_exports.string().optional().describe("Range end (ISO-8601). Requires `from`."),
52318
+ metrics: external_exports.array(external_exports.string().min(1)).min(1).describe('One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).'),
52319
+ aggs: external_exports.array(external_exports.enum(ALL_AGG_FNS)).optional().describe('Aggregation functions to apply per metric (default: ["count","avg"]).'),
52320
+ bucket: external_exports.string().optional().describe('Bucket width like "5m", "1h", "1d". Omit for a single bucket spanning the full range.'),
52321
+ maxBucketSamples: external_exports.number().int().positive().max(MAX_SAMPLE_CAP).optional().describe(`Sample cap per bucket to bound memory (default ${1e4}, max ${MAX_SAMPLE_CAP}). partial=true in the result when any bucket was capped.`)
52322
+ }).strict(),
52323
+ outputSchema: {
52324
+ deviceId: external_exports.string(),
52325
+ bucket: external_exports.string().optional().describe("Bucket width echoed back when specified; omitted for single-bucket results."),
52326
+ from: external_exports.string().describe("Effective range start (ISO-8601)."),
52327
+ to: external_exports.string().describe("Effective range end (ISO-8601)."),
52328
+ metrics: external_exports.array(external_exports.string()).describe("Metrics that were requested."),
52329
+ aggs: external_exports.array(external_exports.enum(ALL_AGG_FNS)).describe("Aggregation functions that were applied."),
52330
+ buckets: external_exports.array(
52331
+ external_exports.object({
52332
+ t: external_exports.string().describe("Bucket start timestamp (ISO-8601)."),
52333
+ metrics: external_exports.record(
52334
+ external_exports.string(),
52335
+ external_exports.object({
52336
+ count: external_exports.number().optional(),
52337
+ min: external_exports.number().optional(),
52338
+ max: external_exports.number().optional(),
52339
+ avg: external_exports.number().optional(),
52340
+ sum: external_exports.number().optional(),
52341
+ p50: external_exports.number().optional(),
52342
+ p95: external_exports.number().optional()
52343
+ }).describe("Per-aggregate function result for this metric in this bucket.")
52344
+ ).describe("Per-metric result keyed by metric name.")
52345
+ })
52346
+ ).describe("Time-ordered buckets; empty when no records match."),
52347
+ partial: external_exports.boolean().describe("True if any bucket was sample-capped; retry with a higher maxBucketSamples or a narrower range for exact values."),
52348
+ notes: external_exports.array(external_exports.string()).describe('Human-readable notes about the aggregation (e.g. "metric X is non-numeric").')
52349
+ }
52350
+ },
52351
+ async (args) => {
52352
+ const opts = {
52353
+ since: args.since,
52354
+ from: args.from,
52355
+ to: args.to,
52356
+ metrics: args.metrics,
52357
+ aggs: args.aggs,
52358
+ bucket: args.bucket,
52359
+ maxBucketSamples: args.maxBucketSamples
52360
+ };
52361
+ const res = await aggregateDeviceHistory(args.deviceId, opts);
52362
+ const structured = {
52363
+ deviceId: res.deviceId,
52364
+ from: res.from,
52365
+ to: res.to,
52366
+ metrics: res.metrics,
52367
+ aggs: res.aggs,
52368
+ buckets: res.buckets,
52369
+ partial: res.partial,
52370
+ notes: res.notes
52371
+ };
52372
+ if (res.bucket !== void 0) structured.bucket = res.bucket;
51500
52373
  return {
51501
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
51502
- structuredContent: { device: toMcpDescribeShape(result) }
52374
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
52375
+ structuredContent: structured
51503
52376
  };
51504
- } catch (err) {
51505
- if (err instanceof DeviceNotFoundError) {
51506
- return mcpError("runtime", 152, err.message, {
51507
- hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).",
51508
- context: { deviceId }
51509
- });
51510
- }
51511
- return apiErrorToMcpError(err);
51512
52377
  }
51513
- }
51514
- );
51515
- server.registerTool(
51516
- "aggregate_device_history",
51517
- {
51518
- title: "Aggregate device history",
51519
- description: "Bucketed statistics (count/min/max/avg/sum/p50/p95) over JSONL-recorded device history. Read-only; no network calls.",
51520
- _meta: { agentSafetyTier: "read" },
51521
- inputSchema: external_exports.object({
51522
- deviceId: external_exports.string().min(1).describe("Device ID to aggregate over (must exist in ~/.switchbot/device-history/)."),
51523
- since: external_exports.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
51524
- from: external_exports.string().optional().describe("Range start (ISO-8601). Requires `to`."),
51525
- to: external_exports.string().optional().describe("Range end (ISO-8601). Requires `from`."),
51526
- metrics: external_exports.array(external_exports.string().min(1)).min(1).describe('One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).'),
51527
- aggs: external_exports.array(external_exports.enum(ALL_AGG_FNS)).optional().describe('Aggregation functions to apply per metric (default: ["count","avg"]).'),
51528
- bucket: external_exports.string().optional().describe('Bucket width like "5m", "1h", "1d". Omit for a single bucket spanning the full range.'),
51529
- maxBucketSamples: external_exports.number().int().positive().max(MAX_SAMPLE_CAP).optional().describe(`Sample cap per bucket to bound memory (default ${1e4}, max ${MAX_SAMPLE_CAP}). partial=true in the result when any bucket was capped.`)
51530
- }).strict(),
51531
- outputSchema: {
51532
- deviceId: external_exports.string(),
51533
- bucket: external_exports.string().optional().describe("Bucket width echoed back when specified; omitted for single-bucket results."),
51534
- from: external_exports.string().describe("Effective range start (ISO-8601)."),
51535
- to: external_exports.string().describe("Effective range end (ISO-8601)."),
51536
- metrics: external_exports.array(external_exports.string()).describe("Metrics that were requested."),
51537
- aggs: external_exports.array(external_exports.enum(ALL_AGG_FNS)).describe("Aggregation functions that were applied."),
51538
- buckets: external_exports.array(
51539
- external_exports.object({
51540
- t: external_exports.string().describe("Bucket start timestamp (ISO-8601)."),
51541
- metrics: external_exports.record(
51542
- external_exports.string(),
51543
- external_exports.object({
51544
- count: external_exports.number().optional(),
51545
- min: external_exports.number().optional(),
51546
- max: external_exports.number().optional(),
51547
- avg: external_exports.number().optional(),
51548
- sum: external_exports.number().optional(),
51549
- p50: external_exports.number().optional(),
51550
- p95: external_exports.number().optional()
51551
- }).describe("Per-aggregate function result for this metric in this bucket.")
51552
- ).describe("Per-metric result keyed by metric name.")
51553
- })
51554
- ).describe("Time-ordered buckets; empty when no records match."),
51555
- partial: external_exports.boolean().describe("True if any bucket was sample-capped; retry with a higher maxBucketSamples or a narrower range for exact values."),
51556
- notes: external_exports.array(external_exports.string()).describe('Human-readable notes about the aggregation (e.g. "metric X is non-numeric").')
52378
+ );
52379
+ if (!skip("account_overview"))
52380
+ server.registerTool(
52381
+ "account_overview",
52382
+ {
52383
+ title: "Bootstrap account overview",
52384
+ description: "Get a complete account snapshot: devices, scenes, quota usage, cache status, and MQTT connection state. Use this for cold-start initialization or periodic health checks.",
52385
+ _meta: { agentSafetyTier: "read" },
52386
+ inputSchema: external_exports.object({}).strict(),
52387
+ outputSchema: {
52388
+ version: external_exports.string(),
52389
+ schemaVersion: external_exports.string(),
52390
+ devices: external_exports.array(external_exports.object({
52391
+ deviceId: external_exports.string(),
52392
+ deviceName: external_exports.string(),
52393
+ deviceType: external_exports.string().optional()
52394
+ }).passthrough()).describe("All physical devices"),
52395
+ infraredRemotes: external_exports.array(external_exports.object({
52396
+ deviceId: external_exports.string(),
52397
+ deviceName: external_exports.string(),
52398
+ remoteType: external_exports.string()
52399
+ }).passthrough()).describe("All IR remotes"),
52400
+ scenes: external_exports.array(external_exports.object({
52401
+ sceneId: external_exports.string(),
52402
+ sceneName: external_exports.string()
52403
+ }).passthrough()).describe("All manual scenes"),
52404
+ quota: external_exports.object({
52405
+ date: external_exports.string(),
52406
+ total: external_exports.number(),
52407
+ remaining: external_exports.number(),
52408
+ endpoints: external_exports.record(external_exports.string(), external_exports.number()).optional()
52409
+ }).describe("Today's quota usage"),
52410
+ cache: external_exports.object({
52411
+ list: external_exports.object({
52412
+ path: external_exports.string(),
52413
+ exists: external_exports.boolean(),
52414
+ lastUpdated: external_exports.string().optional(),
52415
+ ageMs: external_exports.number().optional(),
52416
+ deviceCount: external_exports.number().optional()
52417
+ }),
52418
+ status: external_exports.object({
52419
+ path: external_exports.string(),
52420
+ exists: external_exports.boolean(),
52421
+ entryCount: external_exports.number(),
52422
+ oldestFetchedAt: external_exports.string().optional(),
52423
+ newestFetchedAt: external_exports.string().optional()
52424
+ })
52425
+ }).describe("Cache status"),
52426
+ mqtt: external_exports.object({
52427
+ state: external_exports.string(),
52428
+ subscribers: external_exports.number()
52429
+ }).optional().describe("MQTT connection state (present when REST credentials are configured; auto-provisioned via POST /v1.1/iot/credential)")
52430
+ }
52431
+ },
52432
+ async () => {
52433
+ const deviceList = await fetchDeviceList();
52434
+ const sceneList = await fetchScenes();
52435
+ const cacheInfo = describeCache();
52436
+ const quota = todayUsage();
52437
+ const overview = {
52438
+ version: VERSION,
52439
+ schemaVersion: "1.1",
52440
+ devices: deviceList.deviceList.map(toMcpDeviceListShape),
52441
+ infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
52442
+ scenes: sceneList.map((s2) => ({
52443
+ sceneId: s2.sceneId,
52444
+ sceneName: s2.sceneName
52445
+ })),
52446
+ quota: {
52447
+ date: quota.date,
52448
+ total: quota.total,
52449
+ remaining: quota.remaining,
52450
+ endpoints: quota.endpoints
52451
+ },
52452
+ cache: {
52453
+ list: cacheInfo.list,
52454
+ status: cacheInfo.status
52455
+ },
52456
+ ...eventManager ? {
52457
+ mqtt: {
52458
+ state: eventManager.getState(),
52459
+ subscribers: eventManager.getSubscriberCount()
52460
+ }
52461
+ } : {}
52462
+ };
52463
+ return {
52464
+ content: [{
52465
+ type: "text",
52466
+ text: JSON.stringify(overview, null, 2)
52467
+ }],
52468
+ structuredContent: overview
52469
+ };
51557
52470
  }
51558
- },
51559
- async (args) => {
51560
- const opts = {
51561
- since: args.since,
51562
- from: args.from,
51563
- to: args.to,
51564
- metrics: args.metrics,
51565
- aggs: args.aggs,
51566
- bucket: args.bucket,
51567
- maxBucketSamples: args.maxBucketSamples
51568
- };
51569
- const res = await aggregateDeviceHistory(args.deviceId, opts);
51570
- const structured = {
51571
- deviceId: res.deviceId,
51572
- from: res.from,
51573
- to: res.to,
51574
- metrics: res.metrics,
51575
- aggs: res.aggs,
51576
- buckets: res.buckets,
51577
- partial: res.partial,
51578
- notes: res.notes
51579
- };
51580
- if (res.bucket !== void 0) structured.bucket = res.bucket;
51581
- return {
51582
- content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
51583
- structuredContent: structured
51584
- };
51585
- }
51586
- );
51587
- server.registerTool(
51588
- "account_overview",
51589
- {
51590
- title: "Bootstrap account overview",
51591
- description: "Get a complete account snapshot: devices, scenes, quota usage, cache status, and MQTT connection state. Use this for cold-start initialization or periodic health checks.",
51592
- _meta: { agentSafetyTier: "read" },
51593
- inputSchema: external_exports.object({}).strict(),
51594
- outputSchema: {
51595
- version: external_exports.string(),
51596
- schemaVersion: external_exports.string(),
51597
- devices: external_exports.array(external_exports.object({
51598
- deviceId: external_exports.string(),
51599
- deviceName: external_exports.string(),
51600
- deviceType: external_exports.string().optional()
51601
- }).passthrough()).describe("All physical devices"),
51602
- infraredRemotes: external_exports.array(external_exports.object({
51603
- deviceId: external_exports.string(),
51604
- deviceName: external_exports.string(),
51605
- remoteType: external_exports.string()
51606
- }).passthrough()).describe("All IR remotes"),
51607
- scenes: external_exports.array(external_exports.object({
51608
- sceneId: external_exports.string(),
51609
- sceneName: external_exports.string()
51610
- }).passthrough()).describe("All manual scenes"),
51611
- quota: external_exports.object({
51612
- date: external_exports.string(),
51613
- total: external_exports.number(),
51614
- remaining: external_exports.number(),
51615
- endpoints: external_exports.record(external_exports.string(), external_exports.number()).optional()
51616
- }).describe("Today's quota usage"),
51617
- cache: external_exports.object({
51618
- list: external_exports.object({
51619
- path: external_exports.string(),
51620
- exists: external_exports.boolean(),
51621
- lastUpdated: external_exports.string().optional(),
51622
- ageMs: external_exports.number().optional(),
51623
- deviceCount: external_exports.number().optional()
51624
- }),
51625
- status: external_exports.object({
52471
+ );
52472
+ if (!skip("policy_validate"))
52473
+ server.registerTool(
52474
+ "policy_validate",
52475
+ {
52476
+ title: "Validate a policy.yaml file",
52477
+ description: "Check a policy file against the embedded JSON Schema, offline command/device semantics, and local safety guards. By default this stays offline; set live=true to resolve aliases and rule targets against the current account inventory. It still does not verify commands against live capabilities, current firmware, or other runtime-only device behavior. When no path is given, reads the resolved default (${SWITCHBOT_POLICY_PATH} or ~/.config/openclaw/switchbot/policy.yaml). Use before relying on aliases/quiet_hours/confirmations so the agent never acts on a broken policy.",
52478
+ _meta: { agentSafetyTier: "read" },
52479
+ inputSchema: external_exports.object({
52480
+ path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
52481
+ live: external_exports.boolean().optional().describe("When true, also resolve aliases and rule targets against the current account inventory")
52482
+ }).strict(),
52483
+ outputSchema: {
52484
+ policyPath: external_exports.string(),
52485
+ schemaVersion: external_exports.string(),
52486
+ validationScope: external_exports.string(),
52487
+ limitations: external_exports.array(external_exports.string()),
52488
+ present: external_exports.boolean().describe("false when the file does not exist"),
52489
+ valid: external_exports.boolean().nullable().describe("null when present=false"),
52490
+ errors: external_exports.array(external_exports.object({
51626
52491
  path: external_exports.string(),
51627
- exists: external_exports.boolean(),
51628
- entryCount: external_exports.number(),
51629
- oldestFetchedAt: external_exports.string().optional(),
51630
- newestFetchedAt: external_exports.string().optional()
51631
- })
51632
- }).describe("Cache status"),
51633
- mqtt: external_exports.object({
51634
- state: external_exports.string(),
51635
- subscribers: external_exports.number()
51636
- }).optional().describe("MQTT connection state (present when REST credentials are configured; auto-provisioned via POST /v1.1/iot/credential)")
51637
- }
51638
- },
51639
- async () => {
51640
- const deviceList = await fetchDeviceList();
51641
- const sceneList = await fetchScenes();
51642
- const cacheInfo = describeCache();
51643
- const quota = todayUsage();
51644
- const overview = {
51645
- version: VERSION,
51646
- schemaVersion: "1.1",
51647
- devices: deviceList.deviceList.map(toMcpDeviceListShape),
51648
- infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
51649
- scenes: sceneList.map((s2) => ({
51650
- sceneId: s2.sceneId,
51651
- sceneName: s2.sceneName
51652
- })),
51653
- quota: {
51654
- date: quota.date,
51655
- total: quota.total,
51656
- remaining: quota.remaining,
51657
- endpoints: quota.endpoints
51658
- },
51659
- cache: {
51660
- list: cacheInfo.list,
51661
- status: cacheInfo.status
51662
- },
51663
- ...eventManager ? {
51664
- mqtt: {
51665
- state: eventManager.getState(),
51666
- subscribers: eventManager.getSubscriberCount()
52492
+ line: external_exports.number().optional(),
52493
+ col: external_exports.number().optional(),
52494
+ keyword: external_exports.string(),
52495
+ message: external_exports.string(),
52496
+ hint: external_exports.string().optional(),
52497
+ schemaPath: external_exports.string()
52498
+ })).describe("Empty when valid or when the file is missing")
52499
+ }
52500
+ },
52501
+ async ({ path: pathArg, live }) => {
52502
+ const policyPath = resolvePolicyPath({ flag: pathArg });
52503
+ try {
52504
+ const loaded = loadPolicyFile(policyPath);
52505
+ let result = validateLoadedPolicy(loaded);
52506
+ if (live) {
52507
+ if (!tryLoadConfig()) {
52508
+ return mcpError("runtime", 151, "policy_validate live=true requires configured SwitchBot credentials.", {
52509
+ hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
52510
+ });
52511
+ }
52512
+ const inventory = await fetchDeviceList(void 0, { bypassCache: true });
52513
+ result = validateLoadedPolicyAgainstInventory(loaded, inventory);
51667
52514
  }
51668
- } : {}
51669
- };
51670
- return {
51671
- content: [{
51672
- type: "text",
51673
- text: JSON.stringify(overview, null, 2)
51674
- }],
51675
- structuredContent: overview
51676
- };
51677
- }
51678
- );
51679
- server.registerTool(
51680
- "policy_validate",
51681
- {
51682
- title: "Validate a policy.yaml file",
51683
- description: "Check a policy file against the embedded JSON Schema, offline command/device semantics, and local safety guards. By default this stays offline; set live=true to resolve aliases and rule targets against the current account inventory. It still does not verify commands against live capabilities, current firmware, or other runtime-only device behavior. When no path is given, reads the resolved default (${SWITCHBOT_POLICY_PATH} or ~/.config/openclaw/switchbot/policy.yaml). Use before relying on aliases/quiet_hours/confirmations so the agent never acts on a broken policy.",
51684
- _meta: { agentSafetyTier: "read" },
51685
- inputSchema: external_exports.object({
51686
- path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
51687
- live: external_exports.boolean().optional().describe("When true, also resolve aliases and rule targets against the current account inventory")
51688
- }).strict(),
51689
- outputSchema: {
51690
- policyPath: external_exports.string(),
51691
- schemaVersion: external_exports.string(),
51692
- validationScope: external_exports.string(),
51693
- limitations: external_exports.array(external_exports.string()),
51694
- present: external_exports.boolean().describe("false when the file does not exist"),
51695
- valid: external_exports.boolean().nullable().describe("null when present=false"),
51696
- errors: external_exports.array(external_exports.object({
51697
- path: external_exports.string(),
51698
- line: external_exports.number().optional(),
51699
- col: external_exports.number().optional(),
51700
- keyword: external_exports.string(),
51701
- message: external_exports.string(),
51702
- hint: external_exports.string().optional(),
51703
- schemaPath: external_exports.string()
51704
- })).describe("Empty when valid or when the file is missing")
51705
- }
51706
- },
51707
- async ({ path: pathArg, live }) => {
51708
- const policyPath = resolvePolicyPath({ flag: pathArg });
51709
- try {
51710
- const loaded = loadPolicyFile(policyPath);
51711
- let result = validateLoadedPolicy(loaded);
51712
- if (live) {
51713
- if (!tryLoadConfig()) {
51714
- return mcpError("runtime", 151, "policy_validate live=true requires configured SwitchBot credentials.", {
51715
- hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
51716
- });
52515
+ const structured = {
52516
+ policyPath: result.policyPath,
52517
+ schemaVersion: result.schemaVersion,
52518
+ validationScope: result.validationScope,
52519
+ limitations: result.limitations,
52520
+ present: true,
52521
+ valid: result.valid,
52522
+ errors: result.errors
52523
+ };
52524
+ return {
52525
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52526
+ structuredContent: structured
52527
+ };
52528
+ } catch (err) {
52529
+ if (err instanceof PolicyFileNotFoundError) {
52530
+ const structured = {
52531
+ policyPath,
52532
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
52533
+ validationScope: "schema+offline-semantics",
52534
+ limitations: [
52535
+ "Does not resolve aliases against the live device inventory.",
52536
+ "Does not verify commands against the real target device, live capabilities, or current firmware."
52537
+ ],
52538
+ present: false,
52539
+ valid: null,
52540
+ errors: []
52541
+ };
52542
+ return {
52543
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52544
+ structuredContent: structured
52545
+ };
52546
+ }
52547
+ if (err instanceof PolicyYamlParseError) {
52548
+ const structured = {
52549
+ policyPath,
52550
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
52551
+ validationScope: "schema+offline-semantics",
52552
+ limitations: [
52553
+ "Does not resolve aliases against the live device inventory.",
52554
+ "Does not verify commands against the real target device, live capabilities, or current firmware."
52555
+ ],
52556
+ present: true,
52557
+ valid: false,
52558
+ errors: err.yamlErrors.map((e) => ({
52559
+ path: "",
52560
+ line: e.line,
52561
+ col: e.col,
52562
+ keyword: "yaml-parse",
52563
+ message: e.message,
52564
+ schemaPath: ""
52565
+ }))
52566
+ };
52567
+ return {
52568
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52569
+ structuredContent: structured
52570
+ };
51717
52571
  }
51718
- const inventory = await fetchDeviceList(void 0, { bypassCache: true });
51719
- result = validateLoadedPolicyAgainstInventory(loaded, inventory);
52572
+ throw err;
52573
+ }
52574
+ }
52575
+ );
52576
+ if (!skip("policy_new"))
52577
+ server.registerTool(
52578
+ "policy_new",
52579
+ {
52580
+ title: "Scaffold a starter policy.yaml",
52581
+ description: "Write a starter policy file to the resolved default path (or a given path). Refuses to overwrite unless force=true. This is a write action: the agent should only call it after confirming with the user.",
52582
+ _meta: { agentSafetyTier: "action" },
52583
+ inputSchema: external_exports.object({
52584
+ path: external_exports.string().optional().describe("Optional target path; defaults to the resolved default"),
52585
+ force: external_exports.boolean().optional().describe("When true, overwrite an existing file")
52586
+ }).strict(),
52587
+ outputSchema: {
52588
+ policyPath: external_exports.string(),
52589
+ schemaVersion: external_exports.string(),
52590
+ bytesWritten: external_exports.number(),
52591
+ overwritten: external_exports.boolean()
51720
52592
  }
52593
+ },
52594
+ async ({ path: pathArg, force }) => {
52595
+ const policyPath = resolvePolicyPath({ flag: pathArg });
52596
+ const doForce = force === true;
52597
+ if (fs18.existsSync(policyPath) && !doForce) {
52598
+ return mcpError("guard", 5, `refusing to overwrite existing policy at ${policyPath}`, {
52599
+ hint: "pass force=true to overwrite, or choose a different path",
52600
+ context: { policyPath }
52601
+ });
52602
+ }
52603
+ const template = readPolicyExampleYaml();
52604
+ fs18.mkdirSync(pathDirname(policyPath), { recursive: true });
52605
+ fs18.writeFileSync(policyPath, template, { encoding: "utf-8" });
51721
52606
  const structured = {
51722
- policyPath: result.policyPath,
51723
- schemaVersion: result.schemaVersion,
51724
- validationScope: result.validationScope,
51725
- limitations: result.limitations,
51726
- present: true,
51727
- valid: result.valid,
51728
- errors: result.errors
52607
+ policyPath,
52608
+ schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
52609
+ bytesWritten: Buffer.byteLength(template, "utf-8"),
52610
+ overwritten: doForce
51729
52611
  };
51730
52612
  return {
51731
52613
  content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51732
52614
  structuredContent: structured
51733
52615
  };
51734
- } catch (err) {
51735
- if (err instanceof PolicyFileNotFoundError) {
51736
- const structured = {
51737
- policyPath,
51738
- schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
51739
- validationScope: "schema+offline-semantics",
51740
- limitations: [
51741
- "Does not resolve aliases against the live device inventory.",
51742
- "Does not verify commands against the real target device, live capabilities, or current firmware."
51743
- ],
51744
- present: false,
51745
- valid: null,
51746
- errors: []
52616
+ }
52617
+ );
52618
+ if (!skip("policy_migrate"))
52619
+ server.registerTool(
52620
+ "policy_migrate",
52621
+ {
52622
+ title: "Migrate a policy file to the latest supported schema",
52623
+ description: 'Rewrites a policy file between schema versions this CLI still supports while preserving comments. Safe by default: if the migrated document would fail schema validation, the file is NOT rewritten and the tool returns status="precheck-failed" with the list of errors. Pass dryRun=true to preview without touching the file. This release only supports v0.2, so legacy v0.1 files are reported as unsupported rather than migrated.',
52624
+ _meta: { agentSafetyTier: "action" },
52625
+ inputSchema: external_exports.object({
52626
+ path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
52627
+ dryRun: external_exports.boolean().optional().describe("When true, report what would change without writing"),
52628
+ to: external_exports.string().optional().describe(`Target schema version (default: latest supported, "${LATEST_SUPPORTED_VERSION}")`)
52629
+ }).strict(),
52630
+ outputSchema: {
52631
+ policyPath: external_exports.string(),
52632
+ fileVersion: external_exports.string().optional(),
52633
+ targetVersion: external_exports.string(),
52634
+ supportedVersions: external_exports.array(external_exports.string()),
52635
+ status: external_exports.enum([
52636
+ "already-current",
52637
+ "migrated",
52638
+ "dry-run",
52639
+ "no-version-field",
52640
+ "unsupported",
52641
+ "precheck-failed",
52642
+ "file-not-found"
52643
+ ]),
52644
+ from: external_exports.string().optional(),
52645
+ to: external_exports.string().optional(),
52646
+ bytesWritten: external_exports.number().optional(),
52647
+ message: external_exports.string(),
52648
+ errors: external_exports.array(external_exports.object({ path: external_exports.string(), keyword: external_exports.string(), message: external_exports.string() })).optional()
52649
+ }
52650
+ },
52651
+ async ({ path: pathArg, dryRun, to }) => {
52652
+ const policyPath = resolvePolicyPath({ flag: pathArg });
52653
+ const target = to ?? LATEST_SUPPORTED_VERSION;
52654
+ let loaded;
52655
+ try {
52656
+ loaded = loadPolicyFile(policyPath);
52657
+ } catch (err) {
52658
+ if (err instanceof PolicyFileNotFoundError) {
52659
+ const structured2 = {
52660
+ policyPath,
52661
+ targetVersion: target,
52662
+ supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
52663
+ status: "file-not-found",
52664
+ message: `policy file not found: ${policyPath}`
52665
+ };
52666
+ return {
52667
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52668
+ structuredContent: structured2
52669
+ };
52670
+ }
52671
+ throw err;
52672
+ }
52673
+ const data = loaded.data;
52674
+ const fileVersion = typeof data?.version === "string" ? data.version : void 0;
52675
+ const base = {
52676
+ policyPath,
52677
+ fileVersion,
52678
+ targetVersion: target,
52679
+ supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS]
52680
+ };
52681
+ if (!fileVersion) {
52682
+ const structured2 = {
52683
+ ...base,
52684
+ status: "no-version-field",
52685
+ message: `policy has no \`version\` field \u2014 add \`version: "${CURRENT_POLICY_SCHEMA_VERSION}"\``
51747
52686
  };
51748
52687
  return {
51749
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51750
- structuredContent: structured
52688
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52689
+ structuredContent: structured2
51751
52690
  };
51752
52691
  }
51753
- if (err instanceof PolicyYamlParseError) {
51754
- const structured = {
51755
- policyPath,
51756
- schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
51757
- validationScope: "schema+offline-semantics",
51758
- limitations: [
51759
- "Does not resolve aliases against the live device inventory.",
51760
- "Does not verify commands against the real target device, live capabilities, or current firmware."
51761
- ],
51762
- present: true,
51763
- valid: false,
51764
- errors: err.yamlErrors.map((e) => ({
51765
- path: "",
51766
- line: e.line,
51767
- col: e.col,
51768
- keyword: "yaml-parse",
51769
- message: e.message,
51770
- schemaPath: ""
51771
- }))
52692
+ if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
52693
+ const isLegacy = fileVersion === "0.1";
52694
+ const structured2 = {
52695
+ ...base,
52696
+ status: "unsupported",
52697
+ message: isLegacy ? `policy schema v${fileVersion} is legacy and cannot be migrated by this CLI` : `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(", ")})`
51772
52698
  };
51773
52699
  return {
51774
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51775
- structuredContent: structured
52700
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52701
+ structuredContent: structured2
51776
52702
  };
51777
52703
  }
51778
- throw err;
51779
- }
51780
- }
51781
- );
51782
- server.registerTool(
51783
- "policy_new",
51784
- {
51785
- title: "Scaffold a starter policy.yaml",
51786
- description: "Write a starter policy file to the resolved default path (or a given path). Refuses to overwrite unless force=true. This is a write action: the agent should only call it after confirming with the user.",
51787
- _meta: { agentSafetyTier: "action" },
51788
- inputSchema: external_exports.object({
51789
- path: external_exports.string().optional().describe("Optional target path; defaults to the resolved default"),
51790
- force: external_exports.boolean().optional().describe("When true, overwrite an existing file")
51791
- }).strict(),
51792
- outputSchema: {
51793
- policyPath: external_exports.string(),
51794
- schemaVersion: external_exports.string(),
51795
- bytesWritten: external_exports.number(),
51796
- overwritten: external_exports.boolean()
51797
- }
51798
- },
51799
- async ({ path: pathArg, force }) => {
51800
- const policyPath = resolvePolicyPath({ flag: pathArg });
51801
- const doForce = force === true;
51802
- if (fs18.existsSync(policyPath) && !doForce) {
51803
- return mcpError("guard", 5, `refusing to overwrite existing policy at ${policyPath}`, {
51804
- hint: "pass force=true to overwrite, or choose a different path",
51805
- context: { policyPath }
51806
- });
51807
- }
51808
- const template = readPolicyExampleYaml();
51809
- fs18.mkdirSync(pathDirname(policyPath), { recursive: true });
51810
- fs18.writeFileSync(policyPath, template, { encoding: "utf-8" });
51811
- const structured = {
51812
- policyPath,
51813
- schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
51814
- bytesWritten: Buffer.byteLength(template, "utf-8"),
51815
- overwritten: doForce
51816
- };
51817
- return {
51818
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51819
- structuredContent: structured
51820
- };
51821
- }
51822
- );
51823
- server.registerTool(
51824
- "policy_migrate",
51825
- {
51826
- title: "Migrate a policy file to the latest supported schema",
51827
- description: 'Rewrites a policy file between schema versions this CLI still supports while preserving comments. Safe by default: if the migrated document would fail schema validation, the file is NOT rewritten and the tool returns status="precheck-failed" with the list of errors. Pass dryRun=true to preview without touching the file. This release only supports v0.2, so legacy v0.1 files are reported as unsupported rather than migrated.',
51828
- _meta: { agentSafetyTier: "action" },
51829
- inputSchema: external_exports.object({
51830
- path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
51831
- dryRun: external_exports.boolean().optional().describe("When true, report what would change without writing"),
51832
- to: external_exports.string().optional().describe(`Target schema version (default: latest supported, "${LATEST_SUPPORTED_VERSION}")`)
51833
- }).strict(),
51834
- outputSchema: {
51835
- policyPath: external_exports.string(),
51836
- fileVersion: external_exports.string().optional(),
51837
- targetVersion: external_exports.string(),
51838
- supportedVersions: external_exports.array(external_exports.string()),
51839
- status: external_exports.enum([
51840
- "already-current",
51841
- "migrated",
51842
- "dry-run",
51843
- "no-version-field",
51844
- "unsupported",
51845
- "precheck-failed",
51846
- "file-not-found"
51847
- ]),
51848
- from: external_exports.string().optional(),
51849
- to: external_exports.string().optional(),
51850
- bytesWritten: external_exports.number().optional(),
51851
- message: external_exports.string(),
51852
- errors: external_exports.array(external_exports.object({ path: external_exports.string(), keyword: external_exports.string(), message: external_exports.string() })).optional()
51853
- }
51854
- },
51855
- async ({ path: pathArg, dryRun, to }) => {
51856
- const policyPath = resolvePolicyPath({ flag: pathArg });
51857
- const target = to ?? LATEST_SUPPORTED_VERSION;
51858
- let loaded;
51859
- try {
51860
- loaded = loadPolicyFile(policyPath);
51861
- } catch (err) {
51862
- if (err instanceof PolicyFileNotFoundError) {
52704
+ if (fileVersion === target) {
51863
52705
  const structured2 = {
51864
- policyPath,
51865
- targetVersion: target,
51866
- supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
51867
- status: "file-not-found",
51868
- message: `policy file not found: ${policyPath}`
52706
+ ...base,
52707
+ status: "already-current",
52708
+ message: `already on schema v${target}; no migration needed`,
52709
+ bytesWritten: 0
51869
52710
  };
51870
52711
  return {
51871
52712
  content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
51872
52713
  structuredContent: structured2
51873
52714
  };
51874
52715
  }
51875
- throw err;
51876
- }
51877
- const data = loaded.data;
51878
- const fileVersion = typeof data?.version === "string" ? data.version : void 0;
51879
- const base = {
51880
- policyPath,
51881
- fileVersion,
51882
- targetVersion: target,
51883
- supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS]
51884
- };
51885
- if (!fileVersion) {
51886
- const structured2 = {
51887
- ...base,
51888
- status: "no-version-field",
51889
- message: `policy has no \`version\` field \u2014 add \`version: "${CURRENT_POLICY_SCHEMA_VERSION}"\``
51890
- };
51891
- return {
51892
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
51893
- structuredContent: structured2
51894
- };
51895
- }
51896
- if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
51897
- const isLegacy = fileVersion === "0.1";
51898
- const structured2 = {
51899
- ...base,
51900
- status: "unsupported",
51901
- message: isLegacy ? `policy schema v${fileVersion} is legacy and cannot be migrated by this CLI` : `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(", ")})`
51902
- };
51903
- return {
51904
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
51905
- structuredContent: structured2
51906
- };
51907
- }
51908
- if (fileVersion === target) {
51909
- const structured2 = {
51910
- ...base,
51911
- status: "already-current",
51912
- message: `already on schema v${target}; no migration needed`,
51913
- bytesWritten: 0
51914
- };
51915
- return {
51916
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
51917
- structuredContent: structured2
51918
- };
51919
- }
51920
- const plan = planMigration(loaded, fileVersion, target);
51921
- if (!plan.precheck.valid) {
51922
- const structured2 = {
51923
- ...base,
51924
- status: "precheck-failed",
51925
- message: `migrated policy fails schema v${target} precheck; file not written`,
51926
- errors: plan.precheck.errors.map((e) => ({ path: e.path, keyword: e.keyword, message: e.message }))
51927
- };
51928
- return {
51929
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
51930
- structuredContent: structured2
51931
- };
51932
- }
51933
- const bytes = Buffer.byteLength(plan.nextSource, "utf-8");
51934
- if (dryRun) {
51935
- const structured2 = {
52716
+ const plan = planMigration(loaded, fileVersion, target);
52717
+ if (!plan.precheck.valid) {
52718
+ const structured2 = {
52719
+ ...base,
52720
+ status: "precheck-failed",
52721
+ message: `migrated policy fails schema v${target} precheck; file not written`,
52722
+ errors: plan.precheck.errors.map((e) => ({ path: e.path, keyword: e.keyword, message: e.message }))
52723
+ };
52724
+ return {
52725
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52726
+ structuredContent: structured2
52727
+ };
52728
+ }
52729
+ const bytes = Buffer.byteLength(plan.nextSource, "utf-8");
52730
+ if (dryRun) {
52731
+ const structured2 = {
52732
+ ...base,
52733
+ status: "dry-run",
52734
+ from: plan.fromVersion,
52735
+ to: plan.toVersion,
52736
+ bytesWritten: 0,
52737
+ message: `dry-run: would upgrade v${plan.fromVersion} \u2192 v${plan.toVersion} (${bytes} bytes)`
52738
+ };
52739
+ return {
52740
+ content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
52741
+ structuredContent: structured2
52742
+ };
52743
+ }
52744
+ writeFileSync(policyPath, plan.nextSource, { encoding: "utf-8" });
52745
+ const structured = {
51936
52746
  ...base,
51937
- status: "dry-run",
52747
+ status: "migrated",
51938
52748
  from: plan.fromVersion,
51939
52749
  to: plan.toVersion,
51940
- bytesWritten: 0,
51941
- message: `dry-run: would upgrade v${plan.fromVersion} \u2192 v${plan.toVersion} (${bytes} bytes)`
52750
+ bytesWritten: bytes,
52751
+ message: `migrated ${policyPath} to schema v${plan.toVersion} (from v${plan.fromVersion})`
51942
52752
  };
51943
52753
  return {
51944
- content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
51945
- structuredContent: structured2
52754
+ content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
52755
+ structuredContent: structured
51946
52756
  };
51947
52757
  }
51948
- writeFileSync(policyPath, plan.nextSource, { encoding: "utf-8" });
51949
- const structured = {
51950
- ...base,
51951
- status: "migrated",
51952
- from: plan.fromVersion,
51953
- to: plan.toVersion,
51954
- bytesWritten: bytes,
51955
- message: `migrated ${policyPath} to schema v${plan.toVersion} (from v${plan.fromVersion})`
51956
- };
51957
- return {
51958
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
51959
- structuredContent: structured
51960
- };
51961
- }
51962
- );
51963
- server.registerTool(
51964
- "policy_diff",
51965
- {
51966
- title: "Compare two policy files",
51967
- description: "Compare two policy YAML files and return the same contract as `switchbot --json policy diff`: { leftPath, rightPath, equal, changeCount, truncated, stats, changes, diff }.",
51968
- _meta: { agentSafetyTier: "read" },
51969
- inputSchema: external_exports.object({
51970
- left_path: external_exports.string().min(1).describe("Path to the baseline policy file."),
51971
- right_path: external_exports.string().min(1).describe("Path to the candidate policy file.")
51972
- }).strict(),
51973
- outputSchema: {
51974
- leftPath: external_exports.string(),
51975
- rightPath: external_exports.string(),
51976
- equal: external_exports.boolean(),
51977
- changeCount: external_exports.number().int(),
51978
- truncated: external_exports.boolean(),
51979
- stats: external_exports.object({
51980
- added: external_exports.number().int(),
51981
- removed: external_exports.number().int(),
51982
- changed: external_exports.number().int()
51983
- }),
51984
- changes: external_exports.array(external_exports.object({
51985
- path: external_exports.string(),
51986
- kind: external_exports.enum(["added", "removed", "changed"]),
51987
- before: external_exports.unknown().optional(),
51988
- after: external_exports.unknown().optional()
51989
- })),
51990
- diff: external_exports.string()
51991
- }
51992
- },
51993
- ({ left_path, right_path }) => {
51994
- let leftSource = "";
51995
- let rightSource = "";
51996
- try {
51997
- leftSource = fs18.readFileSync(left_path, "utf-8");
51998
- } catch (err) {
51999
- if (err?.code === "ENOENT") {
52000
- return mcpError("usage", 2, `policy file not found: ${left_path}`, {
52001
- context: { policyPath: left_path }
52002
- });
52758
+ );
52759
+ if (!skip("policy_diff"))
52760
+ server.registerTool(
52761
+ "policy_diff",
52762
+ {
52763
+ title: "Compare two policy files",
52764
+ description: "Compare two policy YAML files and return the same contract as `switchbot --json policy diff`: { leftPath, rightPath, equal, changeCount, truncated, stats, changes, diff }.",
52765
+ _meta: { agentSafetyTier: "read" },
52766
+ inputSchema: external_exports.object({
52767
+ left_path: external_exports.string().min(1).describe("Path to the baseline policy file."),
52768
+ right_path: external_exports.string().min(1).describe("Path to the candidate policy file.")
52769
+ }).strict(),
52770
+ outputSchema: {
52771
+ leftPath: external_exports.string(),
52772
+ rightPath: external_exports.string(),
52773
+ equal: external_exports.boolean(),
52774
+ changeCount: external_exports.number().int(),
52775
+ truncated: external_exports.boolean(),
52776
+ stats: external_exports.object({
52777
+ added: external_exports.number().int(),
52778
+ removed: external_exports.number().int(),
52779
+ changed: external_exports.number().int()
52780
+ }),
52781
+ changes: external_exports.array(external_exports.object({
52782
+ path: external_exports.string(),
52783
+ kind: external_exports.enum(["added", "removed", "changed"]),
52784
+ before: external_exports.unknown().optional(),
52785
+ after: external_exports.unknown().optional()
52786
+ })),
52787
+ diff: external_exports.string()
52003
52788
  }
52004
- return mcpError("runtime", 1, `failed to read ${left_path}: ${String(err)}`);
52005
- }
52006
- try {
52007
- rightSource = fs18.readFileSync(right_path, "utf-8");
52008
- } catch (err) {
52009
- if (err?.code === "ENOENT") {
52010
- return mcpError("usage", 2, `policy file not found: ${right_path}`, {
52011
- context: { policyPath: right_path }
52012
- });
52789
+ },
52790
+ ({ left_path, right_path }) => {
52791
+ let leftSource = "";
52792
+ let rightSource = "";
52793
+ try {
52794
+ leftSource = fs18.readFileSync(left_path, "utf-8");
52795
+ } catch (err) {
52796
+ if (err?.code === "ENOENT") {
52797
+ return mcpError("usage", 2, `policy file not found: ${left_path}`, {
52798
+ context: { policyPath: left_path }
52799
+ });
52800
+ }
52801
+ return mcpError("runtime", 1, `failed to read ${left_path}: ${String(err)}`);
52013
52802
  }
52014
- return mcpError("runtime", 1, `failed to read ${right_path}: ${String(err)}`);
52015
- }
52016
- let leftDoc;
52017
- let rightDoc;
52018
- try {
52019
- leftDoc = (0, import_yaml7.parse)(leftSource);
52020
- } catch (err) {
52021
- return mcpError("usage", 2, `YAML parse error in ${left_path}: ${err.message}`);
52022
- }
52023
- try {
52024
- rightDoc = (0, import_yaml7.parse)(rightSource);
52025
- } catch (err) {
52026
- return mcpError("usage", 2, `YAML parse error in ${right_path}: ${err.message}`);
52803
+ try {
52804
+ rightSource = fs18.readFileSync(right_path, "utf-8");
52805
+ } catch (err) {
52806
+ if (err?.code === "ENOENT") {
52807
+ return mcpError("usage", 2, `policy file not found: ${right_path}`, {
52808
+ context: { policyPath: right_path }
52809
+ });
52810
+ }
52811
+ return mcpError("runtime", 1, `failed to read ${right_path}: ${String(err)}`);
52812
+ }
52813
+ let leftDoc;
52814
+ let rightDoc;
52815
+ try {
52816
+ leftDoc = (0, import_yaml7.parse)(leftSource);
52817
+ } catch (err) {
52818
+ return mcpError("usage", 2, `YAML parse error in ${left_path}: ${err.message}`);
52819
+ }
52820
+ try {
52821
+ rightDoc = (0, import_yaml7.parse)(rightSource);
52822
+ } catch (err) {
52823
+ return mcpError("usage", 2, `YAML parse error in ${right_path}: ${err.message}`);
52824
+ }
52825
+ const result = {
52826
+ leftPath: left_path,
52827
+ rightPath: right_path,
52828
+ ...diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource)
52829
+ };
52830
+ return {
52831
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
52832
+ structuredContent: result
52833
+ };
52027
52834
  }
52028
- const result = {
52029
- leftPath: left_path,
52030
- rightPath: right_path,
52031
- ...diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource)
52032
- };
52033
- return {
52034
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
52035
- structuredContent: result
52036
- };
52037
- }
52038
- );
52835
+ );
52039
52836
  if (eventManager) {
52040
52837
  server.registerResource(
52041
52838
  "events",
@@ -52058,559 +52855,568 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
52058
52855
  }
52059
52856
  );
52060
52857
  }
52061
- server.registerTool(
52062
- "plan_suggest",
52063
- {
52064
- title: "Draft a SwitchBot execution plan from intent",
52065
- description: "Generate a candidate Plan JSON from a natural language intent and a list of device IDs. Uses keyword heuristics (no LLM) to pick the command. The returned plan is ready to pass to `plan run` \u2014 review and edit before executing. Recognised commands: turnOn, turnOff, press, lock, unlock, open, close, pause. Falls back to turnOn with a warning when intent is unclear.",
52066
- _meta: { agentSafetyTier: "read" },
52067
- inputSchema: external_exports.object({
52068
- intent: external_exports.string().min(1).describe('Natural language description of what to do (e.g. "turn off all lights").'),
52069
- device_ids: external_exports.array(external_exports.string().min(1)).min(1).describe("Device IDs to act on.")
52070
- }).strict(),
52071
- outputSchema: {
52072
- plan: external_exports.unknown().describe("Candidate Plan JSON (version 1.0) ready to pass to plan run."),
52073
- warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted to turnOn).")
52074
- }
52075
- },
52076
- ({ intent, device_ids }) => {
52077
- const devices = device_ids.map((id) => {
52078
- const cached2 = getCachedDevice(id);
52079
- return { id, name: cached2?.name, type: cached2?.type };
52080
- });
52081
- try {
52082
- const { plan, warnings } = suggestPlan({ intent, devices });
52083
- return {
52084
- content: [{ type: "text", text: JSON.stringify({ plan, warnings }, null, 2) }],
52085
- structuredContent: { plan, warnings }
52086
- };
52087
- } catch (err) {
52088
- return apiErrorToMcpError(err);
52089
- }
52090
- }
52091
- );
52092
- server.registerTool(
52093
- "plan_run",
52094
- {
52095
- title: "Validate and execute a SwitchBot plan",
52096
- 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. Scene and wait steps run in order. Returns per-step results and a summary.",
52097
- _meta: { agentSafetyTier: "action" },
52098
- inputSchema: external_exports.object({
52099
- plan: external_exports.unknown().describe("Plan JSON object (same schema as `switchbot plan run`)."),
52100
- yes: external_exports.boolean().optional().describe("Authorize destructive command steps."),
52101
- continue_on_error: external_exports.boolean().optional().describe("Keep executing later steps after a failed step.")
52102
- }).strict(),
52103
- outputSchema: {
52104
- ran: external_exports.boolean(),
52105
- plan: external_exports.unknown(),
52106
- results: external_exports.array(external_exports.unknown()),
52107
- summary: external_exports.object({
52108
- total: external_exports.number().int(),
52109
- ok: external_exports.number().int(),
52110
- error: external_exports.number().int(),
52111
- skipped: external_exports.number().int()
52112
- })
52113
- }
52114
- },
52115
- async ({ plan, yes, continue_on_error }) => {
52116
- const validated = validatePlan(plan);
52117
- if (!validated.ok) {
52118
- return mcpError("usage", 2, "plan invalid", {
52119
- context: { issues: validated.issues },
52120
- hint: "Fix the reported issues and retry plan_run."
52121
- });
52122
- }
52123
- const out = {
52124
- ran: true,
52125
- plan: validated.plan,
52126
- results: [],
52127
- summary: { total: validated.plan.steps.length, ok: 0, error: 0, skipped: 0 }
52128
- };
52129
- const continueOnError = continue_on_error === true;
52130
- const allowDestructive = yes === true;
52131
- const destructiveSteps = validated.plan.steps.map((step, index) => ({ step, index })).filter((entry) => entry.step.type === "command").map(({ step, index }) => {
52132
- const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
52133
- const commandType = step.commandType ?? "command";
52134
- const deviceType = getCachedDevice(resolvedDeviceId)?.type;
52135
- return {
52136
- index: index + 1,
52137
- deviceId: resolvedDeviceId,
52138
- command: step.command,
52139
- commandType,
52140
- deviceType: deviceType ?? null,
52141
- destructive: isDestructiveCommand(deviceType, step.command, commandType)
52142
- };
52143
- }).filter((step) => step.destructive);
52144
- if (allowDestructive && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
52145
- return mcpError("guard", 3, "Direct destructive execution is disabled for plan_run.", {
52146
- hint: destructiveExecutionHint(),
52147
- context: {
52148
- destructiveSteps: destructiveSteps.map((step) => ({
52149
- step: step.index,
52150
- deviceId: step.deviceId,
52151
- deviceType: step.deviceType,
52152
- command: step.command,
52153
- commandType: step.commandType
52154
- })),
52155
- requiredWorkflow: "plan-approval"
52156
- }
52858
+ if (!skip("plan_suggest"))
52859
+ server.registerTool(
52860
+ "plan_suggest",
52861
+ {
52862
+ title: "Draft a SwitchBot execution plan from intent",
52863
+ description: "Generate a candidate Plan JSON from a natural language intent and a list of device IDs. Uses keyword heuristics (no LLM) to pick the command. The returned plan is ready to pass to `plan run` \u2014 review and edit before executing. Recognised commands: turnOn, turnOff, press, lock, unlock, open, close, pause. Falls back to turnOn with a warning when intent is unclear.",
52864
+ _meta: { agentSafetyTier: "read" },
52865
+ inputSchema: external_exports.object({
52866
+ intent: external_exports.string().min(1).describe('Natural language description of what to do (e.g. "turn off all lights").'),
52867
+ device_ids: external_exports.array(external_exports.string().min(1)).min(1).describe("Device IDs to act on.")
52868
+ }).strict(),
52869
+ outputSchema: {
52870
+ plan: external_exports.unknown().describe("Candidate Plan JSON (version 1.0) ready to pass to plan run."),
52871
+ warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted to turnOn).")
52872
+ }
52873
+ },
52874
+ ({ intent, device_ids }) => {
52875
+ const devices = device_ids.map((id) => {
52876
+ const cached2 = getCachedDevice(id);
52877
+ return { id, name: cached2?.name, type: cached2?.type };
52157
52878
  });
52879
+ try {
52880
+ const { plan, warnings } = suggestPlan({ intent, devices });
52881
+ return {
52882
+ content: [{ type: "text", text: JSON.stringify({ plan, warnings }, null, 2) }],
52883
+ structuredContent: { plan, warnings }
52884
+ };
52885
+ } catch (err) {
52886
+ return apiErrorToMcpError(err);
52887
+ }
52158
52888
  }
52159
- for (let i = 0; i < validated.plan.steps.length; i++) {
52160
- const step = validated.plan.steps[i];
52161
- const idx = i + 1;
52162
- if (step.type === "wait") {
52163
- await new Promise((resolve2) => setTimeout(resolve2, step.ms));
52164
- out.results.push({ step: idx, type: "wait", ms: step.ms, status: "ok" });
52165
- out.summary.ok++;
52166
- continue;
52889
+ );
52890
+ if (!skip("plan_run"))
52891
+ server.registerTool(
52892
+ "plan_run",
52893
+ {
52894
+ title: "Validate and execute a SwitchBot plan",
52895
+ 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. Scene and wait steps run in order. Returns per-step results and a summary.",
52896
+ _meta: { agentSafetyTier: "action" },
52897
+ inputSchema: external_exports.object({
52898
+ plan: external_exports.unknown().describe("Plan JSON object (same schema as `switchbot plan run`)."),
52899
+ yes: external_exports.boolean().optional().describe("Authorize destructive command steps."),
52900
+ continue_on_error: external_exports.boolean().optional().describe("Keep executing later steps after a failed step.")
52901
+ }).strict(),
52902
+ outputSchema: {
52903
+ ran: external_exports.boolean(),
52904
+ plan: external_exports.unknown(),
52905
+ results: external_exports.array(external_exports.unknown()),
52906
+ summary: external_exports.object({
52907
+ total: external_exports.number().int(),
52908
+ ok: external_exports.number().int(),
52909
+ error: external_exports.number().int(),
52910
+ skipped: external_exports.number().int()
52911
+ })
52167
52912
  }
52168
- if (step.type === "scene") {
52169
- try {
52170
- await executeScene(step.sceneId);
52171
- out.results.push({ step: idx, type: "scene", sceneId: step.sceneId, status: "ok" });
52172
- out.summary.ok++;
52173
- } catch (err) {
52174
- const msg = err instanceof Error ? err.message : String(err);
52175
- out.results.push({ step: idx, type: "scene", sceneId: step.sceneId, status: "error", error: msg });
52176
- out.summary.error++;
52177
- if (!continueOnError) break;
52178
- }
52179
- continue;
52913
+ },
52914
+ async ({ plan, yes, continue_on_error }) => {
52915
+ const validated = validatePlan(plan);
52916
+ if (!validated.ok) {
52917
+ return mcpError("usage", 2, "plan invalid", {
52918
+ context: { issues: validated.issues },
52919
+ hint: "Fix the reported issues and retry plan_run."
52920
+ });
52180
52921
  }
52181
- let resolvedDeviceId = "";
52182
- try {
52183
- resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
52922
+ const out = {
52923
+ ran: true,
52924
+ plan: validated.plan,
52925
+ results: [],
52926
+ summary: { total: validated.plan.steps.length, ok: 0, error: 0, skipped: 0 }
52927
+ };
52928
+ const continueOnError = continue_on_error === true;
52929
+ const allowDestructive = yes === true;
52930
+ const destructiveSteps = validated.plan.steps.map((step, index) => ({ step, index })).filter((entry) => entry.step.type === "command").map(({ step, index }) => {
52931
+ const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
52184
52932
  const commandType = step.commandType ?? "command";
52185
52933
  const deviceType = getCachedDevice(resolvedDeviceId)?.type;
52186
- const destructive = isDestructiveCommand(deviceType, step.command, commandType);
52187
- if (destructive && !allowDestructive) {
52934
+ return {
52935
+ index: index + 1,
52936
+ deviceId: resolvedDeviceId,
52937
+ command: step.command,
52938
+ commandType,
52939
+ deviceType: deviceType ?? null,
52940
+ destructive: isDestructiveCommand(deviceType, step.command, commandType)
52941
+ };
52942
+ }).filter((step) => step.destructive);
52943
+ if (allowDestructive && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
52944
+ return mcpError("guard", 3, "Direct destructive execution is disabled for plan_run.", {
52945
+ hint: destructiveExecutionHint(),
52946
+ context: {
52947
+ destructiveSteps: destructiveSteps.map((step) => ({
52948
+ step: step.index,
52949
+ deviceId: step.deviceId,
52950
+ deviceType: step.deviceType,
52951
+ command: step.command,
52952
+ commandType: step.commandType
52953
+ })),
52954
+ requiredWorkflow: "plan-approval"
52955
+ }
52956
+ });
52957
+ }
52958
+ for (let i = 0; i < validated.plan.steps.length; i++) {
52959
+ const step = validated.plan.steps[i];
52960
+ const idx = i + 1;
52961
+ if (step.type === "wait") {
52962
+ await new Promise((resolve2) => setTimeout(resolve2, step.ms));
52963
+ out.results.push({ step: idx, type: "wait", ms: step.ms, status: "ok" });
52964
+ out.summary.ok++;
52965
+ continue;
52966
+ }
52967
+ if (step.type === "scene") {
52968
+ try {
52969
+ await executeScene(step.sceneId);
52970
+ out.results.push({ step: idx, type: "scene", sceneId: step.sceneId, status: "ok" });
52971
+ out.summary.ok++;
52972
+ } catch (err) {
52973
+ const msg = err instanceof Error ? err.message : String(err);
52974
+ out.results.push({ step: idx, type: "scene", sceneId: step.sceneId, status: "error", error: msg });
52975
+ out.summary.error++;
52976
+ if (!continueOnError) break;
52977
+ }
52978
+ continue;
52979
+ }
52980
+ let resolvedDeviceId = "";
52981
+ try {
52982
+ resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
52983
+ const commandType = step.commandType ?? "command";
52984
+ const deviceType = getCachedDevice(resolvedDeviceId)?.type;
52985
+ const destructive = isDestructiveCommand(deviceType, step.command, commandType);
52986
+ if (destructive && !allowDestructive) {
52987
+ out.results.push({
52988
+ step: idx,
52989
+ type: "command",
52990
+ deviceId: resolvedDeviceId,
52991
+ command: step.command,
52992
+ status: "skipped",
52993
+ error: "destructive \u2014 rerun with yes=true"
52994
+ });
52995
+ out.summary.skipped++;
52996
+ if (!continueOnError) break;
52997
+ continue;
52998
+ }
52999
+ await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
52188
53000
  out.results.push({
52189
53001
  step: idx,
52190
53002
  type: "command",
52191
53003
  deviceId: resolvedDeviceId,
52192
53004
  command: step.command,
52193
- status: "skipped",
52194
- error: "destructive \u2014 rerun with yes=true"
53005
+ status: "ok"
52195
53006
  });
52196
- out.summary.skipped++;
52197
- if (!continueOnError) break;
52198
- continue;
52199
- }
52200
- await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
52201
- out.results.push({
52202
- step: idx,
52203
- type: "command",
52204
- deviceId: resolvedDeviceId,
52205
- command: step.command,
52206
- status: "ok"
52207
- });
52208
- out.summary.ok++;
52209
- } catch (err) {
52210
- if (err instanceof Error && err.name === "DryRunSignal") {
53007
+ out.summary.ok++;
53008
+ } catch (err) {
53009
+ if (err instanceof Error && err.name === "DryRunSignal") {
53010
+ out.results.push({
53011
+ step: idx,
53012
+ type: "command",
53013
+ deviceId: resolvedDeviceId || step.deviceId || "unknown",
53014
+ command: step.command,
53015
+ status: "ok"
53016
+ });
53017
+ out.summary.ok++;
53018
+ continue;
53019
+ }
53020
+ const msg = err instanceof Error ? err.message : String(err);
52211
53021
  out.results.push({
52212
53022
  step: idx,
52213
53023
  type: "command",
52214
53024
  deviceId: resolvedDeviceId || step.deviceId || "unknown",
52215
53025
  command: step.command,
52216
- status: "ok"
53026
+ status: "error",
53027
+ error: msg
52217
53028
  });
52218
- out.summary.ok++;
52219
- continue;
53029
+ out.summary.error++;
53030
+ if (!continueOnError) break;
52220
53031
  }
52221
- const msg = err instanceof Error ? err.message : String(err);
52222
- out.results.push({
52223
- step: idx,
52224
- type: "command",
52225
- deviceId: resolvedDeviceId || step.deviceId || "unknown",
52226
- command: step.command,
52227
- status: "error",
52228
- error: msg
52229
- });
52230
- out.summary.error++;
52231
- if (!continueOnError) break;
52232
53032
  }
52233
- }
52234
- return {
52235
- content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
52236
- structuredContent: out
52237
- };
52238
- }
52239
- );
52240
- server.registerTool(
52241
- "audit_query",
52242
- {
52243
- title: "Query command/rule audit log entries",
52244
- description: "Filter entries from the local audit log (default ~/.switchbot/audit.log) by time range, kind, device, rule, and result. Useful for review flows and rule-fire inspection without leaving MCP.",
52245
- _meta: { agentSafetyTier: "read" },
52246
- inputSchema: external_exports.object({
52247
- file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
52248
- since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h"). Mutually exclusive with from/to.'),
52249
- from: external_exports.string().optional().describe("Range start (ISO-8601)."),
52250
- to: external_exports.string().optional().describe("Range end (ISO-8601)."),
52251
- kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
52252
- device_id: external_exports.string().optional().describe("Filter by deviceId."),
52253
- rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
52254
- results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
52255
- limit: external_exports.number().int().min(1).max(5e3).optional().describe("Max entries returned from the tail of the filtered set (default 200).")
52256
- }).strict(),
52257
- outputSchema: {
52258
- file: external_exports.string(),
52259
- totalMatched: external_exports.number().int(),
52260
- returned: external_exports.number().int(),
52261
- entries: external_exports.array(external_exports.unknown())
52262
- }
52263
- },
52264
- ({ file: file2, since, from, to, kinds, device_id, rule_name, results, limit }) => {
52265
- const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
52266
- const entries = readAudit(filePath);
52267
- try {
52268
- const filtered = filterAuditEntries(entries, {
52269
- since,
52270
- from,
52271
- to,
52272
- kinds,
52273
- deviceId: device_id,
52274
- ruleName: rule_name,
52275
- results
52276
- });
52277
- const bounded = filtered.slice(-Math.max(1, limit ?? 200));
52278
- const out = {
52279
- file: filePath,
52280
- totalMatched: filtered.length,
52281
- returned: bounded.length,
52282
- entries: bounded
52283
- };
52284
53033
  return {
52285
53034
  content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
52286
53035
  structuredContent: out
52287
53036
  };
52288
- } catch (err) {
52289
- return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit query options");
52290
53037
  }
52291
- }
52292
- );
52293
- server.registerTool(
52294
- "audit_stats",
52295
- {
52296
- title: "Aggregate audit log counts for review dashboards",
52297
- description: "Compute summary counters over the local audit log: by kind, by result, top devices, and top rules. Supports the same filters as audit_query.",
52298
- _meta: { agentSafetyTier: "read" },
52299
- inputSchema: external_exports.object({
52300
- file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
52301
- since: external_exports.string().optional().describe('Relative window ending now (e.g. "6h"). Mutually exclusive with from/to.'),
52302
- from: external_exports.string().optional().describe("Range start (ISO-8601)."),
52303
- to: external_exports.string().optional().describe("Range end (ISO-8601)."),
52304
- kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
52305
- device_id: external_exports.string().optional().describe("Filter by deviceId."),
52306
- rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
52307
- results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
52308
- top_n: external_exports.number().int().min(1).max(100).optional().describe("Number of top device/rule rows to return (default 10).")
52309
- }).strict(),
52310
- outputSchema: {
52311
- file: external_exports.string(),
52312
- totalMatched: external_exports.number().int(),
52313
- byKind: external_exports.record(external_exports.string(), external_exports.number().int()),
52314
- byResult: external_exports.record(external_exports.string(), external_exports.number().int()),
52315
- topDevices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), count: external_exports.number().int() })),
52316
- topRules: external_exports.array(external_exports.object({ ruleName: external_exports.string(), count: external_exports.number().int() }))
53038
+ );
53039
+ if (!skip("audit_query"))
53040
+ server.registerTool(
53041
+ "audit_query",
53042
+ {
53043
+ title: "Query command/rule audit log entries",
53044
+ description: "Filter entries from the local audit log (default ~/.switchbot/audit.log) by time range, kind, device, rule, and result. Useful for review flows and rule-fire inspection without leaving MCP.",
53045
+ _meta: { agentSafetyTier: "read" },
53046
+ inputSchema: external_exports.object({
53047
+ file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
53048
+ since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h"). Mutually exclusive with from/to.'),
53049
+ from: external_exports.string().optional().describe("Range start (ISO-8601)."),
53050
+ to: external_exports.string().optional().describe("Range end (ISO-8601)."),
53051
+ kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
53052
+ device_id: external_exports.string().optional().describe("Filter by deviceId."),
53053
+ rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
53054
+ results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
53055
+ limit: external_exports.number().int().min(1).max(5e3).optional().describe("Max entries returned from the tail of the filtered set (default 200).")
53056
+ }).strict(),
53057
+ outputSchema: {
53058
+ file: external_exports.string(),
53059
+ totalMatched: external_exports.number().int(),
53060
+ returned: external_exports.number().int(),
53061
+ entries: external_exports.array(external_exports.unknown())
53062
+ }
53063
+ },
53064
+ ({ file: file2, since, from, to, kinds, device_id, rule_name, results, limit }) => {
53065
+ const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
53066
+ const entries = readAudit(filePath);
53067
+ try {
53068
+ const filtered = filterAuditEntries(entries, {
53069
+ since,
53070
+ from,
53071
+ to,
53072
+ kinds,
53073
+ deviceId: device_id,
53074
+ ruleName: rule_name,
53075
+ results
53076
+ });
53077
+ const bounded = filtered.slice(-Math.max(1, limit ?? 200));
53078
+ const out = {
53079
+ file: filePath,
53080
+ totalMatched: filtered.length,
53081
+ returned: bounded.length,
53082
+ entries: bounded
53083
+ };
53084
+ return {
53085
+ content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
53086
+ structuredContent: out
53087
+ };
53088
+ } catch (err) {
53089
+ return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit query options");
53090
+ }
52317
53091
  }
52318
- },
52319
- ({ file: file2, since, from, to, kinds, device_id, rule_name, results, top_n }) => {
52320
- const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
52321
- const entries = readAudit(filePath);
52322
- try {
52323
- const filtered = filterAuditEntries(entries, {
52324
- since,
52325
- from,
52326
- to,
52327
- kinds,
52328
- deviceId: device_id,
52329
- ruleName: rule_name,
52330
- results
52331
- });
52332
- const byKind = /* @__PURE__ */ new Map();
52333
- const byResult = /* @__PURE__ */ new Map();
52334
- const byDevice = /* @__PURE__ */ new Map();
52335
- const byRule = /* @__PURE__ */ new Map();
52336
- for (const entry of filtered) {
52337
- byKind.set(entry.kind, (byKind.get(entry.kind) ?? 0) + 1);
52338
- if (entry.result) byResult.set(entry.result, (byResult.get(entry.result) ?? 0) + 1);
52339
- if (entry.deviceId) byDevice.set(entry.deviceId, (byDevice.get(entry.deviceId) ?? 0) + 1);
52340
- if (entry.rule?.name) byRule.set(entry.rule.name, (byRule.get(entry.rule.name) ?? 0) + 1);
52341
- }
52342
- const topN = top_n ?? 10;
52343
- const out = {
52344
- file: filePath,
52345
- totalMatched: filtered.length,
52346
- byKind: Object.fromEntries([...byKind.entries()].sort((a, b2) => a[0].localeCompare(b2[0]))),
52347
- byResult: Object.fromEntries([...byResult.entries()].sort((a, b2) => a[0].localeCompare(b2[0]))),
52348
- topDevices: topNFromMap(byDevice, topN).map((item) => ({ deviceId: item.key, count: item.count })),
52349
- topRules: topNFromMap(byRule, topN).map((item) => ({ ruleName: item.key, count: item.count }))
52350
- };
53092
+ );
53093
+ if (!skip("audit_stats"))
53094
+ server.registerTool(
53095
+ "audit_stats",
53096
+ {
53097
+ title: "Aggregate audit log counts for review dashboards",
53098
+ description: "Compute summary counters over the local audit log: by kind, by result, top devices, and top rules. Supports the same filters as audit_query.",
53099
+ _meta: { agentSafetyTier: "read" },
53100
+ inputSchema: external_exports.object({
53101
+ file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
53102
+ since: external_exports.string().optional().describe('Relative window ending now (e.g. "6h"). Mutually exclusive with from/to.'),
53103
+ from: external_exports.string().optional().describe("Range start (ISO-8601)."),
53104
+ to: external_exports.string().optional().describe("Range end (ISO-8601)."),
53105
+ kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
53106
+ device_id: external_exports.string().optional().describe("Filter by deviceId."),
53107
+ rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
53108
+ results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
53109
+ top_n: external_exports.number().int().min(1).max(100).optional().describe("Number of top device/rule rows to return (default 10).")
53110
+ }).strict(),
53111
+ outputSchema: {
53112
+ file: external_exports.string(),
53113
+ totalMatched: external_exports.number().int(),
53114
+ byKind: external_exports.record(external_exports.string(), external_exports.number().int()),
53115
+ byResult: external_exports.record(external_exports.string(), external_exports.number().int()),
53116
+ topDevices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), count: external_exports.number().int() })),
53117
+ topRules: external_exports.array(external_exports.object({ ruleName: external_exports.string(), count: external_exports.number().int() }))
53118
+ }
53119
+ },
53120
+ ({ file: file2, since, from, to, kinds, device_id, rule_name, results, top_n }) => {
53121
+ const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
53122
+ const entries = readAudit(filePath);
53123
+ try {
53124
+ const filtered = filterAuditEntries(entries, {
53125
+ since,
53126
+ from,
53127
+ to,
53128
+ kinds,
53129
+ deviceId: device_id,
53130
+ ruleName: rule_name,
53131
+ results
53132
+ });
53133
+ const byKind = /* @__PURE__ */ new Map();
53134
+ const byResult = /* @__PURE__ */ new Map();
53135
+ const byDevice = /* @__PURE__ */ new Map();
53136
+ const byRule = /* @__PURE__ */ new Map();
53137
+ for (const entry of filtered) {
53138
+ byKind.set(entry.kind, (byKind.get(entry.kind) ?? 0) + 1);
53139
+ if (entry.result) byResult.set(entry.result, (byResult.get(entry.result) ?? 0) + 1);
53140
+ if (entry.deviceId) byDevice.set(entry.deviceId, (byDevice.get(entry.deviceId) ?? 0) + 1);
53141
+ if (entry.rule?.name) byRule.set(entry.rule.name, (byRule.get(entry.rule.name) ?? 0) + 1);
53142
+ }
53143
+ const topN = top_n ?? 10;
53144
+ const out = {
53145
+ file: filePath,
53146
+ totalMatched: filtered.length,
53147
+ byKind: Object.fromEntries([...byKind.entries()].sort((a, b2) => a[0].localeCompare(b2[0]))),
53148
+ byResult: Object.fromEntries([...byResult.entries()].sort((a, b2) => a[0].localeCompare(b2[0]))),
53149
+ topDevices: topNFromMap(byDevice, topN).map((item) => ({ deviceId: item.key, count: item.count })),
53150
+ topRules: topNFromMap(byRule, topN).map((item) => ({ ruleName: item.key, count: item.count }))
53151
+ };
53152
+ return {
53153
+ content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
53154
+ structuredContent: out
53155
+ };
53156
+ } catch (err) {
53157
+ return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit stats options");
53158
+ }
53159
+ }
53160
+ );
53161
+ if (!skip("rule_notifications"))
53162
+ server.registerTool(
53163
+ "rule_notifications",
53164
+ {
53165
+ title: "Query rule notification delivery history",
53166
+ description: "Returns audit entries for notify actions (kind: rule-notify). Useful for confirming whether a notification rule fired and whether delivery succeeded. Filter by rule name, time range, result, or channel.",
53167
+ _meta: { agentSafetyTier: "read" },
53168
+ inputSchema: external_exports.object({
53169
+ file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
53170
+ rule: external_exports.string().optional().describe("Filter by rule name (exact match)."),
53171
+ since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h").'),
53172
+ from: external_exports.string().optional().describe("Range start (ISO-8601)."),
53173
+ to: external_exports.string().optional().describe("Range end (ISO-8601)."),
53174
+ result: external_exports.enum(["ok", "error"]).optional().describe("Filter by delivery result."),
53175
+ channel: external_exports.enum(["webhook", "openclaw", "file"]).optional().describe("Filter by notify channel."),
53176
+ limit: external_exports.number().int().min(1).max(500).default(100).describe("Max entries to return (newest first).")
53177
+ }).strict(),
53178
+ outputSchema: {
53179
+ entries: external_exports.array(external_exports.unknown()).describe("Matching audit entries, newest first."),
53180
+ total: external_exports.number().int().describe("Count after filtering.")
53181
+ }
53182
+ },
53183
+ ({ file: file2, rule: ruleName, since, from, to, result: resultFilter, channel, limit }) => {
53184
+ const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
53185
+ let entries = readAudit(filePath).filter((e) => e.kind === "rule-notify");
53186
+ if (ruleName) entries = entries.filter((e) => e.rule?.name === ruleName);
53187
+ if (resultFilter) entries = entries.filter((e) => e.result === resultFilter);
53188
+ if (channel) entries = entries.filter((e) => e.notifyChannel === channel);
53189
+ try {
53190
+ entries = filterAuditEntries(entries, { since, from, to });
53191
+ } catch (err) {
53192
+ return mcpError("usage", 2, err instanceof Error ? err.message : "invalid filter options");
53193
+ }
53194
+ const bounded = entries.slice(-limit).reverse();
53195
+ const out = { entries: bounded, total: entries.length };
52351
53196
  return {
52352
53197
  content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
52353
53198
  structuredContent: out
52354
53199
  };
52355
- } catch (err) {
52356
- return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit stats options");
52357
- }
52358
- }
52359
- );
52360
- server.registerTool(
52361
- "rule_notifications",
52362
- {
52363
- title: "Query rule notification delivery history",
52364
- description: "Returns audit entries for notify actions (kind: rule-notify). Useful for confirming whether a notification rule fired and whether delivery succeeded. Filter by rule name, time range, result, or channel.",
52365
- _meta: { agentSafetyTier: "read" },
52366
- inputSchema: external_exports.object({
52367
- file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
52368
- rule: external_exports.string().optional().describe("Filter by rule name (exact match)."),
52369
- since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h").'),
52370
- from: external_exports.string().optional().describe("Range start (ISO-8601)."),
52371
- to: external_exports.string().optional().describe("Range end (ISO-8601)."),
52372
- result: external_exports.enum(["ok", "error"]).optional().describe("Filter by delivery result."),
52373
- channel: external_exports.enum(["webhook", "openclaw", "file"]).optional().describe("Filter by notify channel."),
52374
- limit: external_exports.number().int().min(1).max(500).default(100).describe("Max entries to return (newest first).")
52375
- }).strict(),
52376
- outputSchema: {
52377
- entries: external_exports.array(external_exports.unknown()).describe("Matching audit entries, newest first."),
52378
- total: external_exports.number().int().describe("Count after filtering.")
52379
53200
  }
52380
- },
52381
- ({ file: file2, rule: ruleName, since, from, to, result: resultFilter, channel, limit }) => {
52382
- const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
52383
- let entries = readAudit(filePath).filter((e) => e.kind === "rule-notify");
52384
- if (ruleName) entries = entries.filter((e) => e.rule?.name === ruleName);
52385
- if (resultFilter) entries = entries.filter((e) => e.result === resultFilter);
52386
- if (channel) entries = entries.filter((e) => e.notifyChannel === channel);
52387
- try {
52388
- entries = filterAuditEntries(entries, { since, from, to });
52389
- } catch (err) {
52390
- return mcpError("usage", 2, err instanceof Error ? err.message : "invalid filter options");
52391
- }
52392
- const bounded = entries.slice(-limit).reverse();
52393
- const out = { entries: bounded, total: entries.length };
52394
- return {
52395
- content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
52396
- structuredContent: out
52397
- };
52398
- }
52399
- );
52400
- server.registerTool(
52401
- "rules_suggest",
52402
- {
52403
- title: "Draft a SwitchBot automation rule from intent",
52404
- description: "Generate a candidate automation rule YAML from a natural language intent. Uses keyword heuristics by default; pass llm to use an LLM backend (auto | openai | anthropic). Always emits dry_run: true \u2014 the rule must be reviewed before arming. Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.",
52405
- _meta: { agentSafetyTier: "read" },
52406
- inputSchema: external_exports.object({
52407
- intent: external_exports.string().min(1).describe('Natural language description (e.g. "turn off lights at 10pm").'),
52408
- trigger: external_exports.enum(["mqtt", "cron", "webhook"]).optional().describe("Trigger type (inferred from intent if omitted)."),
52409
- device_ids: external_exports.array(external_exports.string().min(1)).optional().describe("Device IDs; first is sensor for mqtt triggers, rest are action targets."),
52410
- event: external_exports.string().optional().describe("MQTT event name override (e.g. motion.detected)."),
52411
- schedule: external_exports.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
52412
- days: external_exports.array(external_exports.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
52413
- webhook_path: external_exports.string().optional().describe("Webhook path override (default /action)."),
52414
- llm: external_exports.enum(["auto", "openai", "anthropic"]).optional().describe("LLM backend (auto | openai | anthropic). Omit to use keyword heuristics.")
52415
- }).strict(),
52416
- outputSchema: {
52417
- rule: external_exports.unknown().describe("Rule object matching the v0.2 policy schema."),
52418
- rule_yaml: external_exports.string().describe("YAML string ready to pipe to policy_add_rule."),
52419
- warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted).")
52420
- }
52421
- },
52422
- async ({ intent, trigger, device_ids, event, schedule, days, webhook_path, llm }) => {
52423
- const devices = (device_ids ?? []).map((id) => {
52424
- const cached2 = getCachedDevice(id);
52425
- return { id, name: cached2?.name, type: cached2?.type };
52426
- });
52427
- try {
52428
- const { rule, ruleYaml, warnings } = await suggestRule({
52429
- intent,
52430
- trigger,
52431
- devices,
52432
- event,
52433
- schedule,
52434
- days,
52435
- webhookPath: webhook_path,
52436
- llm
53201
+ );
53202
+ if (!skip("rules_suggest"))
53203
+ server.registerTool(
53204
+ "rules_suggest",
53205
+ {
53206
+ title: "Draft a SwitchBot automation rule from intent",
53207
+ description: "Generate a candidate automation rule YAML from a natural language intent. Uses keyword heuristics by default; pass llm to use an LLM backend (auto | openai | anthropic). Always emits dry_run: true \u2014 the rule must be reviewed before arming. Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.",
53208
+ _meta: { agentSafetyTier: "read" },
53209
+ inputSchema: external_exports.object({
53210
+ intent: external_exports.string().min(1).describe('Natural language description (e.g. "turn off lights at 10pm").'),
53211
+ trigger: external_exports.enum(["mqtt", "cron", "webhook"]).optional().describe("Trigger type (inferred from intent if omitted)."),
53212
+ device_ids: external_exports.array(external_exports.string().min(1)).optional().describe("Device IDs; first is sensor for mqtt triggers, rest are action targets."),
53213
+ event: external_exports.string().optional().describe("MQTT event name override (e.g. motion.detected)."),
53214
+ schedule: external_exports.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
53215
+ days: external_exports.array(external_exports.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
53216
+ webhook_path: external_exports.string().optional().describe("Webhook path override (default /action)."),
53217
+ llm: external_exports.enum(["auto", "openai", "anthropic"]).optional().describe("LLM backend (auto | openai | anthropic). Omit to use keyword heuristics.")
53218
+ }).strict(),
53219
+ outputSchema: {
53220
+ rule: external_exports.unknown().describe("Rule object matching the v0.2 policy schema."),
53221
+ rule_yaml: external_exports.string().describe("YAML string ready to pipe to policy_add_rule."),
53222
+ warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted).")
53223
+ }
53224
+ },
53225
+ async ({ intent, trigger, device_ids, event, schedule, days, webhook_path, llm }) => {
53226
+ const devices = (device_ids ?? []).map((id) => {
53227
+ const cached2 = getCachedDevice(id);
53228
+ return { id, name: cached2?.name, type: cached2?.type };
52437
53229
  });
52438
- return {
52439
- content: [{ type: "text", text: ruleYaml }],
52440
- structuredContent: { rule, rule_yaml: ruleYaml, warnings }
52441
- };
52442
- } catch (err) {
52443
- return apiErrorToMcpError(err);
52444
- }
52445
- }
52446
- );
52447
- server.registerTool(
52448
- "rules_explain",
52449
- {
52450
- title: "Show why a rule evaluation fired or was blocked",
52451
- description: 'Read rule-evaluate trace records from the audit log and format them for inspection. Pass fire_id to explain a specific evaluation; or pass rule_name with last:true for the most recent evaluation; or pass rule_name + since for a window. Returns trace records only when automation.audit.evaluate_trace is "sampled" or "full".',
52452
- _meta: { agentSafetyTier: "read" },
52453
- inputSchema: external_exports.object({
52454
- fire_id: external_exports.string().optional().describe("Specific fireId to explain."),
52455
- rule_name: external_exports.string().optional().describe("Filter to this rule name."),
52456
- since: external_exports.string().optional().describe("Duration string (e.g. 1h, 7d) \u2014 show evaluations in this window."),
52457
- last: external_exports.boolean().optional().describe("Return only the most recent evaluation (requires rule_name)."),
52458
- audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
52459
- }).strict(),
52460
- outputSchema: {
52461
- records: external_exports.array(external_exports.unknown()).describe("Array of trace + relatedAudit objects."),
52462
- count: external_exports.number().describe("Number of trace records returned.")
52463
- }
52464
- },
52465
- async ({ fire_id, rule_name, since, last, audit_log }) => {
52466
- const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
52467
- const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
52468
- const sinceIso = since ? new Date(Date.now() - (parseDurationToMs(since) ?? 0)).toISOString() : void 0;
52469
- let records = loadTraceRecords(auditFile, {
52470
- fireId: fire_id,
52471
- ruleName: rule_name,
52472
- since: sinceIso
52473
- });
52474
- if (records.length === 0) {
52475
- return {
52476
- content: [{ type: "text", text: 'No rule-evaluate trace records found. Check that automation.audit.evaluate_trace is "sampled" or "full".' }],
52477
- structuredContent: { records: [], count: 0 }
52478
- };
52479
- }
52480
- if (last) {
52481
- records = [records[records.length - 1]];
52482
- }
52483
- const output = records.map((record2) => {
52484
- const related = loadRelatedAudit(auditFile, record2.fireId);
52485
- return JSON.parse(formatExplainJson(record2, related));
52486
- });
52487
- return {
52488
- content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
52489
- structuredContent: { records: output, count: output.length }
52490
- };
52491
- }
52492
- );
52493
- server.registerTool(
52494
- "rules_simulate",
52495
- {
52496
- title: "Simulate a rule against historical events",
52497
- description: "Replay historical events from the audit log or a JSONL file against a rule definition and report would-fire / blocked-by-condition / throttled outcomes. Useful for validating a new or modified rule before deployment. Pass rule_yaml to test an unpublished rule, or rule_name + policy_path to test a deployed rule.",
52498
- _meta: { agentSafetyTier: "read" },
52499
- inputSchema: external_exports.object({
52500
- rule_yaml: external_exports.string().optional().describe("Standalone rule YAML (takes precedence over policy_path + rule_name)."),
52501
- policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to ~/.switchbot/policy.yaml)."),
52502
- rule_name: external_exports.string().optional().describe("Name of the rule in policy.yaml to simulate."),
52503
- since: external_exports.string().optional().describe("Replay events from this window (e.g. 7d, 24h)."),
52504
- against: external_exports.string().optional().describe("JSONL file path of EngineEvent objects to replay."),
52505
- live_llm: external_exports.boolean().optional().describe("Allow live LLM calls for llm conditions (default: skip and report as would-call)."),
52506
- audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
52507
- }).strict(),
52508
- outputSchema: {
52509
- report: external_exports.unknown().describe("SimulateReport object.")
52510
- }
52511
- },
52512
- async ({ rule_yaml, policy_path, rule_name, since, against, live_llm, audit_log }) => {
52513
- const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
52514
- const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
52515
- let rule;
52516
- if (rule_yaml) {
52517
53230
  try {
52518
- rule = (0, import_yaml7.parse)(rule_yaml);
53231
+ const { rule, ruleYaml, warnings } = await suggestRule({
53232
+ intent,
53233
+ trigger,
53234
+ devices,
53235
+ event,
53236
+ schedule,
53237
+ days,
53238
+ webhookPath: webhook_path,
53239
+ llm
53240
+ });
53241
+ return {
53242
+ content: [{ type: "text", text: ruleYaml }],
53243
+ structuredContent: { rule, rule_yaml: ruleYaml, warnings }
53244
+ };
52519
53245
  } catch (err) {
53246
+ return apiErrorToMcpError(err);
53247
+ }
53248
+ }
53249
+ );
53250
+ if (!skip("rules_explain"))
53251
+ server.registerTool(
53252
+ "rules_explain",
53253
+ {
53254
+ title: "Show why a rule evaluation fired or was blocked",
53255
+ description: 'Read rule-evaluate trace records from the audit log and format them for inspection. Pass fire_id to explain a specific evaluation; or pass rule_name with last:true for the most recent evaluation; or pass rule_name + since for a window. Returns trace records only when automation.audit.evaluate_trace is "sampled" or "full".',
53256
+ _meta: { agentSafetyTier: "read" },
53257
+ inputSchema: external_exports.object({
53258
+ fire_id: external_exports.string().optional().describe("Specific fireId to explain."),
53259
+ rule_name: external_exports.string().optional().describe("Filter to this rule name."),
53260
+ since: external_exports.string().optional().describe("Duration string (e.g. 1h, 7d) \u2014 show evaluations in this window."),
53261
+ last: external_exports.boolean().optional().describe("Return only the most recent evaluation (requires rule_name)."),
53262
+ audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
53263
+ }).strict(),
53264
+ outputSchema: {
53265
+ records: external_exports.array(external_exports.unknown()).describe("Array of trace + relatedAudit objects."),
53266
+ count: external_exports.number().describe("Number of trace records returned.")
53267
+ }
53268
+ },
53269
+ async ({ fire_id, rule_name, since, last, audit_log }) => {
53270
+ const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
53271
+ const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
53272
+ const sinceIso = since ? new Date(Date.now() - (parseDurationToMs(since) ?? 0)).toISOString() : void 0;
53273
+ let records = loadTraceRecords(auditFile, {
53274
+ fireId: fire_id,
53275
+ ruleName: rule_name,
53276
+ since: sinceIso
53277
+ });
53278
+ if (records.length === 0) {
52520
53279
  return {
52521
- content: [{ type: "text", text: `Failed to parse rule_yaml: ${String(err)}` }],
52522
- structuredContent: { report: null }
53280
+ content: [{ type: "text", text: 'No rule-evaluate trace records found. Check that automation.audit.evaluate_trace is "sampled" or "full".' }],
53281
+ structuredContent: { records: [], count: 0 }
52523
53282
  };
52524
53283
  }
52525
- } else if (policy_path || rule_name) {
52526
- const { loadPolicyFile: loadPolicyFile2 } = await Promise.resolve().then(() => (init_load(), load_exports));
52527
- const policyFile = policy_path ?? pathJoin(os14.homedir(), ".switchbot", "policy.yaml");
52528
- try {
52529
- const policy = loadPolicyFile2(policyFile);
52530
- const data = policy.data ?? {};
52531
- const found = data.automation?.rules?.find((r) => r.name === rule_name);
52532
- if (!found) {
53284
+ if (last) {
53285
+ records = [records[records.length - 1]];
53286
+ }
53287
+ const output = records.map((record2) => {
53288
+ const related = loadRelatedAudit(auditFile, record2.fireId);
53289
+ return JSON.parse(formatExplainJson(record2, related));
53290
+ });
53291
+ return {
53292
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
53293
+ structuredContent: { records: output, count: output.length }
53294
+ };
53295
+ }
53296
+ );
53297
+ if (!skip("rules_simulate"))
53298
+ server.registerTool(
53299
+ "rules_simulate",
53300
+ {
53301
+ title: "Simulate a rule against historical events",
53302
+ description: "Replay historical events from the audit log or a JSONL file against a rule definition and report would-fire / blocked-by-condition / throttled outcomes. Useful for validating a new or modified rule before deployment. Pass rule_yaml to test an unpublished rule, or rule_name + policy_path to test a deployed rule.",
53303
+ _meta: { agentSafetyTier: "read" },
53304
+ inputSchema: external_exports.object({
53305
+ rule_yaml: external_exports.string().optional().describe("Standalone rule YAML (takes precedence over policy_path + rule_name)."),
53306
+ policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to ~/.switchbot/policy.yaml)."),
53307
+ rule_name: external_exports.string().optional().describe("Name of the rule in policy.yaml to simulate."),
53308
+ since: external_exports.string().optional().describe("Replay events from this window (e.g. 7d, 24h)."),
53309
+ against: external_exports.string().optional().describe("JSONL file path of EngineEvent objects to replay."),
53310
+ live_llm: external_exports.boolean().optional().describe("Allow live LLM calls for llm conditions (default: skip and report as would-call)."),
53311
+ audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
53312
+ }).strict(),
53313
+ outputSchema: {
53314
+ report: external_exports.unknown().describe("SimulateReport object.")
53315
+ }
53316
+ },
53317
+ async ({ rule_yaml, policy_path, rule_name, since, against, live_llm, audit_log }) => {
53318
+ const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
53319
+ const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
53320
+ let rule;
53321
+ if (rule_yaml) {
53322
+ try {
53323
+ rule = (0, import_yaml7.parse)(rule_yaml);
53324
+ } catch (err) {
52533
53325
  return {
52534
- content: [{ type: "text", text: `Rule "${rule_name}" not found in ${policyFile}.` }],
53326
+ content: [{ type: "text", text: `Failed to parse rule_yaml: ${String(err)}` }],
52535
53327
  structuredContent: { report: null }
52536
53328
  };
52537
53329
  }
52538
- rule = found;
53330
+ } else if (policy_path || rule_name) {
53331
+ const { loadPolicyFile: loadPolicyFile2 } = await Promise.resolve().then(() => (init_load(), load_exports));
53332
+ const policyFile = policy_path ?? pathJoin(os14.homedir(), ".switchbot", "policy.yaml");
53333
+ try {
53334
+ const policy = loadPolicyFile2(policyFile);
53335
+ const data = policy.data ?? {};
53336
+ const found = data.automation?.rules?.find((r) => r.name === rule_name);
53337
+ if (!found) {
53338
+ return {
53339
+ content: [{ type: "text", text: `Rule "${rule_name}" not found in ${policyFile}.` }],
53340
+ structuredContent: { report: null }
53341
+ };
53342
+ }
53343
+ rule = found;
53344
+ } catch (err) {
53345
+ return {
53346
+ content: [{ type: "text", text: `Failed to load policy: ${String(err)}` }],
53347
+ structuredContent: { report: null }
53348
+ };
53349
+ }
53350
+ } else {
53351
+ return {
53352
+ content: [{ type: "text", text: "Provide rule_yaml or (policy_path + rule_name) to specify the rule to simulate." }],
53353
+ structuredContent: { report: null }
53354
+ };
53355
+ }
53356
+ try {
53357
+ const report = await simulateRule({
53358
+ rule,
53359
+ since,
53360
+ against,
53361
+ auditLog: auditFile,
53362
+ liveLlm: live_llm ?? false
53363
+ });
53364
+ return {
53365
+ content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
53366
+ structuredContent: { report }
53367
+ };
52539
53368
  } catch (err) {
52540
53369
  return {
52541
- content: [{ type: "text", text: `Failed to load policy: ${String(err)}` }],
53370
+ content: [{ type: "text", text: `Simulate error: ${String(err)}` }],
52542
53371
  structuredContent: { report: null }
52543
53372
  };
52544
53373
  }
52545
- } else {
52546
- return {
52547
- content: [{ type: "text", text: "Provide rule_yaml or (policy_path + rule_name) to specify the rule to simulate." }],
52548
- structuredContent: { report: null }
52549
- };
52550
- }
52551
- try {
52552
- const report = await simulateRule({
52553
- rule,
52554
- since,
52555
- against,
52556
- auditLog: auditFile,
52557
- liveLlm: live_llm ?? false
52558
- });
52559
- return {
52560
- content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
52561
- structuredContent: { report }
52562
- };
52563
- } catch (err) {
52564
- return {
52565
- content: [{ type: "text", text: `Simulate error: ${String(err)}` }],
52566
- structuredContent: { report: null }
52567
- };
52568
53374
  }
52569
- }
52570
- );
52571
- server.registerTool(
52572
- "policy_add_rule",
52573
- {
52574
- title: "Append a rule to automation.rules[] in policy.yaml",
52575
- description: "Inject a rule YAML snippet (as produced by rules_suggest) into the automation.rules[] array in policy.yaml. Preserves existing comments and formatting. Always run with dry_run: true first so the agent can show the diff for user approval. Never set enable_automation: true without explicitly informing the user.",
52576
- _meta: { agentSafetyTier: "action" },
52577
- inputSchema: external_exports.object({
52578
- rule_yaml: external_exports.string().min(1).describe("YAML string of a single rule object (e.g. from rules_suggest)."),
52579
- policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to $SWITCHBOT_POLICY_PATH or ~/.switchbot/policy.yaml)."),
52580
- enable_automation: external_exports.boolean().default(false).describe("If true, sets automation.enabled: true after inserting the rule."),
52581
- dry_run: external_exports.boolean().default(false).describe("If true, compute and return the diff without writing to disk."),
52582
- force: external_exports.boolean().default(false).describe("If true, overwrite an existing rule with the same name.")
52583
- }).strict(),
52584
- outputSchema: {
52585
- policyPath: external_exports.string().describe("Resolved path to the policy file."),
52586
- ruleName: external_exports.string().describe("Name of the rule that was (or would be) inserted."),
52587
- written: external_exports.boolean().describe("True when the file was actually written."),
52588
- diff: external_exports.string().describe("Unified-style diff showing lines added/removed.")
52589
- }
52590
- },
52591
- ({ rule_yaml, policy_path, enable_automation, dry_run, force }) => {
52592
- const policyPath = resolvePolicyPath({ flag: policy_path });
52593
- try {
52594
- const result = addRuleToPolicyFile({
52595
- ruleYaml: rule_yaml,
52596
- policyPath,
52597
- enableAutomation: enable_automation,
52598
- dryRun: dry_run,
52599
- force
52600
- });
52601
- const out = { policyPath, ruleName: result.ruleName, written: result.written, diff: result.diff };
52602
- return {
52603
- content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
52604
- structuredContent: out
52605
- };
52606
- } catch (err) {
52607
- if (err instanceof AddRuleError) {
52608
- return apiErrorToMcpError(new Error(`${err.code}: ${err.message}`));
53375
+ );
53376
+ if (!skip("policy_add_rule"))
53377
+ server.registerTool(
53378
+ "policy_add_rule",
53379
+ {
53380
+ title: "Append a rule to automation.rules[] in policy.yaml",
53381
+ description: "Inject a rule YAML snippet (as produced by rules_suggest) into the automation.rules[] array in policy.yaml. Preserves existing comments and formatting. Always run with dry_run: true first so the agent can show the diff for user approval. Never set enable_automation: true without explicitly informing the user.",
53382
+ _meta: { agentSafetyTier: "action" },
53383
+ inputSchema: external_exports.object({
53384
+ rule_yaml: external_exports.string().min(1).describe("YAML string of a single rule object (e.g. from rules_suggest)."),
53385
+ policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to $SWITCHBOT_POLICY_PATH or ~/.switchbot/policy.yaml)."),
53386
+ enable_automation: external_exports.boolean().default(false).describe("If true, sets automation.enabled: true after inserting the rule."),
53387
+ dry_run: external_exports.boolean().default(false).describe("If true, compute and return the diff without writing to disk."),
53388
+ force: external_exports.boolean().default(false).describe("If true, overwrite an existing rule with the same name.")
53389
+ }).strict(),
53390
+ outputSchema: {
53391
+ policyPath: external_exports.string().describe("Resolved path to the policy file."),
53392
+ ruleName: external_exports.string().describe("Name of the rule that was (or would be) inserted."),
53393
+ written: external_exports.boolean().describe("True when the file was actually written."),
53394
+ diff: external_exports.string().describe("Unified-style diff showing lines added/removed.")
53395
+ }
53396
+ },
53397
+ ({ rule_yaml, policy_path, enable_automation, dry_run, force }) => {
53398
+ const policyPath = resolvePolicyPath({ flag: policy_path });
53399
+ try {
53400
+ const result = addRuleToPolicyFile({
53401
+ ruleYaml: rule_yaml,
53402
+ policyPath,
53403
+ enableAutomation: enable_automation,
53404
+ dryRun: dry_run,
53405
+ force
53406
+ });
53407
+ const out = { policyPath, ruleName: result.ruleName, written: result.written, diff: result.diff };
53408
+ return {
53409
+ content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
53410
+ structuredContent: out
53411
+ };
53412
+ } catch (err) {
53413
+ if (err instanceof AddRuleError) {
53414
+ return apiErrorToMcpError(new Error(`${err.code}: ${err.message}`));
53415
+ }
53416
+ return apiErrorToMcpError(err);
52609
53417
  }
52610
- return apiErrorToMcpError(err);
52611
53418
  }
52612
- }
52613
- );
53419
+ );
52614
53420
  return server;
52615
53421
  }
52616
53422
  function listRegisteredTools(server) {
@@ -52636,8 +53442,8 @@ function listRegisteredToolsWithMeta(server) {
52636
53442
  function listRegisteredResources() {
52637
53443
  return ["switchbot://events"];
52638
53444
  }
52639
- function printMcpToolDirectory() {
52640
- const server = createSwitchBotMcpServer();
53445
+ function printMcpToolDirectory(toolProfile) {
53446
+ const server = createSwitchBotMcpServer({ toolProfile });
52641
53447
  const tools = listRegisteredToolsWithMeta(server);
52642
53448
  const resources = listRegisteredResources().map((uri) => ({ uri }));
52643
53449
  if (isJsonMode()) {
@@ -52709,16 +53515,30 @@ Example Claude Desktop config (~/Library/Application Support/Claude/claude_deskt
52709
53515
  Inspect locally:
52710
53516
  $ npx @modelcontextprotocol/inspector switchbot mcp serve
52711
53517
  `);
52712
- mcp.command("tools").description("Print the registered MCP tools in human or JSON form").action(() => printMcpToolDirectory());
52713
- mcp.command("list-tools").description("Alias of `mcp tools`").action(() => printMcpToolDirectory());
52714
- mcp.command("serve").description("Start the MCP server on stdio (default) or HTTP (--port)").option("--port <n>", "Listen on HTTP instead of stdio (Streamable HTTP transport)", intArg("--port", { min: 1, max: 65535 })).option("--bind <host>", "IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)", stringArg("--bind"), "127.0.0.1").option("--auth-token <token>", "Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)", stringArg("--auth-token")).option("--cors-origin <url>", "Allowed CORS origin(s) for HTTP (repeatable)", stringArg("--cors-origin")).option("--rate-limit <n>", "Max requests per minute per profile (default 60)", intArg("--rate-limit", { min: 1 }), "60").addHelpText("after", `
53518
+ mcp.command("tools").description("Print the registered MCP tools in human or JSON form").option("--tools <profile>", "Tool profile: default, readonly, all (default: all)", stringArg("--tools"), "all").action((opts) => {
53519
+ try {
53520
+ printMcpToolDirectory(resolveToolProfile(opts.tools));
53521
+ } catch (e) {
53522
+ handleError(e);
53523
+ }
53524
+ });
53525
+ mcp.command("list-tools").description("Alias of `mcp tools`").option("--tools <profile>", "Tool profile: default, readonly, all (default: all)", stringArg("--tools"), "all").action((opts) => {
53526
+ try {
53527
+ printMcpToolDirectory(resolveToolProfile(opts.tools));
53528
+ } catch (e) {
53529
+ handleError(e);
53530
+ }
53531
+ });
53532
+ mcp.command("serve").description("Start the MCP server on stdio (default) or HTTP (--port)").option("--port <n>", "Listen on HTTP instead of stdio (Streamable HTTP transport)", intArg("--port", { min: 1, max: 65535 })).option("--bind <host>", "IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)", stringArg("--bind"), "127.0.0.1").option("--auth-token <token>", "Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)", stringArg("--auth-token")).option("--cors-origin <url>", "Allowed CORS origin(s) for HTTP (repeatable)", stringArg("--cors-origin")).option("--rate-limit <n>", "Max requests per minute per profile (default 60)", intArg("--rate-limit", { min: 1 }), "60").option("--tools <profile>", "Tool profile: default, readonly, all (default: default)", stringArg("--tools"), "default").addHelpText("after", `
52715
53533
  Examples:
52716
53534
  $ switchbot mcp serve
53535
+ $ switchbot mcp serve --tools all
52717
53536
  $ switchbot mcp serve --port 8787
52718
53537
  $ switchbot mcp serve --port 8787 --bind 127.0.0.1 --auth-token your-token
52719
53538
  $ switchbot mcp serve --port 8787 --bind 0.0.0.0 --auth-token your-token
52720
53539
  `).action(async (options) => {
52721
53540
  try {
53541
+ const toolProfile = resolveToolProfile(options.tools);
52722
53542
  if (options.port) {
52723
53543
  const port = Number(options.port);
52724
53544
  if (!Number.isFinite(port) || port < 1 || port > 65535) {
@@ -52867,7 +53687,7 @@ process_uptime_seconds ${Math.floor(process.uptime())}
52867
53687
  }
52868
53688
  }
52869
53689
  const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
52870
- const reqServer = createSwitchBotMcpServer({ eventManager: eventManager2 });
53690
+ const reqServer = createSwitchBotMcpServer({ eventManager: eventManager2, toolProfile });
52871
53691
  res.on("close", () => {
52872
53692
  reqTransport.close();
52873
53693
  reqServer.close();
@@ -52919,7 +53739,7 @@ process_uptime_seconds ${Math.floor(process.uptime())}
52919
53739
  console.error("MQTT initialization failed:", err instanceof Error ? err.message : String(err));
52920
53740
  });
52921
53741
  }
52922
- const server = createSwitchBotMcpServer({ eventManager });
53742
+ const server = createSwitchBotMcpServer({ eventManager, toolProfile });
52923
53743
  const transport = new StdioServerTransport();
52924
53744
  await server.connect(transport);
52925
53745
  let isShuttingDown = false;
@@ -55689,7 +56509,7 @@ Examples:
55689
56509
  $ switchbot history show --limit 10
55690
56510
  $ switchbot history replay 3
55691
56511
  `);
55692
- history.command("show").description("Print recent audit entries").option("--file <path>", `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg("--file")).option("--limit <n>", "Show only the last N entries", intArg("--limit", { min: 1 })).action((options) => {
56512
+ history.command("show").alias("list").description("Print recent audit entries").option("--file <path>", `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg("--file")).option("--limit <n>", "Show only the last N entries", intArg("--limit", { min: 1 })).action((options) => {
55693
56513
  const file2 = options.file ?? DEFAULT_AUDIT;
55694
56514
  const entries = readAudit(file2);
55695
56515
  const limited = options.limit !== void 0 ? entries.slice(-Math.max(1, Number(options.limit) || 1)) : entries;
@@ -59338,6 +60158,7 @@ import os25 from "node:os";
59338
60158
  import path27 from "node:path";
59339
60159
  var DEFAULT_AUDIT_PATH3 = path27.join(os25.homedir(), ".switchbot", "audit.log");
59340
60160
  var AUDIT_ERROR_WINDOW_MS = 24 * 60 * 60 * 1e3;
60161
+ var EXPECTED_ERROR_CODES = /* @__PURE__ */ new Set([161, 171, 190]);
59341
60162
  function getHealthReport(auditPath = DEFAULT_AUDIT_PATH3) {
59342
60163
  const now = /* @__PURE__ */ new Date();
59343
60164
  const procHealth = {
@@ -59358,20 +60179,46 @@ function getHealthReport(auditPath = DEFAULT_AUDIT_PATH3) {
59358
60179
  };
59359
60180
  let auditHealth;
59360
60181
  if (!fs31.existsSync(auditPath)) {
59361
- auditHealth = { present: false, recentErrors: 0, recentTotal: 0, errorRatePercent: 0, status: "ok" };
60182
+ auditHealth = {
60183
+ present: false,
60184
+ recentErrors: 0,
60185
+ recentTotal: 0,
60186
+ errorRatePercent: 0,
60187
+ expectedErrors: 0,
60188
+ unexpectedErrors: 0,
60189
+ unexpectedRatePercent: 0,
60190
+ breakdown: {},
60191
+ status: "ok"
60192
+ };
59362
60193
  } else {
59363
60194
  const entries = readAudit(auditPath);
59364
60195
  const windowStart = now.getTime() - AUDIT_ERROR_WINDOW_MS;
59365
60196
  const recent = entries.filter((e) => new Date(e.t).getTime() >= windowStart);
59366
- const errors = recent.filter((e) => e.result === "error").length;
60197
+ const errorEntries = recent.filter((e) => e.result === "error");
59367
60198
  const total = recent.length;
60199
+ const errors = errorEntries.length;
59368
60200
  const errorRate = total > 0 ? Math.round(errors / total * 100) : 0;
60201
+ const breakdown = {};
60202
+ let expectedErrors = 0;
60203
+ for (const e of errorEntries) {
60204
+ const code = e.statusCode !== void 0 ? String(e.statusCode) : "unknown";
60205
+ breakdown[code] = (breakdown[code] ?? 0) + 1;
60206
+ if (e.statusCode !== void 0 && EXPECTED_ERROR_CODES.has(e.statusCode)) {
60207
+ expectedErrors++;
60208
+ }
60209
+ }
60210
+ const unexpectedErrors = errors - expectedErrors;
60211
+ const unexpectedRatePercent = total > 0 ? Math.round(unexpectedErrors / total * 100 * 10) / 10 : 0;
59369
60212
  auditHealth = {
59370
60213
  present: true,
59371
60214
  recentErrors: errors,
59372
60215
  recentTotal: total,
59373
60216
  errorRatePercent: errorRate,
59374
- status: errorRate >= 30 ? "warn" : "ok"
60217
+ expectedErrors,
60218
+ unexpectedErrors,
60219
+ unexpectedRatePercent,
60220
+ breakdown,
60221
+ status: unexpectedRatePercent >= 30 ? "warn" : "ok"
59375
60222
  };
59376
60223
  }
59377
60224
  const cbStats = apiCircuitBreaker.getStats();