@switchbot/openapi-cli 2.6.3 → 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,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.';
@@ -3,11 +3,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { z } from 'zod';
5
5
  import { intArg, stringArg } from '../utils/arg-parsers.js';
6
- import { handleError, isJsonMode, buildErrorPayload, emitJsonError } from '../utils/output.js';
6
+ import { handleError, buildErrorPayload, exitWithError } from '../utils/output.js';
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';
@@ -274,6 +274,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
274
274
  },
275
275
  }, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
276
276
  const effectiveType = commandType ?? 'command';
277
+ let effectiveCommand = command;
277
278
  let effectiveParameter = parameter;
278
279
  // stringifiedParam mirrors the CLI form that validateCommand /
279
280
  // validateParameter expect — B-1 runs on the string representation.
@@ -291,52 +292,37 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
291
292
  context: { deviceId },
292
293
  });
293
294
  }
295
+ const dryValidation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
296
+ if (!dryValidation.ok) {
297
+ return mcpError('usage', 2, dryValidation.error.message, {
298
+ hint: dryValidation.error.hint,
299
+ context: {
300
+ validationKind: dryValidation.error.kind,
301
+ deviceType: cached.type,
302
+ command: effectiveCommand,
303
+ },
304
+ });
305
+ }
306
+ if (dryValidation.normalized) {
307
+ effectiveCommand = dryValidation.normalized;
308
+ }
294
309
  // R-2: run B-1 param validation in dry-run too, so dry-run doesn't
295
310
  // falsely accept inputs the live API would reject.
296
311
  if (effectiveType !== 'customize') {
297
- const pv = validateParameter(cached.type, command, stringifiedParam);
312
+ const pv = validateParameter(cached.type, effectiveCommand, stringifiedParam);
298
313
  if (!pv.ok) {
299
314
  return mcpError('usage', 2, pv.error, {
300
315
  hint: 'Dry-run rejected the parameter client-side; the API would reject it too.',
301
- context: { deviceType: cached.type, command, parameter: stringifiedParam },
316
+ context: { deviceType: cached.type, command: effectiveCommand, parameter: stringifiedParam },
302
317
  });
303
318
  }
304
319
  if (pv.normalized !== undefined) {
305
320
  effectiveParameter = pv.normalized;
306
321
  }
307
322
  }
308
- // Bug #55: validateCommand is lenient by design (passes unknown device
309
- // types, ambiguous catalog matches). For dry-run we need stricter
310
- // checking — query the catalog directly and reject unknown commands
311
- // when the catalog has a definitive match.
312
- if (effectiveType !== 'customize') {
313
- const catalogMatch = findCatalogEntry(cached.type);
314
- if (catalogMatch && !Array.isArray(catalogMatch)) {
315
- const builtinCmds = catalogMatch.commands.filter((c) => c.commandType !== 'customize');
316
- if (builtinCmds.length > 0) {
317
- const exactMatch = builtinCmds.find((c) => c.command === command);
318
- const caseMatch = !exactMatch
319
- ? builtinCmds.find((c) => c.command.toLowerCase() === command.toLowerCase())
320
- : null;
321
- if (!exactMatch && !caseMatch) {
322
- const supported = [...new Set(builtinCmds.map((c) => c.command))].join(', ');
323
- return mcpError('usage', 2, `"${command}" is not a supported command for ${cached.name} (${cached.type}).`, {
324
- hint: `Supported commands: ${supported}`,
325
- context: { validationKind: 'unknown-command', deviceType: cached.type, command },
326
- });
327
- }
328
- }
329
- else if (catalogMatch.readOnly) {
330
- return mcpError('usage', 2, `${cached.name} (${cached.type}) is a read-only sensor — it has no control commands.`, {
331
- hint: "Use 'get_device_status' to read this device instead.",
332
- context: { validationKind: 'read-only-device', deviceType: cached.type, command },
333
- });
334
- }
335
- }
336
- }
337
323
  const wouldSend = {
338
324
  deviceId,
339
- command,
325
+ command: effectiveCommand,
340
326
  parameter: effectiveParameter ?? 'default',
341
327
  commandType: effectiveType,
342
328
  };
@@ -361,40 +347,46 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
361
347
  }
362
348
  typeName = physical ? physical.deviceType : ir.remoteType;
363
349
  }
