@switchbot/openapi-cli 2.6.4 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
- import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
3
+ import { printJson, isJsonMode, handleError, UsageError, emitStreamHeader } from '../utils/output.js';
4
4
  import { intArg, stringArg, durationArg } from '../utils/arg-parsers.js';
5
5
  import { parseDurationToMs } from '../utils/flags.js';
6
6
  import { parseFilterExpr, matchClause, FilterSyntaxError } from '../utils/filter.js';
@@ -19,6 +19,16 @@ import { deviceHistoryStore } from '../mcp/device-history.js';
19
19
  const DEFAULT_PORT = 3000;
20
20
  const DEFAULT_PATH = '/';
21
21
  const MAX_BODY_BYTES = 1_000_000;
22
+ /**
23
+ * P6: unified-envelope schema version shared by webhook and MQTT event output.
24
+ *
25
+ * The same key set now appears on both `events tail` (webhook) and
26
+ * `events mqtt-tail` (MQTT) output lines so downstream JSONL consumers can
27
+ * use a single parser regardless of source. Old fields are kept for one
28
+ * minor window so existing consumers keep working — see README and
29
+ * CHANGELOG for the deprecation schedule.
30
+ */
31
+ export const EVENTS_SCHEMA_VERSION = '1';
22
32
  function extractEventId(parsed) {
23
33
  if (!parsed || typeof parsed !== 'object')
24
34
  return null;
@@ -30,13 +40,27 @@ function extractEventId(parsed) {
30
40
  return ctx.eventId;
31
41
  return null;
32
42
  }
33
- function matchFilter(body, clauses) {
43
+ function extractDeviceId(parsed) {
44
+ if (!parsed || typeof parsed !== 'object')
45
+ return null;
46
+ const p = parsed;
47
+ const ctx = p.context ?? p;
48
+ const mac = ctx.deviceMac;
49
+ if (typeof mac === 'string' && mac.length > 0)
50
+ return mac;
51
+ const id = ctx.deviceId;
52
+ if (typeof id === 'string' && id.length > 0)
53
+ return id;
54
+ return null;
55
+ }
56
+ function matchFilterDetail(body, clauses) {
34
57
  if (!clauses || clauses.length === 0)
35
- return true;
58
+ return { matched: true, matchedKeys: [] };
36
59
  if (!body || typeof body !== 'object')
37
- return false;
60
+ return { matched: false, matchedKeys: [] };
38
61
  const b = body;
39
62
  const ctx = (b.context ?? b);
63
+ const hitKeys = [];
40
64
  for (const c of clauses) {
41
65
  let candidate;
42
66
  if (c.key === 'deviceId') {
@@ -49,9 +73,10 @@ function matchFilter(body, clauses) {
49
73
  candidate = typeof t === 'string' ? t : '';
50
74
  }
51
75
  if (!matchClause(candidate, c))
52
- return false;
76
+ return { matched: false, matchedKeys: [] };
77
+ hitKeys.push(c.key);
53
78
  }
54
- return true;
79
+ return { matched: true, matchedKeys: hitKeys };
55
80
  }
56
81
  const EVENT_FILTER_KEYS = ['deviceId', 'type'];
57
82
  function parseFilter(flag) {
@@ -108,11 +133,22 @@ export function startReceiver(port, pathMatch, filter, onEvent) {
108
133
  catch {
109
134
  // keep raw
110
135
  }
111
- const matched = matchFilter(body, filter);
136
+ const { matched, matchedKeys } = matchFilterDetail(body, filter);
137
+ const t = new Date().toISOString();
138
+ const urlPath = req.url ?? '/';
112
139
  onEvent({
113
- t: new Date().toISOString(),
140
+ schemaVersion: EVENTS_SCHEMA_VERSION,
141
+ source: 'webhook',
142
+ kind: 'event',
143
+ t,
144
+ eventId: extractEventId(body),
145
+ deviceId: extractDeviceId(body),
146
+ topic: urlPath,
147
+ payload: body,
148
+ matchedKeys,
149
+ // Legacy mirror:
114
150
  remote: `${req.socket.remoteAddress ?? ''}:${req.socket.remotePort ?? ''}`,
115
- path: req.url ?? '/',
151
+ path: urlPath,
116
152
  body,
117
153
  matched,
118
154
  });
@@ -143,9 +179,14 @@ SwitchBot posts events to a single webhook URL configured via:
143
179
  the port to the internet yourself (ngrok/cloudflared/reverse proxy) and
144
180
  point the SwitchBot webhook at that public URL.
145
181
 
146
- Output (JSONL, one event per line):
147
- { "t": "<ISO>", "remote": "<ip:port>", "path": "/",
148
- "body": <parsed JSON or raw string>, "matched": true }
182
+ Output (JSONL, one event per line; P6 unified envelope v2.7+):
183
+ { "schemaVersion": "1", "source": "webhook", "kind": "event",
184
+ "t": "<ISO>", "eventId": <string|null>, "deviceId": <string|null>,
185
+ "topic": "/", // = path
186
+ "payload": <parsed JSON or raw string>,
187
+ "matchedKeys": ["deviceId"], // which filter clauses matched
188
+ // Legacy fields kept for one minor window (removed in v3.0):
189
+ "remote": "<ip:port>", "path": "/", "body": <payload>, "matched": true }
149
190
 
150
191
  Filter grammar: comma-separated clauses (AND-ed). Each clause is one of
151
192
  key=value — case-insensitive substring
@@ -179,6 +220,10 @@ Examples:
179
220
  const forTimer = forMs !== null && forMs > 0
180
221
  ? setTimeout(() => ac.abort(), forMs)
181
222
  : null;
223
+ // P7: streaming JSON contract — first line under --json is the
224
+ // stream header (webhook events arrive via push cadence).
225
+ if (isJsonMode())
226
+ emitStreamHeader({ eventKind: 'event', cadence: 'push' });
182
227
  await new Promise((resolve, reject) => {
183
228
  let server = null;
184
229
  try {
@@ -244,14 +289,20 @@ Connects to the SwitchBot MQTT service using your existing credentials
244
289
  (SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json).
245
290
  No additional MQTT configuration required.
246
291
 
247
- Output (JSONL, one event per line):
248
- { "t": "<ISO>", "eventId": "<uuid>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
292
+ Output (JSONL, one event per line; P6 unified envelope v2.7+):
293
+ { "schemaVersion": "1", "source": "mqtt", "kind": "event",
294
+ "t": "<ISO>", "eventId": "<uuid>", "deviceId": <string|null>,
295
+ "topic": "<mqtt-topic>",
296
+ "payload": <parsed JSON or raw string> }
249
297
 
250
- Control records (interleaved, no "payload" field use type-prefix to filter):
251
- { "type": "__session_start", "at": "<ISO>", "eventId": "<uuid>", "state": "connecting" } before credential fetch (JSON mode only)
252
- { "type": "__connect", "at": "<ISO>", "eventId": "<uuid>" } first successful connect
253
- { "type": "__reconnect", "at": "<ISO>", "eventId": "<uuid>" } connect after a disconnect
254
- { "type": "__disconnect", "at": "<ISO>", "eventId": "<uuid>" } reconnecting or failed
298
+ Control records (interleaved, kind: "control" — filter by the "kind" field):
299
+ { "schemaVersion": "1", "source": "mqtt", "kind": "control",
300
+ "controlKind": "session_start"|"connect"|"reconnect"|"disconnect"|"heartbeat",
301
+ "t": "<ISO>", "eventId": "<uuid>",
302
+ "state": "connecting" // present on session_start only
303
+ // Legacy fields kept for one minor window (removed in v3.0):
304
+ "type": "__session_start"|"__connect"|"__reconnect"|"__disconnect",
305
+ "at": "<ISO>" }
255
306
 
256
307
  Reconnect policy: the MQTT client retries with exponential backoff
257
308
  (1s → 30s capped, forever) while the credential is still valid; if the
@@ -346,15 +397,27 @@ Examples:
346
397
  if (!isJsonMode()) {
347
398
  console.error('Fetching MQTT credentials from SwitchBot service…');
348
399
  }
400
+ // P7: streaming JSON contract — first line under --json is the stream
401
+ // header (mqtt events arrive via push cadence). Must emit BEFORE
402
+ // __session_start so header is always the very first line.
403
+ if (isJsonMode())
404
+ emitStreamHeader({ eventKind: 'event', cadence: 'push' });
349
405
  // Emit a __session_start envelope immediately (before any credential
350
406
  // fetch) so JSON consumers can distinguish "connecting" from "never
351
407
  // connected" even when mqtt-tail exits before the broker connects.
352
408
  if (isJsonMode()) {
409
+ const sessionStartAt = new Date().toISOString();
353
410
  printJson({
354
- type: '__session_start',
355
- at: new Date().toISOString(),
411
+ schemaVersion: EVENTS_SCHEMA_VERSION,
412
+ source: 'mqtt',
413
+ kind: 'control',
414
+ controlKind: 'session_start',
415
+ t: sessionStartAt,
356
416
  eventId: crypto.randomUUID(),
357
417
  state: 'connecting',
418
+ // Legacy (deprecated as of v2.7; removed in v3.0):
419
+ type: '__session_start',
420
+ at: sessionStartAt,
358
421
  });
359
422
  }
360
423
  const credential = await fetchMqttCredential(loaded.token, loaded.secret);
@@ -389,7 +452,16 @@ Examples:
389
452
  // Default behavior: record history + print to stdout
390
453
  const { deviceId, deviceType } = parseSinkEvent(parsed);
391
454
  deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
392
- const record = { t, eventId, topic: msgTopic, payload: parsed };
455
+ const record = {
456
+ schemaVersion: EVENTS_SCHEMA_VERSION,
457
+ source: 'mqtt',
458
+ kind: 'event',
459
+ t,
460
+ eventId,
461
+ deviceId: deviceId ?? null,
462
+ topic: msgTopic,
463
+ payload: parsed,
464
+ };
393
465
  if (isJsonMode()) {
394
466
  printJson(record);
395
467
  }
@@ -405,7 +477,24 @@ Examples:
405
477
  let mqttFailed = false;
406
478
  let hasConnectedBefore = false;
407
479
  const emitControl = (kind) => {
408
- const ctl = { type: kind, at: new Date().toISOString(), eventId: crypto.randomUUID() };
480
+ const at = new Date().toISOString();
481
+ const controlKindMap = {
482
+ __connect: 'connect',
483
+ __reconnect: 'reconnect',
484
+ __disconnect: 'disconnect',
485
+ __heartbeat: 'heartbeat',
486
+ };
487
+ const ctl = {
488
+ schemaVersion: EVENTS_SCHEMA_VERSION,
489
+ source: 'mqtt',
490
+ kind: 'control',
491
+ controlKind: controlKindMap[kind],
492
+ t: at,
493
+ eventId: crypto.randomUUID(),
494
+ // Legacy (deprecated as of v2.7; removed in v3.0):
495
+ type: kind,
496
+ at,
497
+ };
409
498
  // Control events always go to stdout as JSONL so consumers that
410
499
  // filter real events by presence of `payload` can skip them.
411
500
  if (isJsonMode()) {
@@ -1,5 +1,5 @@
1
1
  import { intArg, stringArg } from '../utils/arg-parsers.js';
2
- import { handleError, isJsonMode, printJson, UsageError, emitJsonError } from '../utils/output.js';
2
+ import { handleError, isJsonMode, printJson, UsageError, exitWithError } from '../utils/output.js';
3
3
  import { getCachedDevice } from '../devices/cache.js';
4
4
  import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js';
5
5
  import { isDryRun } from '../utils/flags.js';
@@ -92,20 +92,12 @@ Examples:
92
92
  }
93
93
  if (!options.yes && !isDryRun() && isDestructiveCommand(deviceType, command, 'command')) {
94
94
  const reason = getDestructiveReason(deviceType, command, 'command');
95
- if (isJsonMode()) {
96
- emitJsonError({
97
- code: 2,
98
- kind: 'guard',
99
- message: `"${command}" on ${deviceType || 'device'} is destructive and requires --yes.`,
100
- hint: reason ? `Re-run with --yes. Reason: ${reason}` : 'Re-run with --yes to confirm.',
101
- });
102
- }
103
- else {
104
- console.error(`Refusing to run destructive command "${command}" without --yes.`);
105
- if (reason)
106
- console.error(`Reason: ${reason}`);
107
- }
108
- process.exit(2);
95
+ exitWithError({
96
+ code: 2,
97
+ kind: 'guard',
98
+ message: `"${command}" on ${deviceType || 'device'} is destructive and requires --yes.`,
99
+ hint: reason ? `Re-run with --yes. Reason: ${reason}` : 'Re-run with --yes to confirm.',
100
+ });
109
101
  }
110
102
  const body = await executeCommand(deviceId, command, parameter, 'command');
111
103
  const isIr = cached?.category === 'ir';
@@ -43,12 +43,16 @@ Examples:
43
43
  }
44
44
  const caps = desc.capabilities;
45
45
  const commands = caps && 'commands' in caps
46
- ? caps.commands.map((c) => ({
47
- command: c.command,
48
- parameter: c.parameter,
49
- idempotent: c.idempotent,
50
- destructive: c.destructive,
51
- }))
46
+ ? caps.commands.map((c) => {
47
+ const tier = c.safetyTier;
48
+ return {
49
+ command: c.command,
50
+ parameter: c.parameter,
51
+ idempotent: c.idempotent,
52
+ ...(tier ? { safetyTier: tier } : {}),
53
+ destructive: c.destructive,
54
+ };
55
+ })
52
56
  : [];
53
57
  const statusFields = caps && 'statusFields' in caps ? caps.statusFields : [];
54
58
  const liveStatus = caps && 'liveStatus' in caps ? caps.liveStatus : undefined;
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
3
  import { intArg, stringArg } from '../utils/arg-parsers.js';
4
- import { printJson, isJsonMode, handleError, UsageError, emitJsonError } from '../utils/output.js';
4
+ import { printJson, isJsonMode, handleError, UsageError, exitWithError } from '../utils/output.js';
5
5
  import { readAudit, verifyAudit } from '../utils/audit.js';
6
6
  import { executeCommand } from '../lib/devices.js';
7
7
  import { queryDeviceHistory, queryDeviceHistoryStats, } from '../devices/history-query.js';
@@ -10,7 +10,7 @@ const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
10
10
  export function registerHistoryCommand(program) {
11
11
  const history = program
12
12
  .command('history')
13
- .description('View and replay commands recorded via --audit-log')
13
+ .description('View and replay SwitchBot commands recorded via --audit-log')
14
14
  .addHelpText('after', `
15
15
  Every 'devices command' run with --audit-log is appended as JSONL to the
16
16
  audit file (default ~/.switchbot/audit.log). 'history show' prints the file,
@@ -70,25 +70,19 @@ Examples:
70
70
  const entries = readAudit(file);
71
71
  const idx = Number(indexArg);
72
72
  if (!Number.isInteger(idx) || idx < 1 || idx > entries.length) {
73
- const msg = `Invalid index ${indexArg}. Log has ${entries.length} entries.`;
74
- if (isJsonMode()) {
75
- emitJsonError({ code: 2, kind: 'usage', message: msg });
76
- }
77
- else {
78
- console.error(msg);
79
- }
80
- process.exit(2);
73
+ exitWithError({
74
+ code: 2,
75
+ kind: 'usage',
76
+ message: `Invalid index ${indexArg}. Log has ${entries.length} entries.`,
77
+ });
81
78
  }
82
79
  const entry = entries[idx - 1];
83
80
  if (entry.kind !== 'command') {
84
- const msg = `Entry ${idx} is not a command (kind=${entry.kind}).`;
85
- if (isJsonMode()) {
86
- emitJsonError({ code: 2, kind: 'usage', message: msg });
87
- }
88
- else {
89
- console.error(msg);
90
- }
91
- process.exit(2);
81
+ exitWithError({
82
+ code: 2,
83
+ kind: 'usage',
84
+ message: `Entry ${idx} is not a command (kind=${entry.kind}).`,
85
+ });
92
86
  }
93
87
  try {
94
88
  const result = await executeCommand(entry.deviceId, entry.command, entry.parameter, entry.commandType);
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Single source of truth for SwitchBot product identity.
3
+ *
4
+ * Consumed by:
5
+ * - `program.description()` / `--help` (via PRODUCT_TAGLINE in src/index.ts)
6
+ * - `--help --json` root (via src/utils/help-json.ts)
7
+ * - `switchbot capabilities` / `--json` (identity block)
8
+ * - `switchbot agent-bootstrap --json` (identity block)
9
+ *
10
+ * Keeping this in one file prevents drift between those four surfaces.
11
+ *
12
+ * IMPORTANT: the SwitchBot CLI only talks to the SwitchBot Cloud API over
13
+ * HTTPS. It does NOT drive BLE radios directly — BLE-only devices are
14
+ * reached by going through a SwitchBot Hub, which the Cloud API already
15
+ * handles transparently. Please do not reintroduce the word "BLE" into the
16
+ * tagline / README: it is misleading for AI agents reading `--help`.
17
+ */
18
+ export const IDENTITY = {
19
+ product: 'SwitchBot',
20
+ domain: 'IoT smart home device control',
21
+ vendor: 'Wonderlabs, Inc.',
22
+ apiVersion: 'v1.1',
23
+ apiDocs: 'https://github.com/OpenWonderLabs/SwitchBotAPI',
24
+ // Product category keywords. AI agents scan these to judge scope
25
+ // ("does SwitchBot control door locks? air conditioners?") without
26
+ // parsing the full device catalog.
27
+ productCategories: [
28
+ 'lights (bulbs / strips / color)',
29
+ 'locks / keypads',
30
+ 'curtains / blinds / shades',
31
+ 'sensors (motion / contact / climate / water-leak)',
32
+ 'plugs / strips',
33
+ 'bots / mechanical pushers',
34
+ 'robot vacuums',
35
+ 'IR appliances via Hub (TV / AC / fan / projector)',
36
+ ],
37
+ deviceCategories: {
38
+ physical: 'Wi-Fi-connected and Hub-mediated devices — controlled via Cloud API (CLI does not drive BLE directly)',
39
+ ir: 'IR remote devices learned by a SwitchBot Hub (TV, AC, fan, etc.)',
40
+ },
41
+ constraints: {
42
+ quotaPerDay: 10000,
43
+ hubRequiredForBle: true,
44
+ transport: 'Cloud API v1.1 (HTTPS)',
45
+ authMethod: 'HMAC-SHA256 token+secret',
46
+ },
47
+ agentGuide: 'docs/agent-guide.md',
48
+ };
49
+ /**
50
+ * One-line product description used for `program.description()` (the first
51
+ * line an AI agent sees when running `switchbot --help`).
52
+ *
53
+ * Structure: "SwitchBot smart home CLI — <product categories> via <transport>;
54
+ * <verbs: scenes, events, MCP>." Keep categories in sync with
55
+ * IDENTITY.productCategories above.
56
+ */
57
+ export const PRODUCT_TAGLINE = 'SwitchBot smart home CLI — control lights, locks, curtains, sensors, plugs, ' +
58
+ 'and IR appliances (TV/AC/fan) via Cloud API v1.1; run scenes, stream real-time ' +
59
+ 'events, and integrate AI agents via MCP.';
@@ -7,7 +7,7 @@ import { handleError, buildErrorPayload, exitWithError } from '../utils/output.j
7
7
  import { VERSION } from '../version.js';
8
8
  import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
9
9
  import { fetchScenes, executeScene } from '../lib/scenes.js';
10
- import { findCatalogEntry } from '../devices/catalog.js';
10
+ import { findCatalogEntry, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
11
11
  import { getCachedDevice } from '../devices/cache.js';
12
12
  import { validateParameter } from '../devices/param-validator.js';
13
13
  import { EventSubscriptionManager } from '../mcp/events-subscription.js';
@@ -362,7 +362,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
362
362
  command: effectiveCommand,
363
363
  deviceType: typeName,
364
364
  description: spec?.description ?? null,
365
- ...(reason ? { destructiveReason: reason } : {}),
365
+ ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}),
366
366
  },
367
367
  });
368
368
  }
@@ -516,6 +516,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
516
516
  description: z.string(),
517
517
  commandType: z.enum(['command', 'customize']).optional(),
518
518
  idempotent: z.boolean().optional(),
519
+ safetyTier: z.enum(['read', 'mutation', 'ir-fire-forget', 'destructive', 'maintenance']).optional(),
520
+ safetyReason: z.string().optional(),
519
521
  destructive: z.boolean().optional(),
520
522
  }).passthrough()),
521
523
  aliases: z.array(z.string()).optional(),
@@ -532,9 +534,22 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
532
534
  });
533
535
  }
534
536
  const hits = searchCatalog(query, limit);
535
- const structured = { results: hits, total: hits.length };
537
+ const normalised = hits.map((e) => ({
538
+ ...e,
539
+ commands: e.commands.map((c) => {
540
+ const tier = deriveSafetyTier(c, e);
541
+ const reason = getCommandSafetyReason(c);
542
+ return {
543
+ ...c,
544
+ safetyTier: tier,
545
+ destructive: tier === 'destructive',
546
+ ...(reason ? { safetyReason: reason } : {}),
547
+ };
548
+ }),
549
+ }));
550
+ const structured = { results: normalised, total: normalised.length };
536
551
  return {
537
- content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }],
552
+ content: [{ type: 'text', text: JSON.stringify(normalised, null, 2) }],
538
553
  structuredContent: structured,
539
554
  };
540
555
  });
@@ -591,16 +606,64 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
591
606
  _meta: { agentSafetyTier: 'read' },
592
607
  inputSchema: z
593
608
  .object({
594
- deviceId: z.string().min(1),
595
- since: z.string().optional(),
596
- from: z.string().optional(),
597
- to: z.string().optional(),
598
- metrics: z.array(z.string().min(1)).min(1),
599
- aggs: z.array(z.enum(ALL_AGG_FNS)).optional(),
600
- bucket: z.string().optional(),
601
- maxBucketSamples: z.number().int().positive().max(MAX_SAMPLE_CAP).optional(),
609
+ deviceId: z.string().min(1).describe('Device ID to aggregate over (must exist in ~/.switchbot/device-history/).'),
610
+ since: z
611
+ .string()
612
+ .optional()
613
+ .describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
614
+ from: z.string().optional().describe('Range start (ISO-8601). Requires `to`.'),
615
+ to: z.string().optional().describe('Range end (ISO-8601). Requires `from`.'),
616
+ metrics: z
617
+ .array(z.string().min(1))
618
+ .min(1)
619
+ .describe('One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).'),
620
+ aggs: z
621
+ .array(z.enum(ALL_AGG_FNS))
622
+ .optional()
623
+ .describe('Aggregation functions to apply per metric (default: ["count","avg"]).'),
624
+ bucket: z
625
+ .string()
626
+ .optional()
627
+ .describe('Bucket width like "5m", "1h", "1d". Omit for a single bucket spanning the full range.'),
628
+ maxBucketSamples: z
629
+ .number()
630
+ .int()
631
+ .positive()
632
+ .max(MAX_SAMPLE_CAP)
633
+ .optional()
634
+ .describe(`Sample cap per bucket to bound memory (default ${10_000}, max ${MAX_SAMPLE_CAP}). partial=true in the result when any bucket was capped.`),
602
635
  })
603
636
  .strict(),
637
+ outputSchema: {
638
+ deviceId: z.string(),
639
+ bucket: z.string().optional().describe('Bucket width echoed back when specified; omitted for single-bucket results.'),
640
+ from: z.string().describe('Effective range start (ISO-8601).'),
641
+ to: z.string().describe('Effective range end (ISO-8601).'),
642
+ metrics: z.array(z.string()).describe('Metrics that were requested.'),
643
+ aggs: z
644
+ .array(z.enum(ALL_AGG_FNS))
645
+ .describe('Aggregation functions that were applied.'),
646
+ buckets: z
647
+ .array(z.object({
648
+ t: z.string().describe('Bucket start timestamp (ISO-8601).'),
649
+ metrics: z
650
+ .record(z.string(), z
651
+ .object({
652
+ count: z.number().optional(),
653
+ min: z.number().optional(),
654
+ max: z.number().optional(),
655
+ avg: z.number().optional(),
656
+ sum: z.number().optional(),
657
+ p50: z.number().optional(),
658
+ p95: z.number().optional(),
659
+ })
660
+ .describe('Per-aggregate function result for this metric in this bucket.'))
661
+ .describe('Per-metric result keyed by metric name.'),
662
+ }))
663
+ .describe('Time-ordered buckets; empty when no records match.'),
664
+ partial: z.boolean().describe('True if any bucket was sample-capped; retry with a higher maxBucketSamples or a narrower range for exact values.'),
665
+ notes: z.array(z.string()).describe('Human-readable notes about the aggregation (e.g. "metric X is non-numeric").'),
666
+ },
604
667
  }, async (args) => {
605
668
  const opts = {
606
669
  since: args.since,
@@ -612,9 +675,21 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
612
675
  maxBucketSamples: args.maxBucketSamples,
613
676
  };
614
677
  const res = await aggregateDeviceHistory(args.deviceId, opts);
678
+ const structured = {
679
+ deviceId: res.deviceId,
680
+ from: res.from,
681
+ to: res.to,
682
+ metrics: res.metrics,
683
+ aggs: res.aggs,
684
+ buckets: res.buckets,
685
+ partial: res.partial,
686
+ notes: res.notes,
687
+ };
688
+ if (res.bucket !== undefined)
689
+ structured.bucket = res.bucket;
615
690
  return {
616
691
  content: [{ type: 'text', text: JSON.stringify(res, null, 2) }],
617
- structuredContent: res,
692
+ structuredContent: structured,
618
693
  };
619
694
  });
620
695
  // ---- account_overview ---------------------------------------------------
@@ -729,6 +804,18 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
729
804
  }
730
805
  return server;
731
806
  }
807
+ /**
808
+ * P10: list the tool names registered on an McpServer instance. Used by
809
+ * `doctor`'s dry-run check. The MCP SDK keeps `_registeredTools` private,
810
+ * so we reach through a narrow cast — safe because this only runs in
811
+ * diagnostic code and the shape is stable across SDK versions.
812
+ */
813
+ export function listRegisteredTools(server) {
814
+ const internal = server;
815
+ if (!internal._registeredTools)
816
+ return [];
817
+ return Object.keys(internal._registeredTools).sort();
818
+ }
732
819
  export function registerMcpCommand(program) {
733
820
  const mcp = program
734
821
  .command('mcp')
@@ -160,7 +160,7 @@ function readStdin() {
160
160
  export function registerPlanCommand(program) {
161
161
  const plan = program
162
162
  .command('plan')
163
- .description('Agent-authored batch plans: schema, validate, run')
163
+ .description('Author, validate, and run SwitchBot batch plans (JSON schema for AI agents)')
164
164
  .addHelpText('after', `
165
165
  A "plan" is a JSON document describing a sequence of commands/scenes/waits.
166
166
  The schema is fixed — agents emit plans, the CLI executes them. No LLM inside.
@@ -1,6 +1,7 @@
1
1
  import { enumArg, stringArg } from '../utils/arg-parsers.js';
2
2
  import { printJson } from '../utils/output.js';
3
- import { getEffectiveCatalog } from '../devices/catalog.js';
3
+ import { getEffectiveCatalog, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
4
+ import { RESOURCE_CATALOG } from '../devices/resources.js';
4
5
  import { loadCache } from '../devices/cache.js';
5
6
  function toSchemaEntry(e) {
6
7
  return {
@@ -10,18 +11,22 @@ function toSchemaEntry(e) {
10
11
  aliases: e.aliases ?? [],
11
12
  role: e.role ?? null,
12
13
  readOnly: e.readOnly ?? false,
13
- commands: e.commands.map(toSchemaCommand),
14
+ commands: e.commands.map((c) => toSchemaCommand(c, e)),
14
15
  statusFields: e.statusFields ?? [],
15
16
  };
16
17
  }
17
- function toSchemaCommand(c) {
18
+ function toSchemaCommand(c, entry) {
19
+ const tier = deriveSafetyTier(c, entry);
20
+ const reason = getCommandSafetyReason(c);
18
21
  return {
19
22
  command: c.command,
20
23
  parameter: c.parameter,
21
24
  description: c.description,
22
25
  commandType: (c.commandType ?? 'command'),
23
26
  idempotent: Boolean(c.idempotent),
24
- destructive: Boolean(c.destructive),
27
+ safetyTier: tier,
28
+ destructive: tier === 'destructive',
29
+ ...(reason ? { safetyReason: reason } : {}),
25
30
  ...(c.exampleParams ? { exampleParams: c.exampleParams } : {}),
26
31
  };
27
32
  }
@@ -31,13 +36,17 @@ function toCompactEntry(e) {
31
36
  category: e.category,
32
37
  role: e.role ?? null,
33
38
  readOnly: e.readOnly ?? false,
34
- commands: e.commands.map((c) => ({
35
- command: c.command,
36
- parameter: c.parameter,
37
- commandType: (c.commandType ?? 'command'),
38
- idempotent: Boolean(c.idempotent),
39
- destructive: Boolean(c.destructive),
40
- })),
39
+ commands: e.commands.map((c) => {
40
+ const tier = deriveSafetyTier(c, e);
41
+ return {
42
+ command: c.command,
43
+ parameter: c.parameter,
44
+ commandType: (c.commandType ?? 'command'),
45
+ idempotent: Boolean(c.idempotent),
46
+ safetyTier: tier,
47
+ destructive: tier === 'destructive',
48
+ };
49
+ }),
41
50
  statusFields: e.statusFields ?? [],
42
51
  };
43
52
  }
@@ -54,7 +63,7 @@ export function registerSchemaCommand(program) {
54
63
  const CATEGORIES = ['physical', 'ir'];
55
64
  const schema = program
56
65
  .command('schema')
57
- .description('Export the device catalog as structured JSON (for agent prompts / tooling)');
66
+ .description('Export the SwitchBot device catalog as structured JSON (for AI agent prompts / tooling)');
58
67
  schema
59
68
  .command('export')
60
69
  .description('Print the catalog as structured JSON (one object per type)')
@@ -137,6 +146,7 @@ Examples:
137
146
  };
138
147
  if (!options.compact) {
139
148
  payload.generatedAt = new Date().toISOString();
149
+ payload.resources = RESOURCE_CATALOG;
140
150
  payload.cliAddedFields = [
141
151
  {
142
152
  field: '_fetchedAt',