@switchbot/openapi-cli 3.3.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6396,6 +6396,7 @@ var init_prime = __esm({
6396
6396
  import fs2 from "node:fs";
6397
6397
  import path2 from "node:path";
6398
6398
  import os3 from "node:os";
6399
+ import { createHash } from "node:crypto";
6399
6400
  function sanitizeOptionalString(v2) {
6400
6401
  if (typeof v2 !== "string") return void 0;
6401
6402
  const trimmed = v2.trim();
@@ -6586,12 +6587,13 @@ function getConfigSummary() {
6586
6587
  }
6587
6588
  }
6588
6589
  function maskCredential(token) {
6589
- if (token.length <= 8) return "*".repeat(Math.max(4, token.length));
6590
- return token.slice(0, 4) + "*".repeat(token.length - 8) + token.slice(-4);
6590
+ return `**** [sha256:${fingerprintCredential(token)}]`;
6591
6591
  }
6592
6592
  function maskSecret(secret) {
6593
- if (secret.length <= 4) return "****";
6594
- return secret.slice(0, 2) + "*".repeat(secret.length - 4) + secret.slice(-2);
6593
+ return `**** [sha256:${fingerprintCredential(secret)}]`;
6594
+ }
6595
+ function fingerprintCredential(value) {
6596
+ return createHash("sha256").update(value).digest("hex").slice(-8);
6595
6597
  }
6596
6598
  var init_config = __esm({
6597
6599
  "src/config.ts"() {
@@ -7219,7 +7221,7 @@ function emitJsonError(errorPayload) {
7219
7221
  function emitStreamHeader(opts) {
7220
7222
  console.log(
7221
7223
  JSON.stringify({
7222
- schemaVersion: "1",
7224
+ schemaVersion: SCHEMA_VERSION,
7223
7225
  stream: true,
7224
7226
  eventKind: opts.eventKind,
7225
7227
  cadence: opts.cadence
@@ -8125,9 +8127,9 @@ var init_catalog = __esm({
8125
8127
  description: "Battery-powered temperature and humidity sensor; read-only, no control commands.",
8126
8128
  role: "sensor",
8127
8129
  readOnly: true,
8128
- aliases: ["Meter Plus", "MeterPro", "MeterPro(CO2)", "WoIOSensor", "Hub 2"],
8130
+ aliases: ["Meter Plus", "MeterPro", "MeterPro(CO2)", "WoIOSensor"],
8129
8131
  commands: [],
8130
- statusFields: ["temperature", "humidity", "CO2", "battery", "version"]
8132
+ statusFields: ["temperature", "humidity", "battery", "version"]
8131
8133
  },
8132
8134
  {
8133
8135
  type: "Motion Sensor",
@@ -8157,6 +8159,15 @@ var init_catalog = __esm({
8157
8159
  statusFields: ["battery", "version", "status"]
8158
8160
  },
8159
8161
  // Status-only hub-class devices (no control commands)
8162
+ {
8163
+ type: "Hub 2",
8164
+ category: "physical",
8165
+ description: "Wi-Fi hub with built-in temperature, humidity, and light sensors; bridges BLE devices to the cloud.",
8166
+ role: "hub",
8167
+ readOnly: true,
8168
+ commands: [],
8169
+ statusFields: ["version", "temperature", "humidity", "lightLevel"]
8170
+ },
8160
8171
  {
8161
8172
  type: "Hub Mini",
8162
8173
  category: "physical",
@@ -8192,7 +8203,7 @@ var init_catalog = __esm({
8192
8203
  role: "climate",
8193
8204
  readOnly: true,
8194
8205
  commands: [],
8195
- statusFields: ["temperature", "humidity", "version"]
8206
+ statusFields: ["battery", "brightness", "moveDetected", "humidity", "temperature", "version"]
8196
8207
  },
8197
8208
  {
8198
8209
  type: "Wallet Finder Card",
@@ -8301,11 +8312,11 @@ var init_catalog = __esm({
8301
8312
  import fs6 from "node:fs";
8302
8313
  import path6 from "node:path";
8303
8314
  import os7 from "node:os";
8304
- import { createHash } from "node:crypto";
8315
+ import { createHash as createHash2 } from "node:crypto";
8305
8316
  function scopedCacheDir(baseDir) {
8306
8317
  const profile = getActiveProfile();
8307
8318
  if (profile === void 0) return baseDir;
8308
- const hash2 = createHash("sha256").update(profile).digest("hex").slice(0, 8);
8319
+ const hash2 = createHash2("sha256").update(profile).digest("hex").slice(0, 8);
8309
8320
  const dir = path6.join(baseDir, "cache", hash2);
8310
8321
  if (!fs6.existsSync(dir)) fs6.mkdirSync(dir, { recursive: true });
8311
8322
  return dir;
@@ -8367,9 +8378,11 @@ function getCachedTypeMap(deviceIds) {
8367
8378
  function updateCacheFromDeviceList(body) {
8368
8379
  const devices = {};
8369
8380
  for (const d of body.deviceList) {
8370
- if (!d.deviceId || !d.deviceType) continue;
8381
+ if (!d.deviceId) continue;
8371
8382
  devices[d.deviceId] = {
8372
- type: d.deviceType,
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 ?? "",
8373
8386
  name: d.deviceName,
8374
8387
  category: "physical",
8375
8388
  hubDeviceId: d.hubDeviceId,
@@ -27172,6 +27185,9 @@ var IdempotencyConflictError = class extends Error {
27172
27185
  function hashKey(key) {
27173
27186
  return crypto2.createHash("sha256").update(key).digest("hex");
27174
27187
  }
27188
+ function fingerprintIdempotencyKey(key) {
27189
+ return hashKey(key).slice(0, 12);
27190
+ }
27175
27191
  function shapeSignature(command, parameter) {
27176
27192
  let parm;
27177
27193
  try {
@@ -27371,9 +27387,23 @@ var CommandValidationError = class extends Error {
27371
27387
  kind;
27372
27388
  hint;
27373
27389
  };
27374
- async function fetchDeviceList(client) {
27390
+ function hasDanglingHubReference(device, isPhysical, deviceList) {
27391
+ if (!isPhysical) return false;
27392
+ const hubDeviceId = device.hubDeviceId;
27393
+ if (!hubDeviceId || hubDeviceId === "000000000000" || hubDeviceId === device.deviceId) return false;
27394
+ return !deviceList.some((d) => d.deviceId === hubDeviceId);
27395
+ }
27396
+ function describeCatalogNote(deviceId, typeName, isPhysical) {
27397
+ if (isPhysical) {
27398
+ const label2 = typeName || "this device type";
27399
+ return `No built-in catalog entry for ${label2}; raw device metadata is shown. Try \`switchbot devices status ${deviceId}\` for live raw status.`;
27400
+ }
27401
+ const label = typeName || "this IR remote type";
27402
+ return `No built-in catalog entry for ${label}; raw device metadata is shown. Use \`switchbot devices command ${deviceId} "<buttonName>" --type customize\` for custom IR buttons.`;
27403
+ }
27404
+ async function fetchDeviceList(client, options = {}) {
27375
27405
  const mode = getCacheMode();
27376
- if (mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs)) {
27406
+ if (!options.bypassCache && mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs)) {
27377
27407
  const cached2 = loadCache();
27378
27408
  if (cached2) {
27379
27409
  const deviceList = [];
@@ -27383,7 +27413,7 @@ async function fetchDeviceList(client) {
27383
27413
  deviceList.push({
27384
27414
  deviceId,
27385
27415
  deviceName: entry.name,
27386
- deviceType: entry.type,
27416
+ ...entry.type ? { deviceType: entry.type } : {},
27387
27417
  enableCloudService: entry.enableCloudService ?? true,
27388
27418
  hubDeviceId: entry.hubDeviceId ?? "",
27389
27419
  roomID: entry.roomID,
@@ -27439,6 +27469,7 @@ async function executeCommand(deviceId, cmd, parameter, commandType, client, opt
27439
27469
  parameter,
27440
27470
  commandType,
27441
27471
  dryRun: isDryRun(),
27472
+ ...options?.idempotencyKey ? { idempotencyKeyFingerprint: fingerprintIdempotencyKey(options.idempotencyKey) } : {},
27442
27473
  ...options?.planId ? { planId: options.planId } : {}
27443
27474
  };
27444
27475
  const execute = async () => {
@@ -27451,7 +27482,7 @@ async function executeCommand(deviceId, cmd, parameter, commandType, client, opt
27451
27482
  return res.data.body;
27452
27483
  } catch (err) {
27453
27484
  if (err instanceof Error && err.name === "DryRunSignal") {
27454
- writeAudit({ ...baseAudit, result: "ok" });
27485
+ writeAudit({ ...baseAudit, result: "dry-run" });
27455
27486
  } else {
27456
27487
  writeAudit({
27457
27488
  ...baseAudit,
@@ -27468,6 +27499,12 @@ async function executeCommand(deviceId, cmd, parameter, commandType, client, opt
27468
27499
  { command: cmd, parameter }
27469
27500
  );
27470
27501
  if (!replayed) return result;
27502
+ writeAudit({
27503
+ ...baseAudit,
27504
+ t: (/* @__PURE__ */ new Date()).toISOString(),
27505
+ result: "ok",
27506
+ replayed: true
27507
+ });
27471
27508
  if (result && typeof result === "object") {
27472
27509
  return { ...result, replayed: true };
27473
27510
  }
@@ -27557,10 +27594,19 @@ function getDestructiveReason(deviceType, cmd, commandType) {
27557
27594
  return spec ? getCommandSafetyReason(spec) : null;
27558
27595
  }
27559
27596
  async function describeDevice(deviceId, options = {}, client) {
27560
- const body = await fetchDeviceList(client);
27561
- const { deviceList, infraredRemoteList } = body;
27562
- const physical = deviceList.find((d) => d.deviceId === deviceId);
27563
- const ir = infraredRemoteList.find((d) => d.deviceId === deviceId);
27597
+ const mode = getCacheMode();
27598
+ const hadFreshListCache = mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs) && loadCache() !== null;
27599
+ let body = await fetchDeviceList(client);
27600
+ let { deviceList, infraredRemoteList } = body;
27601
+ let physical = deviceList.find((d) => d.deviceId === deviceId);
27602
+ let ir = infraredRemoteList.find((d) => d.deviceId === deviceId);
27603
+ if (!physical && !ir && hadFreshListCache) {
27604
+ body = await fetchDeviceList(client, { bypassCache: true });
27605
+ deviceList = body.deviceList;
27606
+ infraredRemoteList = body.infraredRemoteList;
27607
+ physical = deviceList.find((d) => d.deviceId === deviceId);
27608
+ ir = infraredRemoteList.find((d) => d.deviceId === deviceId);
27609
+ }
27564
27610
  if (!physical && !ir) throw new DeviceNotFoundError(deviceId);
27565
27611
  const typeName = physical ? physical.deviceType ?? "" : ir.remoteType;
27566
27612
  const match = typeName ? findCatalogEntry(typeName) : null;
@@ -27589,8 +27635,13 @@ async function describeDevice(deviceId, options = {}, client) {
27589
27635
  statusFields: catalogEntry.statusFields ?? [],
27590
27636
  ...liveStatus !== void 0 ? { liveStatus } : {}
27591
27637
  } : liveStatus !== void 0 ? { liveStatus } : null;
27638
+ const warnings = [];
27639
+ const selectedDevice = physical ?? ir;
27640
+ if (hasDanglingHubReference(selectedDevice, Boolean(physical), deviceList)) {
27641
+ warnings.push(`hubDeviceId ${selectedDevice.hubDeviceId} is not present in the current inventory`);
27642
+ }
27592
27643
  return {
27593
- device: physical ?? ir,
27644
+ device: selectedDevice,
27594
27645
  isPhysical: Boolean(physical),
27595
27646
  typeName,
27596
27647
  controlType: physical?.controlType ?? ir?.controlType ?? null,
@@ -27598,6 +27649,8 @@ async function describeDevice(deviceId, options = {}, client) {
27598
27649
  capabilities,
27599
27650
  source,
27600
27651
  suggestedActions: catalogEntry ? suggestedActions(catalogEntry) : [],
27652
+ ...catalogEntry ? {} : { catalogNote: describeCatalogNote(deviceId, typeName, Boolean(physical)) },
27653
+ ...warnings.length > 0 ? { warnings } : {},
27601
27654
  inheritedLocation: ir ? buildHubLocationMap(deviceList).get(ir.hubDeviceId) : void 0
27602
27655
  };
27603
27656
  }
@@ -27648,6 +27701,8 @@ function toMcpDescribeShape(r) {
27648
27701
  source: r.source,
27649
27702
  capabilities: r.capabilities,
27650
27703
  suggestedActions: r.suggestedActions,
27704
+ ...r.catalogNote !== void 0 ? { catalogNote: r.catalogNote } : {},
27705
+ ...r.warnings !== void 0 ? { warnings: r.warnings } : {},
27651
27706
  ...r.inheritedLocation !== void 0 ? { inheritedLocation: { family: r.inheritedLocation.family, room: r.inheritedLocation.room } } : {}
27652
27707
  };
27653
27708
  }
@@ -28468,7 +28523,6 @@ Examples:
28468
28523
  idempotencyKey: options.idempotencyKeyPrefix ? `${options.idempotencyKeyPrefix}-${id}` : void 0
28469
28524
  }));
28470
28525
  const planDoc = {
28471
- schemaVersion: "1.1",
28472
28526
  dryRun: true,
28473
28527
  plan: {
28474
28528
  command: cmd,
@@ -28577,7 +28631,6 @@ Examples:
28577
28631
  skipped: dryRunned.length + preSkipped.length,
28578
28632
  durationMs: Date.now() - startedAt,
28579
28633
  unverifiableCount: succeeded.filter((s2) => getCachedDevice(s2.deviceId)?.category === "ir").length,
28580
- schemaVersion: "1.1",
28581
28634
  maxConcurrent: concurrency,
28582
28635
  staggerMs,
28583
28636
  ...dryRun ? { dryRun: true } : {}
@@ -28725,6 +28778,7 @@ function listAllCanonical() {
28725
28778
 
28726
28779
  // src/commands/watch.ts
28727
28780
  var MIN_INTERVAL_MS = 1e3;
28781
+ var INITIAL_MODES = ["snapshot", "emit", "skip"];
28728
28782
  function diff(prev, next, fields) {
28729
28783
  const out = {};
28730
28784
  const keys = fields ?? Object.keys(next);
@@ -28741,6 +28795,10 @@ function formatHumanLine(ev) {
28741
28795
  const when = new Date(ev.t).toLocaleTimeString();
28742
28796
  const head = `[${when}] ${ev.deviceId}${ev.type ? ` (${ev.type})` : ""}`;
28743
28797
  if (ev.error) return `${head}: error \u2014 ${ev.error}`;
28798
+ if (ev.snapshot) {
28799
+ const pairs3 = Object.entries(ev.snapshot).map(([k2, v2]) => `${k2}=${JSON.stringify(v2)}`).join(", ");
28800
+ return `${head}: snapshot ${pairs3}`;
28801
+ }
28744
28802
  const keys = Object.keys(ev.changed);
28745
28803
  if (keys.length === 0) return `${head}: no changes`;
28746
28804
  const pairs2 = keys.map((k2) => {
@@ -28771,15 +28829,19 @@ function registerWatchCommand(devices) {
28771
28829
  `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1e3}s)`,
28772
28830
  durationArg("--interval"),
28773
28831
  "30s"
28774
- ).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").addHelpText(
28832
+ ).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(
28775
28833
  "after",
28776
28834
  `
28777
28835
  Default output is a human-readable table of field changes per tick; add --json
28778
28836
  to get one JSON-Lines record per deviceId per tick (the agent-friendly form).
28779
28837
 
28780
- The very first poll emits a seed tick with "from": null for every field, so
28781
- the initial state is observable. Subsequent ticks only include fields whose
28782
- value changed (unless --include-unchanged is passed).
28838
+ The first poll is configurable:
28839
+ --initial=snapshot emit one baseline snapshot event, then only diffs
28840
+ --initial=emit treat the first poll as null -> value changes
28841
+ --initial=skip record the baseline silently, then only diffs
28842
+
28843
+ Subsequent ticks only include fields whose value changed (unless
28844
+ --include-unchanged is passed).
28783
28845
 
28784
28846
  Each --json line has the shape:
28785
28847
  { "t": "<ISO>", "tick": <n>, "deviceId": "ID", "type": "Bot",
@@ -28838,7 +28900,32 @@ Examples:
28838
28900
  const cached2 = getCachedDevice(id);
28839
28901
  try {
28840
28902
  const body = await fetchDeviceStatus(id, client);
28841
- const changed = diff(prev.get(id), body, fields);
28903
+ const previous = prev.get(id);
28904
+ const baseline = fields ? Object.fromEntries(fields.map((f2) => [f2, body[f2] ?? null])) : body;
28905
+ if (!prev.has(id)) {
28906
+ if (options.initial === "skip") {
28907
+ prev.set(id, body);
28908
+ return;
28909
+ }
28910
+ if (options.initial === "snapshot") {
28911
+ prev.set(id, body);
28912
+ const ev2 = {
28913
+ t,
28914
+ tick,
28915
+ deviceId: id,
28916
+ type: cached2?.type,
28917
+ changed: {},
28918
+ snapshot: baseline
28919
+ };
28920
+ if (isJsonMode()) {
28921
+ printJson(ev2);
28922
+ } else {
28923
+ console.log(formatHumanLine(ev2));
28924
+ }
28925
+ return;
28926
+ }
28927
+ }
28928
+ const changed = diff(previous, body, fields);
28842
28929
  prev.set(id, body);
28843
28930
  if (Object.keys(changed).length === 0 && !options.includeUnchanged) {
28844
28931
  return;
@@ -28913,7 +29000,7 @@ Examples:
28913
29000
  try {
28914
29001
  const wantLive = options.live !== false;
28915
29002
  const desc = await describeDevice(deviceId, { live: wantLive });
28916
- const warnings = [];
29003
+ const warnings = [...desc.warnings ?? []];
28917
29004
  if (desc.isPhysical && !desc.device.enableCloudService) {
28918
29005
  warnings.push("Cloud service disabled on this device \u2014 commands will fail.");
28919
29006
  }
@@ -28948,6 +29035,7 @@ Examples:
28948
29035
  name: deviceName(desc.device),
28949
29036
  role: desc.catalog?.role ?? null,
28950
29037
  readOnly: desc.catalog?.readOnly ?? false,
29038
+ ...desc.catalogNote ? { catalogNote: desc.catalogNote } : {},
28951
29039
  location,
28952
29040
  liveStatus,
28953
29041
  commands,
@@ -28973,6 +29061,9 @@ function printHuman(r) {
28973
29061
  const loc = [r.location?.family, r.location?.room].filter(Boolean).join(" / ");
28974
29062
  console.log(`location: ${loc}`);
28975
29063
  }
29064
+ if (r.catalogNote) {
29065
+ console.log(`catalog: ${r.catalogNote}`);
29066
+ }
28976
29067
  if (r.warnings.length) {
28977
29068
  console.log("warnings:");
28978
29069
  for (const w2 of r.warnings) console.log(` ! ${w2}`);
@@ -29019,7 +29110,7 @@ init_cache();
29019
29110
  init_flags();
29020
29111
  init_client();
29021
29112
  function registerExpandCommand(devices) {
29022
- 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)").option("--name <query>", "Resolve device by fuzzy name instead of deviceId", stringArg("--name")).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("--yes", "Confirm destructive commands").addHelpText("after", `
29113
+ 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)").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("--yes", "Confirm destructive commands").addHelpText("after", `
29023
29114
  Translates semantic flags into the wire parameter format, then sends the command.
29024
29115
 
29025
29116
  Supported expansions:
@@ -29057,7 +29148,12 @@ Examples:
29057
29148
  effectiveCommand = deviceIdArg;
29058
29149
  effectiveDeviceIdArg = void 0;
29059
29150
  }
29060
- deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
29151
+ deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name, {
29152
+ strategy: options.nameStrategy ?? "require-unique",
29153
+ type: options.nameType,
29154
+ category: options.nameCategory,
29155
+ room: options.nameRoom
29156
+ });
29061
29157
  if (!effectiveCommand) throw new UsageError("A command argument is required (setAll, setPosition, setMode).");
29062
29158
  command = effectiveCommand;
29063
29159
  const cached2 = getCachedDevice(deviceId);
@@ -29240,6 +29336,21 @@ var EXPAND_HINTS = {
29240
29336
  "Blind Tilt": { command: "setPosition", flags: "--direction up --angle 50" },
29241
29337
  "Relay Switch 2PM": { command: "setMode", flags: "--channel 1 --mode edge" }
29242
29338
  };
29339
+ function annotateStatusPayload(deviceId, body) {
29340
+ const annotated = { ...body };
29341
+ if (Object.keys(body).length === 0) {
29342
+ annotated.supported = false;
29343
+ annotated.note = "this device does not expose cloud status";
29344
+ return annotated;
29345
+ }
29346
+ const cached2 = getCachedDevice(deviceId);
29347
+ const looksLikeMeter = cached2?.type?.toLowerCase().includes("meter") ?? false;
29348
+ const staleZeroReading = looksLikeMeter && !Object.prototype.hasOwnProperty.call(body, "onlineStatus") && body.battery === 0 && body.temperature === 0 && body.humidity === 0;
29349
+ if (staleZeroReading) {
29350
+ annotated.hint = "readings look stale; check batteries or hub connectivity";
29351
+ }
29352
+ return annotated;
29353
+ }
29243
29354
  function registerDevicesCommand(program3) {
29244
29355
  const COMMAND_TYPES2 = ["command", "customize"];
29245
29356
  const devices = program3.command("devices").description("Manage and control SwitchBot devices").addHelpText("after", `
@@ -29475,7 +29586,7 @@ Examples:
29475
29586
  const results = await Promise.allSettled(ids.map((id) => fetchDeviceStatus(id)));
29476
29587
  const fetchedAt2 = (/* @__PURE__ */ new Date()).toISOString();
29477
29588
  const batch = results.map(
29478
- (r, i) => r.status === "fulfilled" ? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt2, ...r.value } : { deviceId: ids[i], ok: false, error: r.reason?.message ?? String(r.reason) }
29589
+ (r, i) => r.status === "fulfilled" ? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt2, ...annotateStatusPayload(ids[i], r.value) } : { deviceId: ids[i], ok: false, error: r.reason?.message ?? String(r.reason) }
29479
29590
  );
29480
29591
  const batchFmt = resolveFormat();
29481
29592
  if (isJsonMode() || batchFmt === "json") {
@@ -29509,7 +29620,7 @@ Examples:
29509
29620
  category: options.nameCategory,
29510
29621
  room: options.nameRoom
29511
29622
  });
29512
- const body = await fetchDeviceStatus(deviceId);
29623
+ const body = annotateStatusPayload(deviceId, await fetchDeviceStatus(deviceId));
29513
29624
  const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
29514
29625
  const fmt = resolveFormat();
29515
29626
  if (fmt === "json" && process.argv.includes("--json")) {
@@ -29787,7 +29898,7 @@ ${extra}` : extra;
29787
29898
  if (isJsonMode()) {
29788
29899
  printJson({ dryRun: true, wouldSend });
29789
29900
  } else {
29790
- console.log(`[dry-run] Would POST devices/${_deviceId}/commands with ${JSON.stringify({ command: _cmd, parameter: _parsedParam, commandType })}`);
29901
+ console.log(`\u25E6 dry-run intercepted for ${_cmd} on ${_deviceId}; see stderr preview for the HTTP request.`);
29791
29902
  }
29792
29903
  return;
29793
29904
  }
@@ -29932,6 +30043,8 @@ Examples:
29932
30043
  capabilities,
29933
30044
  source,
29934
30045
  suggestedActions: picks,
30046
+ ...result.catalogNote ? { catalogNote: result.catalogNote } : {},
30047
+ ...result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {},
29935
30048
  ...expandHint ? { expandHint: { command: expandHint.command, flags: expandHint.flags, example: `switchbot devices expand ${deviceId} ${expandHint.command} ${expandHint.flags}` } } : {}
29936
30049
  });
29937
30050
  return;
@@ -29965,8 +30078,17 @@ Examples:
29965
30078
  }
29966
30079
  const liveStatus = capabilities && "liveStatus" in capabilities ? capabilities.liveStatus : void 0;
29967
30080
  console.log("");
30081
+ if (result.warnings && result.warnings.length > 0) {
30082
+ for (const warning of result.warnings) {
30083
+ console.log(`Warning: ${warning}`);
30084
+ }
30085
+ console.log("");
30086
+ }
29968
30087
  if (!catalog) {
29969
30088
  console.log(`(Type "${typeName}" is not in the built-in catalog \u2014 no command reference available.)`);
30089
+ if (result.catalogNote) {
30090
+ console.log(result.catalogNote);
30091
+ }
29970
30092
  if (isPhysical) {
29971
30093
  console.log(`Try 'switchbot devices status ${deviceId}' to see what this device reports.`);
29972
30094
  } else {
@@ -30050,6 +30172,7 @@ function renderCatalogEntry(entry) {
30050
30172
  if (entry.statusFields && entry.statusFields.length > 0) {
30051
30173
  console.log('\nStatus fields (from "devices status"):');
30052
30174
  console.log(" " + entry.statusFields.join(", "));
30175
+ console.log(" Note: statusFields are advisory; actual fields can vary by firmware and device variant.");
30053
30176
  }
30054
30177
  const expandHint = EXPAND_HINTS[entry.type];
30055
30178
  if (expandHint) {
@@ -45423,6 +45546,12 @@ function isSupportedPolicySchemaVersion(v2) {
45423
45546
  return typeof v2 === "string" && SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(v2);
45424
45547
  }
45425
45548
 
45549
+ // src/policy/validate.ts
45550
+ init_catalog();
45551
+
45552
+ // src/rules/action.ts
45553
+ init_cjs_shim();
45554
+
45426
45555
  // src/rules/destructive.ts
45427
45556
  init_cjs_shim();
45428
45557
  var DESTRUCTIVE_COMMANDS = [
@@ -45456,9 +45585,195 @@ function destructiveVerbOf(cmd) {
45456
45585
  return null;
45457
45586
  }
45458
45587
 
45588
+ // src/rules/action.ts
45589
+ var DEVICES_COMMAND_RE = /^devices\s+command\s+(\S+)\s+(\S+)(?:\s+(.*))?$/;
45590
+ function parseRuleCommand(cmd) {
45591
+ const m2 = DEVICES_COMMAND_RE.exec(cmd.trim());
45592
+ if (!m2) return null;
45593
+ const deviceIdSlot = m2[1];
45594
+ const verb = m2[2];
45595
+ const rest = (m2[3] ?? "").trim();
45596
+ return {
45597
+ deviceIdSlot,
45598
+ verb,
45599
+ parameterTokens: rest.length === 0 ? [] : rest.split(/\s+/)
45600
+ };
45601
+ }
45602
+ function resolveActionDevice(explicit, slot, aliases) {
45603
+ const candidate = explicit ?? (slot && slot !== "<id>" ? slot : null);
45604
+ if (!candidate) return null;
45605
+ if (aliases[candidate]) return aliases[candidate];
45606
+ return candidate;
45607
+ }
45608
+ function renderParameter(tokens) {
45609
+ if (tokens.length === 0) return void 0;
45610
+ if (tokens.length === 1) return tokens[0];
45611
+ return tokens.join(":");
45612
+ }
45613
+ async function executeRuleAction(action, ctx) {
45614
+ const parsed = parseRuleCommand(action.command);
45615
+ if (!parsed) {
45616
+ writeAudit({
45617
+ t: (/* @__PURE__ */ new Date()).toISOString(),
45618
+ kind: "rule-fire",
45619
+ deviceId: "unknown",
45620
+ command: action.command,
45621
+ parameter: null,
45622
+ commandType: "command",
45623
+ dryRun: true,
45624
+ result: "error",
45625
+ error: "unparseable-command",
45626
+ rule: {
45627
+ name: ctx.rule.name,
45628
+ triggerSource: ctx.rule.when.source,
45629
+ fireId: ctx.fireId,
45630
+ reason: "unparseable-command"
45631
+ }
45632
+ });
45633
+ return { ok: false, error: "unparseable-command", blocked: true };
45634
+ }
45635
+ if (isDestructiveCommand2(action.command)) {
45636
+ writeAudit({
45637
+ t: (/* @__PURE__ */ new Date()).toISOString(),
45638
+ kind: "rule-fire",
45639
+ deviceId: resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases) ?? "unknown",
45640
+ command: action.command,
45641
+ parameter: null,
45642
+ commandType: "command",
45643
+ dryRun: true,
45644
+ result: "error",
45645
+ error: `destructive-verb:${parsed.verb}`,
45646
+ rule: {
45647
+ name: ctx.rule.name,
45648
+ triggerSource: ctx.rule.when.source,
45649
+ fireId: ctx.fireId,
45650
+ reason: `destructive verb "${parsed.verb}" refused at runtime`
45651
+ }
45652
+ });
45653
+ return { ok: false, error: `destructive-verb:${parsed.verb}`, blocked: true, verb: parsed.verb };
45654
+ }
45655
+ const deviceId = resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases);
45656
+ if (!deviceId || deviceId === "<id>") {
45657
+ writeAudit({
45658
+ t: (/* @__PURE__ */ new Date()).toISOString(),
45659
+ kind: "rule-fire",
45660
+ deviceId: "unknown",
45661
+ command: action.command,
45662
+ parameter: null,
45663
+ commandType: "command",
45664
+ dryRun: true,
45665
+ result: "error",
45666
+ error: "missing-device",
45667
+ rule: {
45668
+ name: ctx.rule.name,
45669
+ triggerSource: ctx.rule.when.source,
45670
+ fireId: ctx.fireId,
45671
+ reason: "action omitted `device` and command used `<id>` placeholder"
45672
+ }
45673
+ });
45674
+ return { ok: false, error: "missing-device", verb: parsed.verb };
45675
+ }
45676
+ const dryRun = ctx.globalDryRun === true || ctx.rule.dry_run === true;
45677
+ const parameter = renderParameter(parsed.parameterTokens);
45678
+ if (dryRun) {
45679
+ writeAudit({
45680
+ t: (/* @__PURE__ */ new Date()).toISOString(),
45681
+ kind: "rule-fire-dry",
45682
+ deviceId,
45683
+ command: parsed.verb,
45684
+ parameter: parameter ?? "default",
45685
+ commandType: "command",
45686
+ dryRun: true,
45687
+ result: "ok",
45688
+ rule: {
45689
+ name: ctx.rule.name,
45690
+ triggerSource: ctx.rule.when.source,
45691
+ matchedDevice: deviceId,
45692
+ fireId: ctx.fireId
45693
+ }
45694
+ });
45695
+ return { ok: true, dryRun: true, deviceId, verb: parsed.verb };
45696
+ }
45697
+ if (ctx.skipApiCall) {
45698
+ writeAudit({
45699
+ t: (/* @__PURE__ */ new Date()).toISOString(),
45700
+ kind: "rule-fire",
45701
+ deviceId,
45702
+ command: parsed.verb,
45703
+ parameter: parameter ?? "default",
45704
+ commandType: "command",
45705
+ dryRun: false,
45706
+ result: "ok",
45707
+ rule: {
45708
+ name: ctx.rule.name,
45709
+ triggerSource: ctx.rule.when.source,
45710
+ matchedDevice: deviceId,
45711
+ fireId: ctx.fireId,
45712
+ reason: "api-skipped"
45713
+ }
45714
+ });
45715
+ return { ok: true, deviceId, verb: parsed.verb };
45716
+ }
45717
+ try {
45718
+ await executeCommand(deviceId, parsed.verb, parameter, "command", ctx.httpClient);
45719
+ writeAudit({
45720
+ t: (/* @__PURE__ */ new Date()).toISOString(),
45721
+ kind: "rule-fire",
45722
+ deviceId,
45723
+ command: parsed.verb,
45724
+ parameter: parameter ?? "default",
45725
+ commandType: "command",
45726
+ dryRun: false,
45727
+ result: "ok",
45728
+ rule: {
45729
+ name: ctx.rule.name,
45730
+ triggerSource: ctx.rule.when.source,
45731
+ matchedDevice: deviceId,
45732
+ fireId: ctx.fireId
45733
+ }
45734
+ });
45735
+ return { ok: true, deviceId, verb: parsed.verb };
45736
+ } catch (err) {
45737
+ const msg = err instanceof Error ? err.message : String(err);
45738
+ writeAudit({
45739
+ t: (/* @__PURE__ */ new Date()).toISOString(),
45740
+ kind: "rule-fire",
45741
+ deviceId,
45742
+ command: parsed.verb,
45743
+ parameter: parameter ?? "default",
45744
+ commandType: "command",
45745
+ dryRun: false,
45746
+ result: "error",
45747
+ error: msg,
45748
+ rule: {
45749
+ name: ctx.rule.name,
45750
+ triggerSource: ctx.rule.when.source,
45751
+ matchedDevice: deviceId,
45752
+ fireId: ctx.fireId
45753
+ }
45754
+ });
45755
+ return { ok: false, error: msg, deviceId, verb: parsed.verb };
45756
+ }
45757
+ }
45758
+ function extractDeviceIdFromAction(action) {
45759
+ if (action.device) return action.device;
45760
+ const m2 = /\bdevices\s+command\s+(\S+)/.exec(action.command ?? "");
45761
+ return m2 ? m2[1] : null;
45762
+ }
45763
+
45459
45764
  // src/policy/validate.ts
45460
45765
  var require4 = createRequire3(import.meta.url);
45461
45766
  var addFormats = require4("ajv-formats");
45767
+ var POLICY_VALIDATION_LIMITATIONS = [
45768
+ "Does not resolve aliases against the live device inventory.",
45769
+ "Does not verify commands against the real target device, live capabilities, or current firmware."
45770
+ ];
45771
+ var POLICY_VALIDATION_LIVE_LIMITATIONS = [
45772
+ "Live inventory checks reflect a point-in-time device list snapshot.",
45773
+ "Does not verify commands against live capabilities or current firmware."
45774
+ ];
45775
+ var HEX_MAC_DEVICE_ID_RE = /^[A-Fa-f0-9]{12}(?:-[A-Za-z0-9]{2,16})?$/;
45776
+ var HYPHENATED_DEVICE_ID_RE = /^[A-Za-z0-9]{2,32}(?:-[A-Za-z0-9]{2,32}){1,4}$/;
45462
45777
  var validators = /* @__PURE__ */ new Map();
45463
45778
  function getValidator(version2) {
45464
45779
  const cached2 = validators.get(version2);
@@ -45502,6 +45817,13 @@ function getKeyNodeAt(doc, parentSegments, key) {
45502
45817
  const pair = parent.items.find((p2) => (0, import_yaml2.isScalar)(p2.key) && String(p2.key.value) === key);
45503
45818
  return pair?.key ?? null;
45504
45819
  }
45820
+ function locateInstancePath(doc, lineCounter, instancePath) {
45821
+ const node = getNodeAt(doc, instancePathToSegments(instancePath));
45822
+ const range = node?.range;
45823
+ if (!range) return {};
45824
+ const pos = lineCounter.linePos(range[0]);
45825
+ return { line: pos.line, col: pos.col };
45826
+ }
45505
45827
  function locateError(doc, lineCounter, err) {
45506
45828
  const segments = instancePathToSegments(err.instancePath);
45507
45829
  if (err.keyword === "additionalProperties") {
@@ -45587,6 +45909,8 @@ function unsupportedVersionResult(loaded, declared) {
45587
45909
  return {
45588
45910
  policyPath: loaded.path,
45589
45911
  schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
45912
+ validationScope: "schema+offline-semantics",
45913
+ limitations: [...POLICY_VALIDATION_LIMITATIONS],
45590
45914
  valid: false,
45591
45915
  errors: [
45592
45916
  {
@@ -45601,6 +45925,58 @@ function unsupportedVersionResult(loaded, declared) {
45601
45925
  ]
45602
45926
  };
45603
45927
  }
45928
+ function escapeJsonPointerSegment(segment) {
45929
+ return segment.replace(/~/g, "~0").replace(/\//g, "~1");
45930
+ }
45931
+ function isPlausibleDeviceId(value) {
45932
+ return HEX_MAC_DEVICE_ID_RE.test(value) || HYPHENATED_DEVICE_ID_RE.test(value);
45933
+ }
45934
+ function hasErrorAtPath(errors, path26) {
45935
+ return errors.some((err) => err.path === path26);
45936
+ }
45937
+ function resolvePolicyDeviceRef(raw, aliases) {
45938
+ if (!raw) return { ok: false, reason: "missing-device" };
45939
+ if (raw === "<id>") return { ok: false, reason: "missing-device" };
45940
+ if (Object.hasOwn(aliases, raw)) return { ok: true };
45941
+ if (isPlausibleDeviceId(raw)) return { ok: true };
45942
+ return { ok: false, reason: "unknown-device-ref" };
45943
+ }
45944
+ function collectAliasMap(data) {
45945
+ const aliases = data?.aliases;
45946
+ if (!aliases || typeof aliases !== "object") return {};
45947
+ return Object.fromEntries(
45948
+ Object.entries(aliases).filter(
45949
+ (entry) => typeof entry[0] === "string" && typeof entry[1] === "string"
45950
+ )
45951
+ );
45952
+ }
45953
+ function isDeviceStateConditionLike(value) {
45954
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
45955
+ const candidate = value;
45956
+ return typeof candidate.device === "string" && typeof candidate.field === "string" && typeof candidate.op === "string";
45957
+ }
45958
+ function collectConditionDeviceRefs(condition, path26) {
45959
+ if (!condition || typeof condition !== "object" || Array.isArray(condition)) return [];
45960
+ const out = [];
45961
+ if (isDeviceStateConditionLike(condition)) {
45962
+ out.push({ path: `${path26}/device`, ref: condition.device });
45963
+ }
45964
+ const candidate = condition;
45965
+ if (Array.isArray(candidate.all)) {
45966
+ for (let i = 0; i < candidate.all.length; i++) {
45967
+ out.push(...collectConditionDeviceRefs(candidate.all[i], `${path26}/all/${i}`));
45968
+ }
45969
+ }
45970
+ if (Array.isArray(candidate.any)) {
45971
+ for (let i = 0; i < candidate.any.length; i++) {
45972
+ out.push(...collectConditionDeviceRefs(candidate.any[i], `${path26}/any/${i}`));
45973
+ }
45974
+ }
45975
+ if (candidate.not !== void 0) {
45976
+ out.push(...collectConditionDeviceRefs(candidate.not, `${path26}/not`));
45977
+ }
45978
+ return out;
45979
+ }
45604
45980
  function collectDestructiveRuleErrors(loaded) {
45605
45981
  const data = loaded.data;
45606
45982
  const rules = data?.automation?.rules;
@@ -45639,6 +46015,258 @@ function collectDestructiveRuleErrors(loaded) {
45639
46015
  }
45640
46016
  return out;
45641
46017
  }
46018
+ function collectOfflineSemanticErrors(loaded, existingErrors) {
46019
+ const data = loaded.data;
46020
+ const out = [];
46021
+ const aliases = collectAliasMap(data);
46022
+ for (const [aliasName, deviceId] of Object.entries(aliases)) {
46023
+ const path26 = `/aliases/${escapeJsonPointerSegment(aliasName)}`;
46024
+ if (hasErrorAtPath(existingErrors, path26)) continue;
46025
+ if (isPlausibleDeviceId(deviceId)) continue;
46026
+ const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, path26);
46027
+ out.push({
46028
+ path: path26,
46029
+ line,
46030
+ col,
46031
+ keyword: "alias-device-id",
46032
+ message: `alias "${aliasName}" does not point to a plausible SwitchBot deviceId`,
46033
+ hint: "use a deviceId from `switchbot devices list --format=tsv`, e.g. 01-202407090924-26354212 or 28372F4C9C4A",
46034
+ schemaPath: "#/properties/aliases"
46035
+ });
46036
+ }
46037
+ const knownDeviceCommands = new Set(
46038
+ getEffectiveCatalog().flatMap((entry) => entry.commands).filter((spec) => spec.commandType !== "customize").map((spec) => spec.command)
46039
+ );
46040
+ const rules = data?.automation?.rules;
46041
+ if (!Array.isArray(rules)) return out;
46042
+ for (let ri = 0; ri < rules.length; ri++) {
46043
+ const rule = rules[ri];
46044
+ const ruleName = typeof rule?.name === "string" ? rule.name : `#${ri}`;
46045
+ if (typeof rule?.when?.device === "string") {
46046
+ const whenDevicePath = `/automation/rules/${ri}/when/device`;
46047
+ const resolved = resolvePolicyDeviceRef(rule.when.device, aliases);
46048
+ if (!resolved.ok) {
46049
+ const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, whenDevicePath);
46050
+ out.push({
46051
+ path: whenDevicePath,
46052
+ line,
46053
+ col,
46054
+ keyword: resolved.reason ?? "unknown-device-ref",
46055
+ message: `rule "${ruleName}" trigger references unknown device "${rule.when.device}"`,
46056
+ hint: "set `when.device` to a declared alias or a plausible deviceId",
46057
+ schemaPath: "#/properties/automation/properties/rules/items/properties/when/properties/device"
46058
+ });
46059
+ }
46060
+ }
46061
+ const conditions = Array.isArray(rule?.conditions) ? rule.conditions : [];
46062
+ for (let ci = 0; ci < conditions.length; ci++) {
46063
+ for (const ref of collectConditionDeviceRefs(conditions[ci], `/automation/rules/${ri}/conditions/${ci}`)) {
46064
+ const resolved = resolvePolicyDeviceRef(ref.ref, aliases);
46065
+ if (!resolved.ok) {
46066
+ const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, ref.path);
46067
+ out.push({
46068
+ path: ref.path,
46069
+ line,
46070
+ col,
46071
+ keyword: resolved.reason ?? "unknown-device-ref",
46072
+ message: `rule "${ruleName}" condition references unknown device "${ref.ref}"`,
46073
+ hint: "set condition `device` to a declared alias or a plausible deviceId",
46074
+ schemaPath: "#/properties/automation/properties/rules/items/properties/conditions"
46075
+ });
46076
+ }
46077
+ }
46078
+ }
46079
+ const actions = Array.isArray(rule?.then) ? rule.then : [];
46080
+ for (let ai = 0; ai < actions.length; ai++) {
46081
+ const action = actions[ai];
46082
+ const cmd = action?.command;
46083
+ if (typeof cmd !== "string") continue;
46084
+ const commandPath = `/automation/rules/${ri}/then/${ai}/command`;
46085
+ const devicePath = `/automation/rules/${ri}/then/${ai}/device`;
46086
+ const parsed = parseRuleCommand(cmd);
46087
+ if (!parsed) {
46088
+ const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
46089
+ out.push({
46090
+ path: commandPath,
46091
+ line,
46092
+ col,
46093
+ keyword: "rule-unparseable-command",
46094
+ message: `rule "${ruleName}" action #${ai} must use \`devices command <id> <verb> [parameter...]\``,
46095
+ hint: "automation rules currently support only `devices command ...` actions; scenes/webhooks/other subcommands are not executable here",
46096
+ schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
46097
+ });
46098
+ continue;
46099
+ }
46100
+ if (!knownDeviceCommands.has(parsed.verb)) {
46101
+ const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
46102
+ out.push({
46103
+ path: commandPath,
46104
+ line,
46105
+ col,
46106
+ keyword: "rule-unknown-command",
46107
+ message: `rule "${ruleName}" action #${ai} uses unknown device command "${parsed.verb}"`,
46108
+ hint: "check `switchbot devices commands <type>` for valid verbs; this validator only checks offline catalog verbs, not the real target device",
46109
+ schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
46110
+ });
46111
+ }
46112
+ if (typeof action?.device === "string") {
46113
+ const resolved2 = resolvePolicyDeviceRef(action.device, aliases);
46114
+ if (!resolved2.ok) {
46115
+ const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, devicePath);
46116
+ out.push({
46117
+ path: devicePath,
46118
+ line,
46119
+ col,
46120
+ keyword: resolved2.reason ?? "unknown-device-ref",
46121
+ message: `rule "${ruleName}" action #${ai} references unknown device "${action.device}"`,
46122
+ hint: "set `device:` to a declared alias or a plausible deviceId",
46123
+ schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/device"
46124
+ });
46125
+ }
46126
+ continue;
46127
+ }
46128
+ const resolved = resolvePolicyDeviceRef(parsed.deviceIdSlot ?? void 0, aliases);
46129
+ if (!resolved.ok) {
46130
+ const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
46131
+ out.push({
46132
+ path: commandPath,
46133
+ line,
46134
+ col,
46135
+ keyword: resolved.reason ?? "missing-device",
46136
+ message: resolved.reason === "missing-device" ? `rule "${ruleName}" action #${ai} uses \`<id>\` but does not provide \`device:\`` : `rule "${ruleName}" action #${ai} references unknown device "${parsed.deviceIdSlot}"`,
46137
+ hint: resolved.reason === "missing-device" ? "either replace `<id>` with a deviceId/alias or add `device: <alias-or-deviceId>` to the action" : "use a declared alias or a plausible deviceId in the command slot",
46138
+ schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
46139
+ });
46140
+ }
46141
+ }
46142
+ }
46143
+ return out;
46144
+ }
46145
+ function resolveInventoryDeviceId(raw, aliases) {
46146
+ if (!raw || raw === "<id>") return null;
46147
+ if (Object.hasOwn(aliases, raw)) return aliases[raw];
46148
+ return raw;
46149
+ }
46150
+ function validateLoadedPolicyAgainstInventory(loaded, inventory) {
46151
+ const base = validateLoadedPolicy(loaded);
46152
+ const errors = [...base.errors];
46153
+ const aliases = collectAliasMap(loaded.data);
46154
+ const inventoryById = /* @__PURE__ */ new Map();
46155
+ for (const device of inventory.deviceList) {
46156
+ inventoryById.set(device.deviceId, { typeName: device.deviceType ?? "" });
46157
+ }
46158
+ for (const remote of inventory.infraredRemoteList) {
46159
+ inventoryById.set(remote.deviceId, { typeName: remote.remoteType });
46160
+ }
46161
+ for (const [aliasName, deviceId] of Object.entries(aliases)) {
46162
+ const path26 = `/aliases/${escapeJsonPointerSegment(aliasName)}`;
46163
+ if (hasErrorAtPath(errors, path26)) continue;
46164
+ if (!inventoryById.has(deviceId)) {
46165
+ const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, path26);
46166
+ errors.push({
46167
+ path: path26,
46168
+ line,
46169
+ col,
46170
+ keyword: "alias-live-device-not-found",
46171
+ message: `alias "${aliasName}" points to deviceId "${deviceId}" which is not present in the current inventory`,
46172
+ hint: "refresh with `switchbot devices list` and confirm the alias target still exists on this account",
46173
+ schemaPath: "#/properties/aliases"
46174
+ });
46175
+ }
46176
+ }
46177
+ const data = loaded.data;
46178
+ const rules = data?.automation?.rules;
46179
+ if (Array.isArray(rules)) {
46180
+ for (let ri = 0; ri < rules.length; ri++) {
46181
+ const rule = rules[ri];
46182
+ const ruleName = typeof rule?.name === "string" ? rule.name : `#${ri}`;
46183
+ if (typeof rule?.when?.device === "string") {
46184
+ const whenDevicePath = `/automation/rules/${ri}/when/device`;
46185
+ const effectiveDeviceId = resolveInventoryDeviceId(rule.when.device, aliases);
46186
+ if (effectiveDeviceId && !inventoryById.has(effectiveDeviceId)) {
46187
+ const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, whenDevicePath);
46188
+ errors.push({
46189
+ path: whenDevicePath,
46190
+ line,
46191
+ col,
46192
+ keyword: "rule-live-device-not-found",
46193
+ message: `rule "${ruleName}" trigger resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`,
46194
+ hint: "confirm `when.device` against `switchbot devices list` before relying on this policy",
46195
+ schemaPath: "#/properties/automation/properties/rules/items/properties/when/properties/device"
46196
+ });
46197
+ }
46198
+ }
46199
+ const conditions = Array.isArray(rule?.conditions) ? rule.conditions : [];
46200
+ for (let ci = 0; ci < conditions.length; ci++) {
46201
+ for (const ref of collectConditionDeviceRefs(conditions[ci], `/automation/rules/${ri}/conditions/${ci}`)) {
46202
+ const effectiveDeviceId = resolveInventoryDeviceId(ref.ref, aliases);
46203
+ if (effectiveDeviceId && !inventoryById.has(effectiveDeviceId)) {
46204
+ const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, ref.path);
46205
+ errors.push({
46206
+ path: ref.path,
46207
+ line,
46208
+ col,
46209
+ keyword: "rule-live-device-not-found",
46210
+ message: `rule "${ruleName}" condition resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`,
46211
+ hint: "confirm the condition device against `switchbot devices list` before relying on this policy",
46212
+ schemaPath: "#/properties/automation/properties/rules/items/properties/conditions"
46213
+ });
46214
+ }
46215
+ }
46216
+ }
46217
+ const actions = Array.isArray(rule?.then) ? rule.then : [];
46218
+ for (let ai = 0; ai < actions.length; ai++) {
46219
+ const action = actions[ai];
46220
+ const cmd = action?.command;
46221
+ if (typeof cmd !== "string") continue;
46222
+ const parsed = parseRuleCommand(cmd);
46223
+ if (!parsed) continue;
46224
+ const commandPath = `/automation/rules/${ri}/then/${ai}/command`;
46225
+ const devicePath = `/automation/rules/${ri}/then/${ai}/device`;
46226
+ const effectiveRef = typeof action?.device === "string" ? action.device : parsed.deviceIdSlot ?? void 0;
46227
+ const effectiveDeviceId = resolveInventoryDeviceId(effectiveRef, aliases);
46228
+ if (!effectiveDeviceId) continue;
46229
+ const target = inventoryById.get(effectiveDeviceId);
46230
+ if (!target) {
46231
+ const path26 = typeof action?.device === "string" ? devicePath : commandPath;
46232
+ const { line: line2, col: col2 } = locateInstancePath(loaded.doc, loaded.lineCounter, path26);
46233
+ errors.push({
46234
+ path: path26,
46235
+ line: line2,
46236
+ col: col2,
46237
+ keyword: "rule-live-device-not-found",
46238
+ message: `rule "${ruleName}" action #${ai} resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`,
46239
+ hint: "confirm the alias/deviceId against `switchbot devices list` before relying on this policy",
46240
+ schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/device"
46241
+ });
46242
+ continue;
46243
+ }
46244
+ const match = target.typeName ? findCatalogEntry(target.typeName) : null;
46245
+ const entry = !match || Array.isArray(match) ? null : match;
46246
+ if (!entry) continue;
46247
+ const supported = entry.commands.filter((spec) => spec.commandType !== "customize").some((spec) => spec.command === parsed.verb);
46248
+ if (supported) continue;
46249
+ const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
46250
+ errors.push({
46251
+ path: commandPath,
46252
+ line,
46253
+ col,
46254
+ keyword: "rule-live-unsupported-command",
46255
+ message: `rule "${ruleName}" action #${ai} uses command "${parsed.verb}" but live target "${effectiveDeviceId}" is type "${target.typeName}"`,
46256
+ hint: `supported offline verbs for ${target.typeName}: ${entry.commands.filter((spec) => spec.commandType !== "customize").map((spec) => spec.command).join(", ")}`,
46257
+ schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
46258
+ });
46259
+ }
46260
+ }
46261
+ }
46262
+ return {
46263
+ ...base,
46264
+ validationScope: "schema+offline-semantics+live-inventory",
46265
+ limitations: [...POLICY_VALIDATION_LIVE_LIMITATIONS],
46266
+ valid: errors.length === 0,
46267
+ errors
46268
+ };
46269
+ }
45642
46270
  function validateLoadedPolicy(loaded) {
45643
46271
  const declared = readDeclaredVersion(loaded.data);
45644
46272
  if (declared !== void 0 && !isSupportedPolicySchemaVersion(declared)) {
@@ -45665,11 +46293,14 @@ function validateLoadedPolicy(loaded) {
45665
46293
  if (version2 === "0.2") {
45666
46294
  const ruleErrors = collectDestructiveRuleErrors(loaded);
45667
46295
  errors.push(...ruleErrors);
46296
+ errors.push(...collectOfflineSemanticErrors(loaded, errors));
45668
46297
  }
45669
46298
  const valid = ok === true && errors.length === 0;
45670
46299
  return {
45671
46300
  policyPath: loaded.path,
45672
46301
  schemaVersion: version2,
46302
+ validationScope: "schema+offline-semantics",
46303
+ limitations: [...POLICY_VALIDATION_LIMITATIONS],
45673
46304
  valid,
45674
46305
  errors
45675
46306
  };
@@ -45751,6 +46382,15 @@ var COMMAND_KEYWORDS = [
45751
46382
  { pattern: /\bclose\b|\blower\b|\bdown\b/i, command: "close" },
45752
46383
  { pattern: /\bpause\b/i, command: "pause" }
45753
46384
  ];
46385
+ function inferCommandFromIntent(intent) {
46386
+ for (const k2 of COMMAND_KEYWORDS) {
46387
+ if (k2.pattern.test(intent)) return k2.command;
46388
+ }
46389
+ return void 0;
46390
+ }
46391
+ function containsCjk(intent) {
46392
+ return /[\u3400-\u9FFF]/u.test(intent);
46393
+ }
45754
46394
 
45755
46395
  // src/lib/plan-store.ts
45756
46396
  init_cjs_shim();
@@ -45956,14 +46596,13 @@ function validatePlan(raw) {
45956
46596
  }
45957
46597
  function suggestPlan(opts) {
45958
46598
  const warnings = [];
45959
- let command = "";
45960
- for (const k2 of COMMAND_KEYWORDS) {
45961
- if (k2.pattern.test(opts.intent)) {
45962
- command = k2.command;
45963
- break;
45964
- }
45965
- }
46599
+ let command = inferCommandFromIntent(opts.intent) ?? "";
45966
46600
  if (!command) {
46601
+ if (containsCjk(opts.intent)) {
46602
+ throw new UsageError(
46603
+ `Intent "${opts.intent}" contains non-English command text that this heuristic cannot safely infer. Use explicit English command words (turnOn/turnOff/open/close/lock/unlock/press/pause) or author the plan manually.`
46604
+ );
46605
+ }
45967
46606
  command = "turnOn";
45968
46607
  warnings.push(
45969
46608
  `Could not infer command from intent "${opts.intent}" \u2014 defaulted to "turnOn". Edit the generated plan to set the correct command.`
@@ -46012,7 +46651,7 @@ async function executePlanSteps(plan, planId, options) {
46012
46651
  const out = {
46013
46652
  plan,
46014
46653
  results: [],
46015
- summary: { total: plan.steps.length, ok: 0, error: 0, skipped: 0 }
46654
+ summary: { total: plan.steps.length, ok: 0, error: 0, skipped: 0, dryRun: 0 }
46016
46655
  };
46017
46656
  for (let i = 0; i < plan.steps.length; i++) {
46018
46657
  const step = plan.steps[i];
@@ -46071,8 +46710,8 @@ async function executePlanSteps(plan, planId, options) {
46071
46710
  if (!isJsonMode()) console.log(` ${idx}. \u2713 ${step.command} on ${resolvedDeviceId}`);
46072
46711
  } catch (err) {
46073
46712
  if (err instanceof Error && err.name === "DryRunSignal") {
46074
- out.results.push({ step: idx, type: "command", deviceId: resolvedDeviceId, command: step.command, status: "ok" });
46075
- out.summary.ok++;
46713
+ out.results.push({ step: idx, type: "command", deviceId: resolvedDeviceId, command: step.command, status: "dry-run" });
46714
+ out.summary.dryRun++;
46076
46715
  if (!isJsonMode()) console.log(` ${idx}. \u25E6 dry-run ${step.command} on ${resolvedDeviceId}`);
46077
46716
  continue;
46078
46717
  }
@@ -46155,25 +46794,29 @@ against the live API without executing any mutations.
46155
46794
  (v2, prev) => [...prev, v2],
46156
46795
  []
46157
46796
  ).option("--out <file>", "Write plan JSON to file instead of stdout").action((opts) => {
46158
- if (opts.device.length === 0) {
46159
- console.error("error: at least one --device is required");
46160
- process.exit(1);
46161
- }
46162
- const devices = opts.device.map((ref) => {
46163
- const cached2 = getCachedDevice(ref);
46164
- return { id: ref, name: cached2?.name, type: cached2?.type };
46165
- });
46166
- const { plan: suggested, warnings } = suggestPlan({ intent: opts.intent, devices });
46167
- for (const w2 of warnings) process.stderr.write(`warning: ${w2}
46797
+ try {
46798
+ if (opts.device.length === 0) {
46799
+ console.error("error: at least one --device is required");
46800
+ process.exit(1);
46801
+ }
46802
+ const devices = opts.device.map((ref) => {
46803
+ const cached2 = getCachedDevice(ref);
46804
+ return { id: ref, name: cached2?.name, type: cached2?.type };
46805
+ });
46806
+ const { plan: suggested, warnings } = suggestPlan({ intent: opts.intent, devices });
46807
+ for (const w2 of warnings) process.stderr.write(`warning: ${w2}
46168
46808
  `);
46169
- const json3 = JSON.stringify(suggested, null, 2);
46170
- if (opts.out) {
46171
- fs13.writeFileSync(opts.out, json3 + "\n", "utf8");
46172
- if (!isJsonMode()) console.log(`\u2713 plan written to ${opts.out}`);
46173
- } else if (isJsonMode()) {
46174
- printJson({ plan: suggested, warnings });
46175
- } else {
46176
- console.log(json3);
46809
+ const json3 = JSON.stringify(suggested, null, 2);
46810
+ if (opts.out) {
46811
+ fs13.writeFileSync(opts.out, json3 + "\n", "utf8");
46812
+ if (!isJsonMode()) console.log(`\u2713 plan written to ${opts.out}`);
46813
+ } else if (isJsonMode()) {
46814
+ printJson({ plan: suggested, warnings });
46815
+ } else {
46816
+ console.log(json3);
46817
+ }
46818
+ } catch (err) {
46819
+ handleError(err);
46177
46820
  }
46178
46821
  });
46179
46822
  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(
@@ -46373,6 +47016,13 @@ summary: ok=${ok} error=${error48} skipped=${skipped} total=${out.summary.total}
46373
47016
  // src/rules/suggest.ts
46374
47017
  init_cjs_shim();
46375
47018
  var import_yaml4 = __toESM(require_dist(), 1);
47019
+ init_output();
47020
+ function buildSuggestedAction(command, deviceId) {
47021
+ if (deviceId) {
47022
+ return { command: `devices command ${deviceId} ${command}` };
47023
+ }
47024
+ return { command: `devices command <id> ${command}` };
47025
+ }
46376
47026
  var TRIGGER_KEYWORDS = [
46377
47027
  { pattern: /\bmotion\b|\bdetect/i, trigger: "mqtt", event: "motion.detected" },
46378
47028
  { pattern: /\bdoor\b|\bcontact\b|\bopen.*sensor/i, trigger: "mqtt", event: "contact.opened" },
@@ -46400,8 +47050,12 @@ function inferSchedule(intent, warnings) {
46400
47050
  return "0 8 * * *";
46401
47051
  }
46402
47052
  function inferCommand(intent, warnings) {
46403
- for (const k2 of COMMAND_KEYWORDS) {
46404
- if (k2.pattern.test(intent)) return k2.command;
47053
+ const command = inferCommandFromIntent(intent);
47054
+ if (command) return command;
47055
+ if (containsCjk(intent)) {
47056
+ throw new UsageError(
47057
+ `Intent "${intent}" contains non-English command text that this heuristic cannot safely infer. Use explicit English command words (turnOn/turnOff/open/close/lock/unlock/press/pause) or edit the generated rule manually.`
47058
+ );
46405
47059
  }
46406
47060
  warnings.push(
46407
47061
  `Could not infer command from intent "${intent}" \u2014 defaulted to "turnOn". Edit the generated rule to set the correct command.`
@@ -46410,6 +47064,7 @@ function inferCommand(intent, warnings) {
46410
47064
  }
46411
47065
  function suggestRule(opts) {
46412
47066
  const warnings = [];
47067
+ const cjkIntent = containsCjk(opts.intent);
46413
47068
  let triggerSource = opts.trigger;
46414
47069
  let inferredEvent;
46415
47070
  if (!triggerSource) {
@@ -46417,6 +47072,11 @@ function suggestRule(opts) {
46417
47072
  triggerSource = inferred.trigger;
46418
47073
  inferredEvent = inferred.event;
46419
47074
  if (inferredEvent === "device.shadow") {
47075
+ if (cjkIntent) {
47076
+ throw new UsageError(
47077
+ `Intent "${opts.intent}" contains non-English trigger text that this heuristic cannot safely infer. Re-run with --trigger and, for mqtt rules, --event explicitly.`
47078
+ );
47079
+ }
46420
47080
  warnings.push(
46421
47081
  `Could not infer trigger type from intent "${opts.intent}" \u2014 defaulted to mqtt/device.shadow. Set --trigger and --event explicitly.`
46422
47082
  );
@@ -46432,6 +47092,11 @@ function suggestRule(opts) {
46432
47092
  }
46433
47093
  when = mqttTrigger;
46434
47094
  } else if (triggerSource === "cron") {
47095
+ if (cjkIntent && !opts.schedule) {
47096
+ throw new UsageError(
47097
+ `Intent "${opts.intent}" contains non-English scheduling text that this heuristic cannot safely infer. Re-run with --schedule "<cron>" explicitly.`
47098
+ );
47099
+ }
46435
47100
  const schedule = opts.schedule ?? inferSchedule(opts.intent, warnings);
46436
47101
  const cronTrigger = { source: "cron", schedule };
46437
47102
  if (opts.days && opts.days.length > 0) cronTrigger.days = opts.days;
@@ -46441,10 +47106,7 @@ function suggestRule(opts) {
46441
47106
  }
46442
47107
  const command = inferCommand(opts.intent, warnings);
46443
47108
  const actionDevices = triggerSource === "mqtt" && opts.devices && opts.devices.length > 1 ? opts.devices.slice(1) : opts.devices ?? [];
46444
- const then = actionDevices.length > 0 ? actionDevices.map((d) => ({
46445
- command: `devices command <id> ${command}`,
46446
- device: d.id
46447
- })) : [{ command: `devices command <id> ${command}` }];
47109
+ const then = actionDevices.length > 0 ? actionDevices.map((d) => buildSuggestedAction(command, d.id)) : [buildSuggestedAction(command)];
46448
47110
  const rule = {
46449
47111
  name: opts.intent,
46450
47112
  when,
@@ -47514,14 +48176,17 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
47514
48176
  "policy_validate",
47515
48177
  {
47516
48178
  title: "Validate a policy.yaml file",
47517
- description: "Check a policy file against the embedded JSON Schema (supports v0.1 and v0.2). Returns the validation result with per-error line/col and a hint. 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.",
48179
+ 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.",
47518
48180
  _meta: { agentSafetyTier: "read" },
47519
48181
  inputSchema: external_exports.object({
47520
- path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path")
48182
+ path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
48183
+ live: external_exports.boolean().optional().describe("When true, also resolve aliases and rule targets against the current account inventory")
47521
48184
  }).strict(),
47522
48185
  outputSchema: {
47523
48186
  policyPath: external_exports.string(),
47524
48187
  schemaVersion: external_exports.string(),
48188
+ validationScope: external_exports.string(),
48189
+ limitations: external_exports.array(external_exports.string()),
47525
48190
  present: external_exports.boolean().describe("false when the file does not exist"),
47526
48191
  valid: external_exports.boolean().nullable().describe("null when present=false"),
47527
48192
  errors: external_exports.array(external_exports.object({
@@ -47535,14 +48200,25 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
47535
48200
  })).describe("Empty when valid or when the file is missing")
47536
48201
  }
47537
48202
  },
47538
- async ({ path: pathArg }) => {
48203
+ async ({ path: pathArg, live }) => {
47539
48204
  const policyPath = resolvePolicyPath({ flag: pathArg });
47540
48205
  try {
47541
48206
  const loaded = loadPolicyFile(policyPath);
47542
- const result = validateLoadedPolicy(loaded);
48207
+ let result = validateLoadedPolicy(loaded);
48208
+ if (live) {
48209
+ if (!tryLoadConfig()) {
48210
+ return mcpError("runtime", 151, "policy_validate live=true requires configured SwitchBot credentials.", {
48211
+ hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
48212
+ });
48213
+ }
48214
+ const inventory = await fetchDeviceList(void 0, { bypassCache: true });
48215
+ result = validateLoadedPolicyAgainstInventory(loaded, inventory);
48216
+ }
47543
48217
  const structured = {
47544
48218
  policyPath: result.policyPath,
47545
48219
  schemaVersion: result.schemaVersion,
48220
+ validationScope: result.validationScope,
48221
+ limitations: result.limitations,
47546
48222
  present: true,
47547
48223
  valid: result.valid,
47548
48224
  errors: result.errors
@@ -47556,6 +48232,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
47556
48232
  const structured = {
47557
48233
  policyPath,
47558
48234
  schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
48235
+ validationScope: "schema+offline-semantics",
48236
+ limitations: [
48237
+ "Does not resolve aliases against the live device inventory.",
48238
+ "Does not verify commands against the real target device, live capabilities, or current firmware."
48239
+ ],
47559
48240
  present: false,
47560
48241
  valid: null,
47561
48242
  errors: []
@@ -47569,6 +48250,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
47569
48250
  const structured = {
47570
48251
  policyPath,
47571
48252
  schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
48253
+ validationScope: "schema+offline-semantics",
48254
+ limitations: [
48255
+ "Does not resolve aliases against the live device inventory.",
48256
+ "Does not verify commands against the real target device, live capabilities, or current firmware."
48257
+ ],
47572
48258
  present: true,
47573
48259
  valid: false,
47574
48260
  errors: err.yamlErrors.map((e) => ({
@@ -47634,7 +48320,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
47634
48320
  "policy_migrate",
47635
48321
  {
47636
48322
  title: "Migrate a policy file to the latest supported schema",
47637
- description: `Upgrades the policy file's schema version in place 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. Currently the only supported upgrade path is v0.1 \u2192 v0.2.`,
48323
+ 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.',
47638
48324
  _meta: { agentSafetyTier: "action" },
47639
48325
  inputSchema: external_exports.object({
47640
48326
  path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
@@ -47704,10 +48390,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
47704
48390
  };
47705
48391
  }
47706
48392
  if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
48393
+ const isLegacy = fileVersion === "0.1";
47707
48394
  const structured2 = {
47708
48395
  ...base,
47709
48396
  status: "unsupported",
47710
- message: `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(", ")})`
48397
+ 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(", ")})`
47711
48398
  };
47712
48399
  return {
47713
48400
  content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
@@ -48060,7 +48747,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
48060
48747
  kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
48061
48748
  device_id: external_exports.string().optional().describe("Filter by deviceId."),
48062
48749
  rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
48063
- results: external_exports.array(external_exports.enum(["ok", "error"])).optional().describe("Filter by execution result."),
48750
+ results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
48064
48751
  limit: external_exports.number().int().min(1).max(5e3).optional().describe("Max entries returned from the tail of the filtered set (default 200).")
48065
48752
  }).strict(),
48066
48753
  outputSchema: {
@@ -48113,7 +48800,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
48113
48800
  kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
48114
48801
  device_id: external_exports.string().optional().describe("Filter by deviceId."),
48115
48802
  rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
48116
- results: external_exports.array(external_exports.enum(["ok", "error"])).optional().describe("Filter by execution result."),
48803
+ results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
48117
48804
  top_n: external_exports.number().int().min(1).max(100).optional().describe("Number of top device/rule rows to return (default 10).")
48118
48805
  }).strict(),
48119
48806
  outputSchema: {
@@ -48261,6 +48948,29 @@ function listRegisteredTools(server) {
48261
48948
  if (!internal._registeredTools) return [];
48262
48949
  return Object.keys(internal._registeredTools).sort();
48263
48950
  }
48951
+ function listRegisteredResources() {
48952
+ return ["switchbot://events"];
48953
+ }
48954
+ function printMcpToolDirectory() {
48955
+ const server = createSwitchBotMcpServer();
48956
+ const tools = listRegisteredTools(server).map((name) => ({ name }));
48957
+ const resources = listRegisteredResources().map((uri) => ({ uri }));
48958
+ if (isJsonMode()) {
48959
+ printJson({ tools, resources });
48960
+ return;
48961
+ }
48962
+ console.log("Tools:");
48963
+ for (const tool of tools) {
48964
+ console.log(` ${tool.name}`);
48965
+ }
48966
+ console.log("");
48967
+ console.log("Resources:");
48968
+ for (const resource of resources) {
48969
+ console.log(` ${resource.uri}`);
48970
+ }
48971
+ console.log(`
48972
+ Total: ${tools.length} tool(s), ${resources.length} resource(s)`);
48973
+ }
48264
48974
  function registerMcpCommand(program3) {
48265
48975
  const mcp = program3.command("mcp").description("Run as a Model Context Protocol server so AI agents can call SwitchBot tools").addHelpText("after", `
48266
48976
  The MCP server exposes twenty-one tools:
@@ -48275,9 +48985,10 @@ function registerMcpCommand(program3) {
48275
48985
  - get_device_history fetch raw JSONL history records for a device
48276
48986
  - query_device_history filter + page history records with field/time predicates
48277
48987
  - aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records
48278
- - policy_validate check policy.yaml against the embedded schema (v0.1 / v0.2)
48988
+ - policy_validate check policy.yaml against the embedded schema + offline semantics
48989
+ (set live=true to resolve aliases and rule targets against current inventory)
48279
48990
  - policy_new scaffold a starter policy.yaml (action \u2014 confirm first)
48280
- - policy_migrate upgrade policy.yaml to the latest schema (action \u2014 preserves comments)
48991
+ - policy_migrate rewrite policy.yaml between currently supported schemas (action \u2014 preserves comments)
48281
48992
  - policy_diff compare two policy files with structural + line diff output
48282
48993
  - plan_suggest draft a Plan JSON from intent + device IDs (heuristic, no LLM)
48283
48994
  - plan_run validate + execute a Plan JSON document
@@ -48309,6 +49020,8 @@ Example Claude Desktop config (~/Library/Application Support/Claude/claude_deskt
48309
49020
  Inspect locally:
48310
49021
  $ npx @modelcontextprotocol/inspector switchbot mcp serve
48311
49022
  `);
49023
+ mcp.command("tools").description("Print the registered MCP tools in human or JSON form").action(() => printMcpToolDirectory());
49024
+ mcp.command("list-tools").description("Alias of `mcp tools`").action(() => printMcpToolDirectory());
48312
49025
  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", `
48313
49026
  Examples:
48314
49027
  $ switchbot mcp serve
@@ -48550,6 +49263,42 @@ process_uptime_seconds ${Math.floor(process.uptime())}
48550
49263
  init_cjs_shim();
48551
49264
  init_output();
48552
49265
  init_quota();
49266
+ function runQuotaStatus() {
49267
+ const usage = todayUsage();
49268
+ const history = loadQuota();
49269
+ if (isJsonMode()) {
49270
+ printJson({
49271
+ today: {
49272
+ date: usage.date,
49273
+ total: usage.total,
49274
+ remaining: usage.remaining,
49275
+ dailyLimit: DAILY_QUOTA,
49276
+ endpoints: usage.endpoints
49277
+ },
49278
+ history: history.days
49279
+ });
49280
+ return;
49281
+ }
49282
+ console.log(`Today (${usage.date}):`);
49283
+ console.log(` Requests used: ${usage.total} / ${DAILY_QUOTA}`);
49284
+ console.log(` Remaining budget: ${usage.remaining}`);
49285
+ if (Object.keys(usage.endpoints).length === 0) {
49286
+ console.log(" (no requests recorded yet)");
49287
+ } else {
49288
+ console.log(" Endpoint breakdown:");
49289
+ const entries = Object.entries(usage.endpoints).sort((a, b2) => b2[1] - a[1]);
49290
+ for (const [endpoint, count] of entries) {
49291
+ console.log(` ${endpoint.padEnd(48)} ${count}`);
49292
+ }
49293
+ }
49294
+ const otherDays = Object.entries(history.days).filter(([d]) => d !== usage.date).sort((a, b2) => b2[0].localeCompare(a[0]));
49295
+ if (otherDays.length > 0) {
49296
+ console.log("\nRecent history:");
49297
+ for (const [date5, bucket] of otherDays) {
49298
+ console.log(` ${date5} ${bucket.total}`);
49299
+ }
49300
+ }
49301
+ }
48553
49302
  function registerQuotaCommand(program3) {
48554
49303
  const quota = program3.command("quota").description("Inspect and manage the local SwitchBot API request counter").addHelpText("after", `
48555
49304
  Every request the CLI makes is counted locally in ~/.switchbot/quota.json.
@@ -48567,41 +49316,11 @@ Examples:
48567
49316
  $ switchbot quota status --json
48568
49317
  $ switchbot quota reset
48569
49318
  `);
49319
+ quota.action(() => {
49320
+ runQuotaStatus();
49321
+ });
48570
49322
  quota.command("status").alias("show").description("Show today's usage and the last 7 days (alias: show)").action(() => {
48571
- const usage = todayUsage();
48572
- const history = loadQuota();
48573
- if (isJsonMode()) {
48574
- printJson({
48575
- today: {
48576
- date: usage.date,
48577
- total: usage.total,
48578
- remaining: usage.remaining,
48579
- dailyLimit: DAILY_QUOTA,
48580
- endpoints: usage.endpoints
48581
- },
48582
- history: history.days
48583
- });
48584
- return;
48585
- }
48586
- console.log(`Today (${usage.date}):`);
48587
- console.log(` Requests used: ${usage.total} / ${DAILY_QUOTA}`);
48588
- console.log(` Remaining budget: ${usage.remaining}`);
48589
- if (Object.keys(usage.endpoints).length === 0) {
48590
- console.log(" (no requests recorded yet)");
48591
- } else {
48592
- console.log(" Endpoint breakdown:");
48593
- const entries = Object.entries(usage.endpoints).sort((a, b2) => b2[1] - a[1]);
48594
- for (const [endpoint, count] of entries) {
48595
- console.log(` ${endpoint.padEnd(48)} ${count}`);
48596
- }
48597
- }
48598
- const otherDays = Object.entries(history.days).filter(([d]) => d !== usage.date).sort((a, b2) => b2[0].localeCompare(a[0]));
48599
- if (otherDays.length > 0) {
48600
- console.log("\nRecent history:");
48601
- for (const [date5, bucket] of otherDays) {
48602
- console.log(` ${date5} ${bucket.total}`);
48603
- }
48604
- }
49323
+ runQuotaStatus();
48605
49324
  });
48606
49325
  quota.command("reset").description("Delete the local quota counter file").action(() => {
48607
49326
  resetQuota();
@@ -49306,6 +50025,13 @@ function extractDeviceId(parsed) {
49306
50025
  if (typeof id === "string" && id.length > 0) return id;
49307
50026
  return null;
49308
50027
  }
50028
+ function emitJsonStreamRecord(record2) {
50029
+ const { schemaVersion, ...rest } = record2;
50030
+ printJson({
50031
+ payloadVersion: schemaVersion,
50032
+ ...rest
50033
+ });
50034
+ }
49309
50035
  function matchFilterDetail(body, clauses) {
49310
50036
  if (!clauses || clauses.length === 0) return { matched: true, matchedKeys: [] };
49311
50037
  if (!body || typeof body !== "object") return { matched: false, matchedKeys: [] };
@@ -49461,7 +50187,7 @@ Examples:
49461
50187
  if (!ev.matched) return;
49462
50188
  matchedCount++;
49463
50189
  if (isJsonMode()) {
49464
- printJson(ev);
50190
+ emitJsonStreamRecord(ev);
49465
50191
  } else {
49466
50192
  const when = new Date(ev.t).toLocaleTimeString();
49467
50193
  console.log(`[${when}] ${ev.remote} ${ev.path} ${JSON.stringify(ev.body)}`);
@@ -49608,7 +50334,7 @@ Examples:
49608
50334
  if (isJsonMode()) emitStreamHeader({ eventKind: "event", cadence: "push" });
49609
50335
  if (isJsonMode()) {
49610
50336
  const sessionStartAt = (/* @__PURE__ */ new Date()).toISOString();
49611
- printJson({
50337
+ emitJsonStreamRecord({
49612
50338
  schemaVersion: EVENTS_SCHEMA_VERSION,
49613
50339
  source: "mqtt",
49614
50340
  kind: "control",
@@ -49660,7 +50386,7 @@ Examples:
49660
50386
  payload: parsed
49661
50387
  };
49662
50388
  if (isJsonMode()) {
49663
- printJson(record2);
50389
+ emitJsonStreamRecord(record2);
49664
50390
  } else {
49665
50391
  console.log(JSON.stringify(record2));
49666
50392
  }
@@ -49692,7 +50418,7 @@ Examples:
49692
50418
  at
49693
50419
  };
49694
50420
  if (isJsonMode()) {
49695
- printJson(ctl);
50421
+ emitJsonStreamRecord(ctl);
49696
50422
  } else {
49697
50423
  console.log(JSON.stringify(ctl));
49698
50424
  }
@@ -49959,6 +50685,33 @@ Examples:
49959
50685
 
49960
50686
  // src/commands/doctor.ts
49961
50687
  init_catalog();
50688
+
50689
+ // src/version-notes.ts
50690
+ init_cjs_shim();
50691
+ var RELEASE_METADATA = [];
50692
+ function semverParts(v2) {
50693
+ const [maj, min, pat] = v2.replace(/-.*$/, "").split(".").map((n) => Number.parseInt(n, 10));
50694
+ return [maj ?? 0, min ?? 0, pat ?? 0];
50695
+ }
50696
+ function semverCompare(a, b2) {
50697
+ const [aMaj, aMin, aPat] = semverParts(a);
50698
+ const [bMaj, bMin, bPat] = semverParts(b2);
50699
+ if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1;
50700
+ if (aMin !== bMin) return aMin < bMin ? -1 : 1;
50701
+ if (aPat !== bPat) return aPat < bPat ? -1 : 1;
50702
+ const aPre = a.includes("-");
50703
+ const bPre = b2.includes("-");
50704
+ if (aPre === bPre) return 0;
50705
+ return aPre ? -1 : 1;
50706
+ }
50707
+ function findBreakingChangeBetween(current, latest) {
50708
+ return RELEASE_METADATA.filter((m2) => m2.breaking && semverCompare(m2.version, current) > 0 && semverCompare(m2.version, latest) <= 0).sort((a, b2) => semverCompare(a.version, b2.version))[0] ?? null;
50709
+ }
50710
+ function getReleaseMetadata(version2) {
50711
+ return RELEASE_METADATA.find((m2) => m2.version === version2) ?? null;
50712
+ }
50713
+
50714
+ // src/commands/doctor.ts
49962
50715
  init_keychain();
49963
50716
  init_request_context();
49964
50717
 
@@ -50344,6 +51097,42 @@ function checkCatalogSchema() {
50344
51097
  }
50345
51098
  };
50346
51099
  }
51100
+ function checkInventoryConsistency() {
51101
+ const cache2 = loadCache();
51102
+ if (!cache2) {
51103
+ return {
51104
+ name: "inventory",
51105
+ status: "ok",
51106
+ detail: "no local inventory cache \u2014 run 'switchbot devices list' to enable hub-reference checks"
51107
+ };
51108
+ }
51109
+ const dangling = Object.entries(cache2.devices).filter(([, device]) => device.category === "physical").filter(([deviceId, device]) => {
51110
+ const hubDeviceId = device.hubDeviceId;
51111
+ return Boolean(
51112
+ hubDeviceId && hubDeviceId !== "000000000000" && hubDeviceId !== deviceId && !cache2.devices[hubDeviceId]
51113
+ );
51114
+ }).map(([deviceId, device]) => ({
51115
+ deviceId,
51116
+ deviceName: device.name,
51117
+ hubDeviceId: device.hubDeviceId,
51118
+ deviceType: device.type
51119
+ }));
51120
+ if (dangling.length === 0) {
51121
+ return {
51122
+ name: "inventory",
51123
+ status: "ok",
51124
+ detail: `inventory graph consistent across ${Object.keys(cache2.devices).length} cached devices`
51125
+ };
51126
+ }
51127
+ return {
51128
+ name: "inventory",
51129
+ status: "warn",
51130
+ detail: {
51131
+ message: `${dangling.length} device(s) reference a hubDeviceId that is not present in the current inventory`,
51132
+ dangling: dangling.slice(0, 10)
51133
+ }
51134
+ };
51135
+ }
50347
51136
  function checkAudit() {
50348
51137
  const p2 = path15.join(os16.homedir(), ".switchbot", "audit.log");
50349
51138
  if (!fs19.existsSync(p2)) {
@@ -50782,6 +51571,26 @@ function checkMcp() {
50782
51571
  };
50783
51572
  }
50784
51573
  }
51574
+ function checkReleaseNotes() {
51575
+ const meta4 = getReleaseMetadata(VERSION);
51576
+ if (!meta4 || !meta4.breaking) {
51577
+ return {
51578
+ name: "release-notes",
51579
+ status: "ok",
51580
+ detail: { version: VERSION, message: "no known breaking-change notice for the current release" }
51581
+ };
51582
+ }
51583
+ return {
51584
+ name: "release-notes",
51585
+ status: "warn",
51586
+ detail: {
51587
+ version: VERSION,
51588
+ breaking: true,
51589
+ message: meta4.summary,
51590
+ hint: "If you have scripts pinned to 3.2.x JSON output, update them before rolling this release wider."
51591
+ }
51592
+ };
51593
+ }
50785
51594
  var CHECK_REGISTRY = [
50786
51595
  { name: "node", description: "Node.js version compatibility", run: () => checkNodeVersion() },
50787
51596
  { name: "path", description: "switchbot binary reachable on PATH", run: () => checkPathDiscoverability() },
@@ -50790,6 +51599,7 @@ var CHECK_REGISTRY = [
50790
51599
  { name: "profiles", description: "profile definitions valid", run: () => checkProfiles() },
50791
51600
  { name: "catalog", description: "catalog loads", run: () => checkCatalog() },
50792
51601
  { name: "catalog-schema", description: "catalog vs agent-bootstrap version aligned", run: () => checkCatalogSchema() },
51602
+ { name: "inventory", description: "cached inventory graph consistency (hubDeviceId references)", run: () => checkInventoryConsistency() },
50793
51603
  { name: "cache", description: "device cache state", run: () => checkCache() },
50794
51604
  { name: "quota", description: "API quota headroom", run: () => checkQuotaFile() },
50795
51605
  { name: "clock", description: "system clock skew", run: () => checkClockSkew() },
@@ -50799,6 +51609,7 @@ var CHECK_REGISTRY = [
50799
51609
  run: ({ probe }) => probe ? checkMqttProbe() : checkMqtt()
50800
51610
  },
50801
51611
  { name: "mcp", description: "MCP server instantiable + tool count", run: () => checkMcp() },
51612
+ { name: "release-notes", description: "current release breaking-change notice", run: () => checkReleaseNotes() },
50802
51613
  { name: "policy", description: "policy.yaml present + schema-valid (if configured)", run: () => checkPolicy() },
50803
51614
  { name: "audit", description: "recent command errors (last 24h)", run: () => checkAudit() },
50804
51615
  { name: "daemon", description: "daemon state file + runtime status", run: () => checkDaemon() },
@@ -51003,88 +51814,50 @@ function projectFields2(entry, fields) {
51003
51814
  }
51004
51815
  return out;
51005
51816
  }
51006
- function registerSchemaCommand(program3) {
51007
- const ROLES = ["lighting", "security", "sensor", "climate", "media", "cleaning", "curtain", "fan", "power", "hub", "other"];
51008
- const CATEGORIES = ["physical", "ir"];
51009
- const schema2 = program3.command("schema").description("Export the SwitchBot device catalog as structured JSON (for AI agent prompts / tooling)");
51010
- schema2.command("export").description("Print the catalog as structured JSON (one object per type)").option("--type <type>", 'Restrict to a single device type (e.g. "Strip Light")', stringArg("--type")).option("--types <csv>", "Restrict to multiple device types (comma-separated)", stringArg("--types")).option("--role <role>", "Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other", enumArg("--role", ROLES)).option("--category <cat>", 'Restrict to "physical" or "ir"', enumArg("--category", CATEGORIES)).option("--compact", "Drop descriptions/aliases/example params \u2014 emit ~60% smaller payload. Useful for agent prompts.").option("--used", 'Restrict to device types present in the local devices cache (run "devices list" first)').option("--project <csv>", "Project per-type fields (e.g. --project type,commands,statusFields)", stringArg("--project")).option("--capabilities", "Annotate each device type with CLI command safety metadata (agentSafetyTier, mutating, consumesQuota)").addHelpText("after", `
51011
- Output is always JSON (this command ignores --format). The output is a
51012
- catalog export \u2014 not a formal JSON Schema standard document \u2014 suitable for
51013
- pre-baking LLM prompts or regenerating docs when the catalog changes.
51014
-
51015
- Size tips:
51016
- --compact --used Smallest realistic payload for a given account
51017
- (< 15 KB on most accounts).
51018
- --fields type,commands Strip statusFields / role / etc. when only
51019
- commands are needed.
51020
- --type + --compact Inspect one type with minimum footprint.
51021
-
51022
- Common top-level fields:
51023
- schemaVersion CLI schema version (stable for agent contracts)
51024
- data.version Catalog schema version
51025
- data.types Array of SchemaEntry (or CompactSchemaEntry with --compact)
51026
- data._fetchedAt CLI-added; present on live-query responses ('devices status'),
51027
- not on this offline export.
51028
-
51029
- Examples:
51030
- $ switchbot schema export > catalog.json
51031
- $ switchbot schema export --compact --used | wc -c # small prompt-ready payload
51032
- $ switchbot schema export --type Bot | jq '.data.types[0].commands'
51033
- $ switchbot schema export --types "Bot,Curtain,Color Bulb"
51034
- $ switchbot schema export --role lighting | jq '[.data.types[].type]'
51035
- $ switchbot schema export --role security --category physical
51036
- $ switchbot schema export --project type,commands,statusFields
51037
- `).action(async (options) => {
51038
- const catalog = getEffectiveCatalog();
51039
- let filtered = catalog;
51040
- if (options.type) {
51041
- const q = options.type.toLowerCase();
51042
- filtered = filtered.filter(
51043
- (e) => e.type.toLowerCase() === q || (e.aliases ?? []).some((a) => a.toLowerCase() === q)
51817
+ function runSchemaExport(options) {
51818
+ const catalog = getEffectiveCatalog();
51819
+ let filtered = catalog;
51820
+ if (options.type) {
51821
+ const q = options.type.toLowerCase();
51822
+ filtered = filtered.filter(
51823
+ (e) => e.type.toLowerCase() === q || (e.aliases ?? []).some((a) => a.toLowerCase() === q)
51824
+ );
51825
+ }
51826
+ if (options.types) {
51827
+ const set3 = new Set(options.types.split(",").map((s2) => s2.trim().toLowerCase()).filter(Boolean));
51828
+ filtered = filtered.filter(
51829
+ (e) => set3.has(e.type.toLowerCase()) || (e.aliases ?? []).some((a) => set3.has(a.toLowerCase()))
51830
+ );
51831
+ }
51832
+ if (options.role) {
51833
+ const q = options.role.toLowerCase();
51834
+ filtered = filtered.filter((e) => (e.role ?? "other") === q);
51835
+ }
51836
+ if (options.category) {
51837
+ const q = options.category.toLowerCase();
51838
+ filtered = filtered.filter((e) => e.category === q);
51839
+ }
51840
+ if (options.used) {
51841
+ const cache2 = loadCache();
51842
+ if (cache2) {
51843
+ const usedTypes = new Set(
51844
+ Object.values(cache2.devices).map((d) => d.type.toLowerCase())
51044
51845
  );
51045
- }
51046
- if (options.types) {
51047
- const set3 = new Set(options.types.split(",").map((s2) => s2.trim().toLowerCase()).filter(Boolean));
51048
51846
  filtered = filtered.filter(
51049
- (e) => set3.has(e.type.toLowerCase()) || (e.aliases ?? []).some((a) => set3.has(a.toLowerCase()))
51847
+ (e) => usedTypes.has(e.type.toLowerCase()) || (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase()))
51050
51848
  );
51849
+ } else {
51850
+ filtered = [];
51051
51851
  }
51052
- if (options.role) {
51053
- const q = options.role.toLowerCase();
51054
- filtered = filtered.filter((e) => (e.role ?? "other") === q);
51055
- }
51056
- if (options.category) {
51057
- const q = options.category.toLowerCase();
51058
- filtered = filtered.filter((e) => e.category === q);
51059
- }
51060
- if (options.used) {
51061
- const cache2 = loadCache();
51062
- if (cache2) {
51063
- const usedTypes = new Set(
51064
- Object.values(cache2.devices).map((d) => d.type.toLowerCase())
51065
- );
51066
- filtered = filtered.filter(
51067
- (e) => usedTypes.has(e.type.toLowerCase()) || (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase()))
51068
- );
51069
- } else {
51070
- filtered = [];
51071
- }
51072
- }
51073
- const mapped = options.compact ? filtered.map(toCompactEntry) : filtered.map(toSchemaEntry);
51074
- const projected = options.project ? mapped.map(
51075
- (e) => projectFields2(e, options.project.split(",").map((s2) => s2.trim()).filter(Boolean))
51076
- ) : mapped;
51077
- let finalTypes = projected;
51078
- if (options.capabilities) {
51079
- const { COMMAND_META: COMMAND_META2 } = await Promise.resolve().then(() => (init_capabilities(), capabilities_exports));
51080
- const devicesMeta = Object.fromEntries(
51081
- Object.entries(COMMAND_META2).filter(([k2]) => k2.startsWith("devices "))
51082
- );
51083
- finalTypes = finalTypes.map((e) => ({ ...e, commandsMeta: devicesMeta }));
51084
- }
51852
+ }
51853
+ const mapped = options.compact ? filtered.map(toCompactEntry) : filtered.map(toSchemaEntry);
51854
+ const projected = options.project ? mapped.map(
51855
+ (e) => projectFields2(e, options.project.split(",").map((s2) => s2.trim()).filter(Boolean))
51856
+ ) : mapped;
51857
+ const finish = (finalTypes2) => {
51085
51858
  const payload = {
51086
51859
  version: "1.0",
51087
- types: finalTypes
51860
+ types: finalTypes2
51088
51861
  };
51089
51862
  if (!options.compact) {
51090
51863
  payload.generatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -51117,6 +51890,56 @@ Examples:
51117
51890
  ];
51118
51891
  }
51119
51892
  printJson(payload);
51893
+ };
51894
+ const finalTypes = projected;
51895
+ if (options.capabilities) {
51896
+ return Promise.resolve().then(() => (init_capabilities(), capabilities_exports)).then(({ COMMAND_META: COMMAND_META2 }) => {
51897
+ const devicesMeta = Object.fromEntries(
51898
+ Object.entries(COMMAND_META2).filter(([k2]) => k2.startsWith("devices "))
51899
+ );
51900
+ finish(finalTypes.map((e) => ({ ...e, commandsMeta: devicesMeta })));
51901
+ });
51902
+ }
51903
+ finish(finalTypes);
51904
+ }
51905
+ function registerSchemaCommand(program3) {
51906
+ const ROLES = ["lighting", "security", "sensor", "climate", "media", "cleaning", "curtain", "fan", "power", "hub", "other"];
51907
+ const CATEGORIES = ["physical", "ir"];
51908
+ const schema2 = program3.command("schema").description("Export the SwitchBot device catalog as structured JSON (for AI agent prompts / tooling)").option("--type <type>", 'Restrict to a single device type (e.g. "Strip Light")', stringArg("--type")).option("--types <csv>", "Restrict to multiple device types (comma-separated)", stringArg("--types")).option("--role <role>", "Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other", enumArg("--role", ROLES)).option("--category <cat>", 'Restrict to "physical" or "ir"', enumArg("--category", CATEGORIES)).option("--compact", "Drop descriptions/aliases/example params \u2014 emit ~60% smaller payload. Useful for agent prompts.").option("--used", 'Restrict to device types present in the local devices cache (run "devices list" first)').option("--project <csv>", "Project per-type fields (e.g. --project type,commands,statusFields)", stringArg("--project")).option("--capabilities", "Annotate each device type with CLI command safety metadata (agentSafetyTier, mutating, consumesQuota)");
51909
+ schema2.action(async (options) => {
51910
+ await runSchemaExport(options);
51911
+ });
51912
+ schema2.command("export").description("Print the catalog as structured JSON (one object per type)").addHelpText("after", `
51913
+ Output is always JSON (this command ignores --format). The output is a
51914
+ catalog export \u2014 not a formal JSON Schema standard document \u2014 suitable for
51915
+ pre-baking LLM prompts or regenerating docs when the catalog changes.
51916
+ \`statusFields\` are advisory offline hints; actual live status can differ by
51917
+ firmware and device variant.
51918
+
51919
+ Size tips:
51920
+ --compact --used Smallest realistic payload for a given account
51921
+ (< 15 KB on most accounts).
51922
+ --fields type,commands Strip statusFields / role / etc. when only
51923
+ commands are needed.
51924
+ --type + --compact Inspect one type with minimum footprint.
51925
+
51926
+ Common top-level fields:
51927
+ schemaVersion CLI schema version (stable for agent contracts)
51928
+ data.version Catalog schema version
51929
+ data.types Array of SchemaEntry (or CompactSchemaEntry with --compact)
51930
+ data._fetchedAt CLI-added; present on live-query responses ('devices status'),
51931
+ not on this offline export.
51932
+
51933
+ Examples:
51934
+ $ switchbot schema export > catalog.json
51935
+ $ switchbot schema export --compact --used | wc -c # small prompt-ready payload
51936
+ $ switchbot schema export --type Bot | jq '.data.types[0].commands'
51937
+ $ switchbot schema export --types "Bot,Curtain,Color Bulb"
51938
+ $ switchbot schema export --role lighting | jq '[.data.types[].type]'
51939
+ $ switchbot schema export --role security --category physical
51940
+ $ switchbot schema export --project type,commands,statusFields
51941
+ `).action(async (_options, cmd) => {
51942
+ await runSchemaExport(cmd.optsWithGlobals());
51120
51943
  });
51121
51944
  }
51122
51945
 
@@ -51442,6 +52265,7 @@ function formatValidationResult(result, source, opts = {}) {
51442
52265
  }
51443
52266
 
51444
52267
  // src/commands/policy.ts
52268
+ init_config();
51445
52269
  var LATEST_SUPPORTED_VERSION2 = SUPPORTED_POLICY_SCHEMA_VERSIONS[SUPPORTED_POLICY_SCHEMA_VERSIONS.length - 1];
51446
52270
  function readEmbeddedTemplate() {
51447
52271
  return readPolicyExampleYaml();
@@ -51484,6 +52308,18 @@ function exitPolicyError(kind, message, extra = {}) {
51484
52308
  }
51485
52309
  process.exit(code);
51486
52310
  }
52311
+ function validationScopeLine(scope) {
52312
+ if (scope === "schema+offline-semantics+live-inventory") {
52313
+ return "Validation scope: schema + offline semantics + live inventory checks + local safety guards.";
52314
+ }
52315
+ return "Validation scope: schema + offline semantics + local safety guards.";
52316
+ }
52317
+ function validationNotCheckedLine(scope) {
52318
+ if (scope === "schema+offline-semantics+live-inventory") {
52319
+ return "Not checked: live capabilities, current firmware, and runtime-only device behavior.";
52320
+ }
52321
+ return "Not checked: alias targets against live devices; command support on the real target device, live capabilities, or current firmware.";
52322
+ }
51487
52323
  function summarizeChangeValue(v2) {
51488
52324
  if (v2 === null) return "null";
51489
52325
  if (v2 === void 0) return "undefined";
@@ -51502,10 +52338,11 @@ audit log path, and which actions always or never need confirmation.
51502
52338
  Default location: ${DEFAULT_POLICY_PATH}
51503
52339
 
51504
52340
  Subcommands:
51505
- validate [path] Check a policy file against the embedded schema
52341
+ validate [path] Check a policy file against the embedded schema + offline semantics
52342
+ (add --live to resolve aliases and rule targets against current inventory)
51506
52343
  new [path] Write a starter policy to the default location (or a given path)
51507
- migrate [path] Upgrade a policy file to the latest supported schema
51508
- (v${CURRENT_POLICY_SCHEMA_VERSION} \u2192 v${LATEST_SUPPORTED_VERSION2} today; no-op if already current)
52344
+ migrate [path] Rewrite a policy file between schema versions this CLI still supports
52345
+ (this build only supports v${CURRENT_POLICY_SCHEMA_VERSION}; legacy v0.1 files cannot be migrated here)
51509
52346
  diff <left> <right>
51510
52347
  Compare two policy files and print structural + line diff
51511
52348
  add-rule Append a rule YAML (from stdin) into automation.rules[]
@@ -51534,7 +52371,9 @@ Examples:
51534
52371
  $ switchbot policy diff ./policy.before.yaml ./policy.after.yaml
51535
52372
  `
51536
52373
  );
51537
- policy.command("validate [path]").description(`Validate a policy.yaml against the embedded v${CURRENT_POLICY_SCHEMA_VERSION} schema`).option("--no-color", "disable ANSI color in human output").option("--no-snippet", "omit the source-line + caret preview").action((pathArg, opts) => {
52374
+ policy.command("validate [path]").description(
52375
+ `Validate a policy.yaml against the embedded v${CURRENT_POLICY_SCHEMA_VERSION} schema, offline semantics, and local safety guards`
52376
+ ).option("--live", "Also resolve aliases and rule target devices against the current account inventory (1 API call)").option("--no-color", "disable ANSI color in human output").option("--no-snippet", "omit the source-line + caret preview").action(async (pathArg, opts) => {
51538
52377
  const policyPath = resolvePolicyPath({ flag: pathArg });
51539
52378
  let loaded;
51540
52379
  try {
@@ -51554,7 +52393,22 @@ Examples:
51554
52393
  }
51555
52394
  exitPolicyError("internal", `unexpected error loading policy: ${String(err)}`);
51556
52395
  }
51557
- const result = validateLoadedPolicy(loaded);
52396
+ let result = validateLoadedPolicy(loaded);
52397
+ if (opts.live) {
52398
+ if (!tryLoadConfig()) {
52399
+ exitWithError({
52400
+ code: 1,
52401
+ kind: "runtime",
52402
+ message: "policy validate --live requires configured SwitchBot credentials.",
52403
+ extra: {
52404
+ hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
52405
+ }
52406
+ });
52407
+ return;
52408
+ }
52409
+ const inventory = await fetchDeviceList(void 0, { bypassCache: true });
52410
+ result = validateLoadedPolicyAgainstInventory(loaded, inventory);
52411
+ }
51558
52412
  if (isJsonMode()) {
51559
52413
  printJson(result);
51560
52414
  process.exit(result.valid ? 0 : 1);
@@ -51565,10 +52419,13 @@ Examples:
51565
52419
  noSnippet: opts.snippet === false
51566
52420
  })
51567
52421
  );
52422
+ console.log("");
52423
+ console.log(validationScopeLine(result.validationScope));
52424
+ console.log(validationNotCheckedLine(result.validationScope));
51568
52425
  process.exit(result.valid ? 0 : 1);
51569
52426
  });
51570
- policy.command("new [path]").description("Write a starter policy.yaml (fails if the file exists unless --force)").option("-f, --force", "overwrite an existing policy file").action((pathArg, opts) => {
51571
- const policyPath = resolvePolicyPath({ flag: pathArg });
52427
+ policy.command("new [path]").description("Write a starter policy.yaml (fails if the file exists unless --force)").option("-o, --output <path>", "write the starter policy to this path (alias of positional [path])").option("-f, --force", "overwrite an existing policy file").action((pathArg, opts) => {
52428
+ const policyPath = resolvePolicyPath({ flag: opts.output ?? pathArg });
51572
52429
  const force = opts.force === true;
51573
52430
  let result;
51574
52431
  try {
@@ -51597,7 +52454,7 @@ Examples:
51597
52454
  console.log(` 2. run \`switchbot policy validate\``);
51598
52455
  }
51599
52456
  });
51600
- policy.command("migrate [path]").description(`Upgrade a policy file to the latest supported schema (currently v${LATEST_SUPPORTED_VERSION2})`).option("--dry-run", "show what would change without writing the file").option(
52457
+ policy.command("migrate [path]").description(`Rewrite a policy file between schema versions supported by this CLI (currently only v${LATEST_SUPPORTED_VERSION2})`).option("--dry-run", "show what would change without writing the file").option(
51601
52458
  "--to <version>",
51602
52459
  `target schema version (default: ${LATEST_SUPPORTED_VERSION2})`,
51603
52460
  LATEST_SUPPORTED_VERSION2
@@ -51637,8 +52494,9 @@ Examples:
51637
52494
  return;
51638
52495
  }
51639
52496
  if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
51640
- const message = `policy schema v${fileVersion} is not supported by this CLI (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(", ")})`;
51641
- const hint = "upgrade @switchbot/openapi-cli, or downgrade the policy file to a supported version";
52497
+ const isLegacy = fileVersion === "0.1";
52498
+ const message = isLegacy ? `policy schema v${fileVersion} is legacy and cannot be migrated by this CLI` : `policy schema v${fileVersion} is not supported by this CLI (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(", ")})`;
52499
+ const hint = isLegacy ? "use @switchbot/openapi-cli <=2.15 to migrate v0.1 first, then upgrade back to this release" : "upgrade @switchbot/openapi-cli, or downgrade the policy file to a supported version";
51642
52500
  if (isJsonMode())
51643
52501
  emitJsonError({ code: 6, kind: "unsupported-version", ...basePayload, message, hint });
51644
52502
  else {
@@ -52258,183 +53116,6 @@ var ThrottleGate = class {
52258
53116
  }
52259
53117
  };
52260
53118
 
52261
- // src/rules/action.ts
52262
- init_cjs_shim();
52263
- var DEVICES_COMMAND_RE = /^devices\s+command\s+(\S+)\s+(\S+)(?:\s+(.*))?$/;
52264
- function parseRuleCommand(cmd) {
52265
- const m2 = DEVICES_COMMAND_RE.exec(cmd.trim());
52266
- if (!m2) return null;
52267
- const deviceIdSlot = m2[1];
52268
- const verb = m2[2];
52269
- const rest = (m2[3] ?? "").trim();
52270
- return {
52271
- deviceIdSlot,
52272
- verb,
52273
- parameterTokens: rest.length === 0 ? [] : rest.split(/\s+/)
52274
- };
52275
- }
52276
- function resolveActionDevice(explicit, slot, aliases) {
52277
- const candidate = explicit ?? (slot && slot !== "<id>" ? slot : null);
52278
- if (!candidate) return null;
52279
- if (aliases[candidate]) return aliases[candidate];
52280
- return candidate;
52281
- }
52282
- function renderParameter(tokens) {
52283
- if (tokens.length === 0) return void 0;
52284
- if (tokens.length === 1) return tokens[0];
52285
- return tokens.join(":");
52286
- }
52287
- async function executeRuleAction(action, ctx) {
52288
- const parsed = parseRuleCommand(action.command);
52289
- if (!parsed) {
52290
- writeAudit({
52291
- t: (/* @__PURE__ */ new Date()).toISOString(),
52292
- kind: "rule-fire",
52293
- deviceId: "unknown",
52294
- command: action.command,
52295
- parameter: null,
52296
- commandType: "command",
52297
- dryRun: true,
52298
- result: "error",
52299
- error: "unparseable-command",
52300
- rule: {
52301
- name: ctx.rule.name,
52302
- triggerSource: ctx.rule.when.source,
52303
- fireId: ctx.fireId,
52304
- reason: "unparseable-command"
52305
- }
52306
- });
52307
- return { ok: false, error: "unparseable-command", blocked: true };
52308
- }
52309
- if (isDestructiveCommand2(action.command)) {
52310
- writeAudit({
52311
- t: (/* @__PURE__ */ new Date()).toISOString(),
52312
- kind: "rule-fire",
52313
- deviceId: resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases) ?? "unknown",
52314
- command: action.command,
52315
- parameter: null,
52316
- commandType: "command",
52317
- dryRun: true,
52318
- result: "error",
52319
- error: `destructive-verb:${parsed.verb}`,
52320
- rule: {
52321
- name: ctx.rule.name,
52322
- triggerSource: ctx.rule.when.source,
52323
- fireId: ctx.fireId,
52324
- reason: `destructive verb "${parsed.verb}" refused at runtime`
52325
- }
52326
- });
52327
- return { ok: false, error: `destructive-verb:${parsed.verb}`, blocked: true, verb: parsed.verb };
52328
- }
52329
- const deviceId = resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases);
52330
- if (!deviceId || deviceId === "<id>") {
52331
- writeAudit({
52332
- t: (/* @__PURE__ */ new Date()).toISOString(),
52333
- kind: "rule-fire",
52334
- deviceId: "unknown",
52335
- command: action.command,
52336
- parameter: null,
52337
- commandType: "command",
52338
- dryRun: true,
52339
- result: "error",
52340
- error: "missing-device",
52341
- rule: {
52342
- name: ctx.rule.name,
52343
- triggerSource: ctx.rule.when.source,
52344
- fireId: ctx.fireId,
52345
- reason: "action omitted `device` and command used `<id>` placeholder"
52346
- }
52347
- });
52348
- return { ok: false, error: "missing-device", verb: parsed.verb };
52349
- }
52350
- const dryRun = ctx.globalDryRun === true || ctx.rule.dry_run === true;
52351
- const parameter = renderParameter(parsed.parameterTokens);
52352
- if (dryRun) {
52353
- writeAudit({
52354
- t: (/* @__PURE__ */ new Date()).toISOString(),
52355
- kind: "rule-fire-dry",
52356
- deviceId,
52357
- command: parsed.verb,
52358
- parameter: parameter ?? "default",
52359
- commandType: "command",
52360
- dryRun: true,
52361
- result: "ok",
52362
- rule: {
52363
- name: ctx.rule.name,
52364
- triggerSource: ctx.rule.when.source,
52365
- matchedDevice: deviceId,
52366
- fireId: ctx.fireId
52367
- }
52368
- });
52369
- return { ok: true, dryRun: true, deviceId, verb: parsed.verb };
52370
- }
52371
- if (ctx.skipApiCall) {
52372
- writeAudit({
52373
- t: (/* @__PURE__ */ new Date()).toISOString(),
52374
- kind: "rule-fire",
52375
- deviceId,
52376
- command: parsed.verb,
52377
- parameter: parameter ?? "default",
52378
- commandType: "command",
52379
- dryRun: false,
52380
- result: "ok",
52381
- rule: {
52382
- name: ctx.rule.name,
52383
- triggerSource: ctx.rule.when.source,
52384
- matchedDevice: deviceId,
52385
- fireId: ctx.fireId,
52386
- reason: "api-skipped"
52387
- }
52388
- });
52389
- return { ok: true, deviceId, verb: parsed.verb };
52390
- }
52391
- try {
52392
- await executeCommand(deviceId, parsed.verb, parameter, "command", ctx.httpClient);
52393
- writeAudit({
52394
- t: (/* @__PURE__ */ new Date()).toISOString(),
52395
- kind: "rule-fire",
52396
- deviceId,
52397
- command: parsed.verb,
52398
- parameter: parameter ?? "default",
52399
- commandType: "command",
52400
- dryRun: false,
52401
- result: "ok",
52402
- rule: {
52403
- name: ctx.rule.name,
52404
- triggerSource: ctx.rule.when.source,
52405
- matchedDevice: deviceId,
52406
- fireId: ctx.fireId
52407
- }
52408
- });
52409
- return { ok: true, deviceId, verb: parsed.verb };
52410
- } catch (err) {
52411
- const msg = err instanceof Error ? err.message : String(err);
52412
- writeAudit({
52413
- t: (/* @__PURE__ */ new Date()).toISOString(),
52414
- kind: "rule-fire",
52415
- deviceId,
52416
- command: parsed.verb,
52417
- parameter: parameter ?? "default",
52418
- commandType: "command",
52419
- dryRun: false,
52420
- result: "error",
52421
- error: msg,
52422
- rule: {
52423
- name: ctx.rule.name,
52424
- triggerSource: ctx.rule.when.source,
52425
- matchedDevice: deviceId,
52426
- fireId: ctx.fireId
52427
- }
52428
- });
52429
- return { ok: false, error: msg, deviceId, verb: parsed.verb };
52430
- }
52431
- }
52432
- function extractDeviceIdFromAction(action) {
52433
- if (action.device) return action.device;
52434
- const m2 = /\bdevices\s+command\s+(\S+)/.exec(action.command ?? "");
52435
- return m2 ? m2[1] : null;
52436
- }
52437
-
52438
53119
  // src/rules/cron-scheduler.ts
52439
53120
  init_cjs_shim();
52440
53121
 
@@ -54874,30 +55555,34 @@ function registerSuggest(rules) {
54874
55555
  []
54875
55556
  ).option("--event <type>", "MQTT event name override (e.g. motion.detected)").option("--schedule <cron>", "5-field cron expression override").option("--days <days>", "Weekday filter, comma-separated (e.g. mon,tue,wed,thu,fri)").option("--webhook-path <path>", "Webhook path override (default: /action)").option("--out <file>", "Write YAML to file instead of stdout").action(
54876
55557
  (opts) => {
54877
- const trigger = opts.trigger;
54878
- const days = opts.days ? opts.days.split(",").map((d) => d.trim()) : void 0;
54879
- const devices = opts.device.map((ref) => {
54880
- const cached2 = getCachedDevice(ref);
54881
- return { id: ref, name: cached2?.name, type: cached2?.type };
54882
- });
54883
- const { rule, ruleYaml, warnings } = suggestRule({
54884
- intent: opts.intent,
54885
- trigger,
54886
- devices,
54887
- event: opts.event,
54888
- schedule: opts.schedule,
54889
- days,
54890
- webhookPath: opts.webhookPath
54891
- });
54892
- for (const w2 of warnings) process.stderr.write(`warning: ${w2}
55558
+ try {
55559
+ const trigger = opts.trigger;
55560
+ const days = opts.days ? opts.days.split(",").map((d) => d.trim()) : void 0;
55561
+ const devices = opts.device.map((ref) => {
55562
+ const cached2 = getCachedDevice(ref);
55563
+ return { id: ref, name: cached2?.name, type: cached2?.type };
55564
+ });
55565
+ const { rule, ruleYaml, warnings } = suggestRule({
55566
+ intent: opts.intent,
55567
+ trigger,
55568
+ devices,
55569
+ event: opts.event,
55570
+ schedule: opts.schedule,
55571
+ days,
55572
+ webhookPath: opts.webhookPath
55573
+ });
55574
+ for (const w2 of warnings) process.stderr.write(`warning: ${w2}
54893
55575
  `);
54894
- if (opts.out) {
54895
- fs21.writeFileSync(opts.out, ruleYaml, "utf8");
54896
- if (!isJsonMode()) console.log(`\u2713 rule YAML written to ${opts.out}`);
54897
- } else if (isJsonMode()) {
54898
- printJson({ rule, rule_yaml: ruleYaml, warnings });
54899
- } else {
54900
- process.stdout.write(ruleYaml);
55576
+ if (opts.out) {
55577
+ fs21.writeFileSync(opts.out, ruleYaml, "utf8");
55578
+ if (!isJsonMode()) console.log(`\u2713 rule YAML written to ${opts.out}`);
55579
+ } else if (isJsonMode()) {
55580
+ printJson({ rule, rule_yaml: ruleYaml, warnings });
55581
+ } else {
55582
+ process.stdout.write(ruleYaml);
55583
+ }
55584
+ } catch (err) {
55585
+ handleError(err);
54901
55586
  }
54902
55587
  }
54903
55588
  );
@@ -56297,13 +56982,105 @@ function resolveStatusSyncRuntime(options) {
56297
56982
  ].join("\n")
56298
56983
  );
56299
56984
  }
56985
+ const openclawUrl = options.openclawUrl ?? process.env.OPENCLAW_URL ?? DEFAULT_OPENCLAW_URL;
56986
+ let parsedUrl;
56987
+ try {
56988
+ parsedUrl = new URL(openclawUrl);
56989
+ } catch {
56990
+ throw new UsageError(
56991
+ [
56992
+ `OpenClaw URL is invalid: ${openclawUrl}`,
56993
+ "Provide a full http:// or https:// URL via one of:",
56994
+ " 1. --openclaw-url <url>",
56995
+ " 2. OPENCLAW_URL=<url> in the environment",
56996
+ "",
56997
+ "After fixing it, re-run the command and verify with `switchbot status-sync status`."
56998
+ ].join("\n")
56999
+ );
57000
+ }
57001
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
57002
+ throw new UsageError(
57003
+ [
57004
+ `OpenClaw URL must use http:// or https:// (received ${parsedUrl.protocol})`,
57005
+ "Provide a full http:// or https:// URL via one of:",
57006
+ " 1. --openclaw-url <url>",
57007
+ " 2. OPENCLAW_URL=<url> in the environment"
57008
+ ].join("\n")
57009
+ );
57010
+ }
56300
57011
  return {
56301
- openclawUrl: options.openclawUrl ?? process.env.OPENCLAW_URL ?? DEFAULT_OPENCLAW_URL,
57012
+ openclawUrl,
56302
57013
  openclawToken,
56303
57014
  openclawModel,
56304
57015
  ...options.topic ? { topic: options.topic } : {}
56305
57016
  };
56306
57017
  }
57018
+ async function probeStatusSyncStart(options = {}) {
57019
+ const runtime = resolveStatusSyncRuntime(options);
57020
+ const config2 = tryLoadConfig();
57021
+ if (!config2) {
57022
+ throw new UsageError(
57023
+ "No credentials found. Run 'switchbot config set-token' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
57024
+ );
57025
+ }
57026
+ const { fetchMqttCredential: fetchMqttCredential2 } = await Promise.resolve().then(() => (init_credential(), credential_exports));
57027
+ let mqttBrokerUrl = "";
57028
+ let mqttRegion = "";
57029
+ try {
57030
+ const cred = await fetchMqttCredential2(config2.token, config2.secret);
57031
+ mqttBrokerUrl = cred.brokerUrl;
57032
+ mqttRegion = cred.region;
57033
+ } catch (err) {
57034
+ throw new UsageError(
57035
+ [
57036
+ "SwitchBot MQTT credential probe failed.",
57037
+ `Reason: ${err instanceof Error ? err.message : String(err)}`,
57038
+ "Verify SWITCHBOT_TOKEN / SWITCHBOT_SECRET first, then re-run `switchbot status-sync start --probe`."
57039
+ ].join("\n")
57040
+ );
57041
+ }
57042
+ const probeUrl = `${runtime.openclawUrl.replace(/\/$/, "")}/v1/chat/completions`;
57043
+ let res;
57044
+ try {
57045
+ res = await fetch(probeUrl, {
57046
+ method: "POST",
57047
+ headers: {
57048
+ "content-type": "application/json",
57049
+ authorization: `Bearer ${runtime.openclawToken}`
57050
+ },
57051
+ body: JSON.stringify({
57052
+ model: runtime.openclawModel,
57053
+ messages: [{ role: "user", content: "status-sync probe" }]
57054
+ }),
57055
+ signal: AbortSignal.timeout(5e3)
57056
+ });
57057
+ } catch (err) {
57058
+ throw new UsageError(
57059
+ [
57060
+ `OpenClaw probe failed for ${probeUrl}.`,
57061
+ `Reason: ${err instanceof Error ? err.message : String(err)}`,
57062
+ "Check URL reachability, TLS/certificate trust, and whether the OpenClaw server is listening."
57063
+ ].join("\n")
57064
+ );
57065
+ }
57066
+ if (!res.ok) {
57067
+ const body = await res.text().catch(() => "");
57068
+ const preview = body ? ` \u2014 ${body.slice(0, 200).replace(/\s+/g, " ").trim()}` : "";
57069
+ const hint = res.status === 401 || res.status === 403 ? "OpenClaw rejected the token. Verify --openclaw-token / OPENCLAW_TOKEN against the server admin." : res.status === 404 ? "OpenClaw returned 404 for /v1/chat/completions. Confirm the base URL does not already include that path, and that the server exposes an OpenAI-compatible endpoint." : res.status === 400 || res.status === 422 ? "OpenClaw rejected the request body. The model name may not be registered; verify --openclaw-model / OPENCLAW_MODEL." : res.status >= 500 ? "OpenClaw returned a server error. Retry, and if it persists check the server logs." : "Unexpected status; inspect the response body above for details.";
57070
+ throw new UsageError(
57071
+ [
57072
+ `OpenClaw probe failed for ${probeUrl}.`,
57073
+ `Reason: HTTP ${res.status} ${res.statusText}${preview}`,
57074
+ hint
57075
+ ].join("\n")
57076
+ );
57077
+ }
57078
+ return {
57079
+ openclawUrl: runtime.openclawUrl,
57080
+ mqttBrokerUrl,
57081
+ mqttRegion
57082
+ };
57083
+ }
56307
57084
  function resolveStatusSyncPaths(explicitStateDir) {
56308
57085
  const stateDir = path23.resolve(
56309
57086
  explicitStateDir ?? process.env.SWITCHBOT_STATUS_SYNC_HOME ?? path23.join(os23.homedir(), ".switchbot", "status-sync")
@@ -56577,12 +57354,21 @@ Examples:
56577
57354
  handleError(error48);
56578
57355
  }
56579
57356
  });
56580
- statusSync.command("start").description("Start the background status-sync bridge").option("--openclaw-url <url>", "OpenClaw gateway URL (default: http://localhost:18789)", stringArg("--openclaw-url")).option("--openclaw-token <token>", "Bearer token for OpenClaw (or env OPENCLAW_TOKEN)", stringArg("--openclaw-token")).option("--openclaw-model <id>", "OpenClaw agent model ID to route events to (or env OPENCLAW_MODEL)", stringArg("--openclaw-model")).option("--topic <pattern>", "MQTT topic filter (default: SwitchBot shadow topic from credential)", stringArg("--topic")).option("--state-dir <path>", "Override the status-sync state directory (or env SWITCHBOT_STATUS_SYNC_HOME)", stringArg("--state-dir")).option("--force", "Stop any existing status-sync bridge before starting a new one").addHelpText(
57357
+ statusSync.command("start").description("Start the background status-sync bridge").option("--openclaw-url <url>", "OpenClaw gateway URL (default: http://localhost:18789)", stringArg("--openclaw-url")).option("--openclaw-token <token>", "Bearer token for OpenClaw (or env OPENCLAW_TOKEN)", stringArg("--openclaw-token")).option("--openclaw-model <id>", "OpenClaw agent model ID to route events to (or env OPENCLAW_MODEL)", stringArg("--openclaw-model")).option("--topic <pattern>", "MQTT topic filter (default: SwitchBot shadow topic from credential)", stringArg("--topic")).option("--state-dir <path>", "Override the status-sync state directory (or env SWITCHBOT_STATUS_SYNC_HOME)", stringArg("--state-dir")).option("--force", "Stop any existing status-sync bridge before starting a new one").option("--probe", "Perform online preflight: fetch MQTT credentials and probe the OpenClaw URL before spawning").addHelpText(
56581
57358
  "after",
56582
57359
  `
56583
57360
  Starts a detached child process that runs:
56584
57361
  switchbot status-sync run ...
56585
57362
 
57363
+ Local preflight before spawning:
57364
+ - SwitchBot credentials must be configured
57365
+ - OpenClaw token + model must be present
57366
+ - OpenClaw URL must parse as http:// or https://
57367
+
57368
+ Optional online preflight with --probe:
57369
+ - fetch MQTT credentials from SwitchBot
57370
+ - perform a short HTTP probe against the OpenClaw URL
57371
+
56586
57372
  State files:
56587
57373
  state.json process metadata (pid, startedAt, command)
56588
57374
  stdout.log redirected stdout from the child process
@@ -56593,8 +57379,11 @@ Examples:
56593
57379
  $ OPENCLAW_TOKEN=abc OPENCLAW_MODEL=home-agent switchbot status-sync start
56594
57380
  $ switchbot status-sync start --state-dir ~/.switchbot/custom-status-sync --force
56595
57381
  `
56596
- ).action((options) => {
57382
+ ).action(async (options) => {
56597
57383
  try {
57384
+ if (options.probe) {
57385
+ await probeStatusSyncStart(options);
57386
+ }
56598
57387
  const status = startStatusSync(options);
56599
57388
  if (isJsonMode()) {
56600
57389
  printJson(status);
@@ -56731,6 +57520,46 @@ function toPrometheusText(report) {
56731
57520
  // src/commands/health.ts
56732
57521
  init_arg_parsers();
56733
57522
  var HEALTHZ_SCHEMA_VERSION = "1.1";
57523
+ function runHealthCheck(opts) {
57524
+ const report = getHealthReport(opts.auditLog);
57525
+ if (opts.prometheus) {
57526
+ process.stdout.write(toPrometheusText(report));
57527
+ return;
57528
+ }
57529
+ if (isJsonMode()) {
57530
+ printJson(report);
57531
+ return;
57532
+ }
57533
+ const statusEmoji = report.overall === "ok" ? "\u2713" : report.overall === "degraded" ? "\u26A0" : "\u2717";
57534
+ console.log(`${statusEmoji} overall: ${report.overall} (${report.generatedAt})`);
57535
+ console.log("");
57536
+ printTable(
57537
+ ["Component", "Status", "Detail"],
57538
+ [
57539
+ [
57540
+ "quota",
57541
+ report.quota.status,
57542
+ `${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`
57543
+ ],
57544
+ [
57545
+ "audit",
57546
+ report.audit.status,
57547
+ report.audit.present ? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)` : "log not present"
57548
+ ],
57549
+ [
57550
+ "circuit",
57551
+ report.circuit.status,
57552
+ `${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`
57553
+ ],
57554
+ [
57555
+ "process",
57556
+ "ok",
57557
+ `pid ${report.process.pid} \xB7 uptime ${report.process.uptimeSeconds}s \xB7 mem ${report.process.memoryMb}MB`
57558
+ ]
57559
+ ]
57560
+ );
57561
+ if (report.overall !== "ok") process.exit(1);
57562
+ }
56734
57563
  function createHealthHandler(auditLogPath) {
56735
57564
  return (req, res) => {
56736
57565
  const url2 = (req.url ?? "/").split("?")[0];
@@ -56750,48 +57579,14 @@ function createHealthHandler(auditLogPath) {
56750
57579
  };
56751
57580
  }
56752
57581
  function registerHealthCommand(program3) {
56753
- const health = program3.command("health").description("Report process health: quota, audit error rate, circuit breaker state.");
56754
- health.command("check").description("Print a one-shot health report.").option("--prometheus", "Emit Prometheus text format.").option("--audit-log <path>", "Audit log path (default: ~/.switchbot/audit.log).").action((opts) => {
56755
- const report = getHealthReport(opts.auditLog);
56756
- if (opts.prometheus) {
56757
- process.stdout.write(toPrometheusText(report));
56758
- return;
56759
- }
56760
- if (isJsonMode()) {
56761
- printJson(report);
56762
- return;
56763
- }
56764
- const statusEmoji = report.overall === "ok" ? "\u2713" : report.overall === "degraded" ? "\u26A0" : "\u2717";
56765
- console.log(`${statusEmoji} overall: ${report.overall} (${report.generatedAt})`);
56766
- console.log("");
56767
- printTable(
56768
- ["Component", "Status", "Detail"],
56769
- [
56770
- [
56771
- "quota",
56772
- report.quota.status,
56773
- `${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`
56774
- ],
56775
- [
56776
- "audit",
56777
- report.audit.status,
56778
- report.audit.present ? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)` : "log not present"
56779
- ],
56780
- [
56781
- "circuit",
56782
- report.circuit.status,
56783
- `${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`
56784
- ],
56785
- [
56786
- "process",
56787
- "ok",
56788
- `pid ${report.process.pid} \xB7 uptime ${report.process.uptimeSeconds}s \xB7 mem ${report.process.memoryMb}MB`
56789
- ]
56790
- ]
56791
- );
56792
- if (report.overall !== "ok") process.exit(1);
57582
+ const health = program3.command("health").description("Report process health: quota, audit error rate, circuit breaker state.").option("--prometheus", "Emit Prometheus text format.").option("--audit-log <path>", "Audit log path (default: ~/.switchbot/audit.log).");
57583
+ health.action((opts) => {
57584
+ runHealthCheck(opts);
57585
+ });
57586
+ health.command("check").description("Print a one-shot health report.").action((_opts, cmd) => {
57587
+ runHealthCheck(cmd.optsWithGlobals());
56793
57588
  });
56794
- health.command("serve").description("Start an HTTP server exposing /healthz (JSON) and /metrics (Prometheus).").option("--port <n>", "Port to listen on.", intArg("--port"), "3100").option("--host <host>", "Bind address.", "127.0.0.1").option("--audit-log <path>", "Audit log path.").addHelpText("after", `
57589
+ health.command("serve").description("Start an HTTP server exposing /healthz (JSON) and /metrics (Prometheus).").option("--port <n>", "Port to listen on.", intArg("--port"), "3100").option("--host <host>", "Bind address.", "127.0.0.1").addHelpText("after", `
56795
57590
  Endpoints:
56796
57591
  GET /healthz JSON health report (HTTP 200 ok/degraded, 503 when circuit is open).
56797
57592
  GET /metrics Prometheus text metrics.
@@ -56799,7 +57594,8 @@ Endpoints:
56799
57594
  Example:
56800
57595
  $ switchbot health serve --port 3100
56801
57596
  $ curl http://127.0.0.1:3100/healthz
56802
- `).action((opts) => {
57597
+ `).action((_opts, cmd) => {
57598
+ const opts = cmd.optsWithGlobals();
56803
57599
  const port = parseInt(opts.port, 10);
56804
57600
  const handler = createHealthHandler(opts.auditLog);
56805
57601
  const server = http3.createServer(handler);
@@ -56902,12 +57698,17 @@ function registerUpgradeCheckCommand(program3) {
56902
57698
  }
56903
57699
  return;
56904
57700
  }
57701
+ const breakingNotice = findBreakingChangeBetween(VERSION, latestVersion);
56905
57702
  const result = {
56906
57703
  current: VERSION,
56907
57704
  latest: latestVersion,
56908
57705
  upToDate,
56909
57706
  updateAvailable: !upToDate,
56910
- breakingChange: latestMajor > currentMajor,
57707
+ breakingChange: latestMajor > currentMajor || breakingNotice !== null,
57708
+ ...breakingNotice ? {
57709
+ breakingVersion: breakingNotice.version,
57710
+ breakingSummary: breakingNotice.summary
57711
+ } : {},
56911
57712
  installCommand: upToDate ? null : `npm install -g ${pkgName}@${latestVersion}`
56912
57713
  };
56913
57714
  if (isJsonMode()) {
@@ -56918,6 +57719,9 @@ function registerUpgradeCheckCommand(program3) {
56918
57719
  console.log(`${source_default.green("\u2713")} You are running the latest version (${VERSION}).`);
56919
57720
  } else {
56920
57721
  console.log(`${source_default.yellow("!")} Update available: ${source_default.bold(VERSION)} \u2192 ${source_default.bold(latestVersion)}`);
57722
+ if (breakingNotice) {
57723
+ console.log(source_default.red(` Breaking change in ${breakingNotice.version}: ${breakingNotice.summary}`));
57724
+ }
56921
57725
  console.log(` Run: ${source_default.cyan(`npm install -g ${pkgName}@${latestVersion}`)}`);
56922
57726
  process.exit(1);
56923
57727
  }