364
- if (isDestructiveCommand(typeName, command, effectiveType) && !confirm) {
365
- const reason = getDestructiveReason(typeName, command, effectiveType);
350
+ if (isDestructiveCommand(typeName, effectiveCommand, effectiveType) && !confirm) {
351
+ const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
366
352
  const entry = typeName ? findCatalogEntry(typeName) : null;
367
353
  const spec = entry && !Array.isArray(entry)
368
- ? entry.commands.find((c) => c.command === command)
354
+ ? entry.commands.find((c) => c.command === effectiveCommand)
369
355
  : undefined;
370
356
  const hint = reason
371
357
  ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}`
372
358
  : 'Re-issue the call with confirm:true to proceed.';
373
- return mcpError('guard', 3, `Command "${command}" on device type "${typeName}" is destructive and requires confirm:true.`, {
359
+ return mcpError('guard', 3, `Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`, {
374
360
  hint,
375
361
  context: {
376
- command,
362
+ command: effectiveCommand,
377
363
  deviceType: typeName,
378
364
  description: spec?.description ?? null,
379
- ...(reason ? { destructiveReason: reason } : {}),
365
+ ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}),
380
366
  },
381
367
  });
382
368
  }
383
369
  // validateCommand covers command existence + required/unexpected-parameter.
384
370
  // stringifiedParam was computed once at the top of the handler so dry-run
385
371
  // and live paths share the same shape.
386
- const validation = validateCommand(deviceId, command, stringifiedParam, effectiveType);
372
+ const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
387
373
  if (!validation.ok) {
388
- return mcpError('usage', 2, validation.error.message, { hint: validation.error.hint, context: { validationKind: validation.error.kind } });
374
+ return mcpError('usage', 2, validation.error.message, {
375
+ hint: validation.error.hint,
376
+ context: { validationKind: validation.error.kind, deviceType: typeName, command: effectiveCommand },
377
+ });
378
+ }
379
+ if (validation.normalized) {
380
+ effectiveCommand = validation.normalized;
389
381
  }
390
382
  // R-2: run B-1 client-side parameter validator (range/format checks).
391
383
  // Customize commands (user-defined IR buttons) opt out — the catalog
392
384
  // cannot know their expected shape.
393
385
  if (effectiveType !== 'customize') {
394
- const pv = validateParameter(typeName, command, stringifiedParam);
386
+ const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
395
387
  if (!pv.ok) {
396
388
  return mcpError('usage', 2, pv.error, {
397
- context: { deviceType: typeName, command, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
389
+ context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
398
390
  });
399
391
  }
400
392
  if (pv.normalized !== undefined) {
@@ -403,7 +395,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
403
395
  }
404
396
  let result;
405
397
  try {
406
- result = await executeCommand(deviceId, command, effectiveParameter, effectiveType, undefined, {
398
+ result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, undefined, {
407
399
  idempotencyKey,
408
400
  });
409
401
  }
@@ -420,7 +412,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
420
412
  return apiErrorToMcpError(err);
421
413
  }
422
414
  const isIr = getCachedDevice(deviceId)?.category === 'ir';
423
- const structured = { ok: true, command, deviceId, result };
415
+ const structured = { ok: true, command: effectiveCommand, deviceId, result };
424
416
  if (isIr) {
425
417
  structured.verification = {
426
418
  verifiable: false,
@@ -524,6 +516,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
524
516
  description: z.string(),
525
517
  commandType: z.enum(['command', 'customize']).optional(),
526
518
  idempotent: z.boolean().optional(),
519
+ safetyTier: z.enum(['read', 'mutation', 'ir-fire-forget', 'destructive', 'maintenance']).optional(),
520
+ safetyReason: z.string().optional(),
527
521
  destructive: z.boolean().optional(),
528
522
  }).passthrough()),
529
523
  aliases: z.array(z.string()).optional(),
@@ -540,9 +534,22 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
540
534
  });
541
535
  }
542
536
  const hits = searchCatalog(query, limit);
543
- 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 };
544
551
  return {
545
- content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }],
552
+ content: [{ type: 'text', text: JSON.stringify(normalised, null, 2) }],
546
553
  structuredContent: structured,
547
554
  };
548
555
  });
@@ -599,16 +606,64 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
599
606
  _meta: { agentSafetyTier: 'read' },
600
607
  inputSchema: z
601
608
  .object({
602
- deviceId: z.string().min(1),
603
- since: z.string().optional(),
604
- from: z.string().optional(),
605
- to: z.string().optional(),
606
- metrics: z.array(z.string().min(1)).min(1),
607
- aggs: z.array(z.enum(ALL_AGG_FNS)).optional(),
608
- bucket: z.string().optional(),
609
- 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.`),
610
635
  })
611
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
+ },
612
667
  }, async (args) => {
613
668
  const opts = {
614
669
  since: args.since,
@@ -620,9 +675,21 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
620
675
  maxBucketSamples: args.maxBucketSamples,
621
676
  };
622
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;
623
690
  return {
624
691
  content: [{ type: 'text', text: JSON.stringify(res, null, 2) }],
625
- structuredContent: res,
692
+ structuredContent: structured,
626
693
  };
627
694
  });
628
695
  // ---- account_overview ---------------------------------------------------
@@ -737,6 +804,18 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
737
804
  }
738
805
  return server;
739
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
+ }
740
819
  export function registerMcpCommand(program) {
741
820
  const mcp = program
742
821
  .command('mcp')
@@ -786,19 +865,19 @@ Inspect locally:
786
865
  .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'))
787
866
  .option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
788
867
  .option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60')
868
+ .addHelpText('after', `
869
+ Examples:
870
+ $ switchbot mcp serve
871
+ $ switchbot mcp serve --port 8787
872
+ $ switchbot mcp serve --port 8787 --bind 127.0.0.1 --auth-token your-token
873
+ $ switchbot mcp serve --port 8787 --bind 0.0.0.0 --auth-token your-token
874
+ `)
789
875
  .action(async (options) => {
790
876
  try {
791
877
  if (options.port) {
792
878
  const port = Number(options.port);
793
879
  if (!Number.isFinite(port) || port < 1 || port > 65535) {
794
- const msg = `Invalid --port "${options.port}". Must be 1-65535.`;
795
- if (isJsonMode()) {
796
- emitJsonError({ code: 2, kind: 'usage', message: msg });
797
- }
798
- else {
799
- console.error(msg);
800
- }
801
- process.exit(2);
880
+ exitWithError(`Invalid --port "${options.port}". Must be 1-65535.`);
802
881
  }
803
882
  const bind = options.bind ?? '127.0.0.1';
804
883
  const authToken = options.authToken ?? process.env.SWITCHBOT_MCP_TOKEN;
@@ -807,14 +886,7 @@ Inspect locally:
807
886
  // Guard: refuse to bind non-localhost without auth
808
887
  const isLocalhost = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1';
809
888
  if (!isLocalhost && !authToken) {
810
- const msg = 'Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token <token> or bind to localhost (default).';
811
- if (isJsonMode()) {
812
- emitJsonError({ code: 2, kind: 'usage', message: msg });
813
- }
814
- else {
815
- console.error(msg);
816
- }
817
- process.exit(2);
889
+ exitWithError('Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token <token> or bind to localhost (default).');
818
890
  }
819
891
  const { createServer } = await import('node:http');
820
892
  const rateLimitMap = new Map();
@@ -1033,6 +1105,29 @@ process_uptime_seconds ${Math.floor(process.uptime())}
1033
1105
  const server = createSwitchBotMcpServer({ eventManager });
1034
1106
  const transport = new StdioServerTransport();
1035
1107
  await server.connect(transport);
1108
+ let isShuttingDown = false;
1109
+ const gracefulShutdown = async () => {
1110
+ if (isShuttingDown)
1111
+ return;
1112
+ isShuttingDown = true;
1113
+ console.error('Shutting down...');
1114
+ // Force exit after 30s if shutdown hangs (e.g. stuck MQTT disconnect).
1115
+ const forceExit = setTimeout(() => {
1116
+ console.error('Force exiting after 30s timeout');
1117
+ process.exit(1);
1118
+ }, 30000);
1119
+ forceExit.unref();
1120
+ try {
1121
+ await eventManager.shutdown();
1122
+ }
1123
+ catch (err) {
1124
+ console.error('Error during shutdown:', err instanceof Error ? err.message : String(err));
1125
+ }
1126
+ process.exit(0);
1127
+ };
1128
+ process.on('SIGTERM', gracefulShutdown);
1129
+ process.on('SIGINT', gracefulShutdown);
1130
+ process.stdin.on('end', gracefulShutdown);
1036
1131
  }
1037
1132
  catch (error) {
1038
1133
  handleError(error);
@@ -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',