@switchbot/openapi-cli 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,12 +4,15 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
4
4
  import { z } from 'zod';
5
5
  import { intArg, stringArg } from '../utils/arg-parsers.js';
6
6
  import { handleError, isJsonMode } from '../utils/output.js';
7
+ import { VERSION } from '../version.js';
7
8
  import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
8
9
  import { fetchScenes, executeScene } from '../lib/scenes.js';
9
10
  import { findCatalogEntry } from '../devices/catalog.js';
10
11
  import { getCachedDevice } from '../devices/cache.js';
11
12
  import { EventSubscriptionManager } from '../mcp/events-subscription.js';
12
13
  import { deviceHistoryStore } from '../mcp/device-history.js';
14
+ import { queryDeviceHistory } from '../devices/history-query.js';
15
+ import { aggregateDeviceHistory, ALL_AGG_FNS, MAX_SAMPLE_CAP, } from '../devices/history-agg.js';
13
16
  import { todayUsage } from '../utils/quota.js';
14
17
  import { describeCache } from '../devices/cache.js';
15
18
  import { withRequestContext } from '../lib/request-context.js';
@@ -32,7 +35,7 @@ export function createSwitchBotMcpServer(options) {
32
35
  const eventManager = options?.eventManager;
33
36
  const server = new McpServer({
34
37
  name: 'switchbot',
35
- version: '2.0.0',
38
+ version: VERSION,
36
39
  }, {
37
40
  capabilities: { tools: {}, resources: {} },
38
41
  instructions: `SwitchBot is an IoT smart home brand by Wonderlabs, Inc. This MCP server controls physical devices \
@@ -59,7 +62,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
59
62
  server.registerTool('list_devices', {
60
63
  title: 'List all devices on the account',
61
64
  description: 'Fetch the complete inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local metadata cache and groups devices by type. Use this as the bootstrap call to discover available deviceIds. Devices without enableCloudService cannot receive commands via API. IR remotes depend on a Hub for connectivity.',
62
- inputSchema: {},
65
+ _meta: { agentSafetyTier: 'read' },
66
+ inputSchema: z.object({}).strict(),
63
67
  outputSchema: {
64
68
  deviceList: z.array(z.object({
65
69
  deviceId: z.string(),
@@ -94,9 +98,10 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
94
98
  server.registerTool('get_device_status', {
95
99
  title: 'Get live status for a device',
96
100
  description: 'Query the real-time status payload for a physical device. IR remotes have no status channel and will error.',
97
- inputSchema: {
101
+ _meta: { agentSafetyTier: 'read' },
102
+ inputSchema: z.object({
98
103
  deviceId: z.string().describe('Device ID from list_devices'),
99
- },
104
+ }).strict(),
100
105
  outputSchema: {
101
106
  status: z.object({
102
107
  deviceId: z.string().optional(),
@@ -118,10 +123,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
118
123
  description: 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' +
119
124
  'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' +
120
125
  'Omit deviceId to list all devices with stored history.',
121
- inputSchema: {
126
+ _meta: { agentSafetyTier: 'read' },
127
+ inputSchema: z.object({
122
128
  deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'),
123
129
  limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'),
124
- },
130
+ }).strict(),
125
131
  outputSchema: {
126
132
  deviceId: z.string().optional(),
127
133
  latest: z.unknown().optional(),
@@ -146,11 +152,54 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
146
152
  structuredContent: result,
147
153
  };
148
154
  });
155
+ // ---- query_device_history --------------------------------------------------
156
+ server.registerTool('query_device_history', {
157
+ title: 'Query time-ranged device history',
158
+ description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) ' +
159
+ 'filtered by a relative duration (since) or absolute ISO-8601 range (from/to). ' +
160
+ 'No API call — zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
161
+ _meta: { agentSafetyTier: 'read' },
162
+ inputSchema: z.object({
163
+ deviceId: z.string().describe('Device ID to query'),
164
+ since: z.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
165
+ from: z.string().optional().describe('Range start (ISO-8601).'),
166
+ to: z.string().optional().describe('Range end (ISO-8601).'),
167
+ fields: z.array(z.string()).optional().describe('Project these payload fields; omit for the full payload.'),
168
+ limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000).'),
169
+ }).strict(),
170
+ outputSchema: {
171
+ deviceId: z.string(),
172
+ count: z.number().int(),
173
+ records: z.array(z.object({
174
+ t: z.string(),
175
+ topic: z.string(),
176
+ deviceType: z.string().optional(),
177
+ payload: z.unknown(),
178
+ })),
179
+ },
180
+ }, async ({ deviceId, since, from, to, fields, limit }) => {
181
+ if (since && (from || to)) {
182
+ return mcpError('usage', 2, '--since is mutually exclusive with --from/--to.');
183
+ }
184
+ try {
185
+ const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit });
186
+ const result = { deviceId, count: records.length, records };
187
+ return {
188
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
189
+ structuredContent: result,
190
+ };
191
+ }
192
+ catch (err) {
193
+ const msg = err instanceof Error ? err.message : 'history query failed';
194
+ return mcpError('usage', 2, msg);
195
+ }
196
+ });
149
197
  // ---- send_command ---------------------------------------------------------
150
198
  server.registerTool('send_command', {
151
199
  title: 'Send a control command to a device',
152
200
  description: 'Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands (Smart Lock unlock, Garage Door open, Keypad createKey/deleteKey) require confirm:true to proceed; otherwise rejected. Commands are validated offline against the device catalog. Use idempotencyKey to safely deduplicate retries within 60 seconds.',
153
- inputSchema: {
201
+ _meta: { agentSafetyTier: 'action' },
202
+ inputSchema: z.object({
154
203
  deviceId: z.string().describe('Device ID from list_devices'),
155
204
  command: z.string().describe('Command name, case-sensitive (e.g. turnOn, setColor, unlock)'),
156
205
  parameter: z
@@ -167,15 +216,52 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
167
216
  .optional()
168
217
  .default(false)
169
218
  .describe('Required true for destructive commands (unlock, garage open, createKey, ...)'),
170
- },
219
+ idempotencyKey: z
220
+ .string()
221
+ .optional()
222
+ .describe('Deduplication key — repeat calls with the same key within 60s replay the first result (adds replayed:true). Same key + different (command, parameter) within 60s returns an idempotency_conflict guard error.'),
223
+ dryRun: z
224
+ .boolean()
225
+ .optional()
226
+ .describe('When true, do not call the API — return { ok:true, dryRun:true, wouldSend:{...} } instead.'),
227
+ }).strict(),
171
228
  outputSchema: {
172
229
  ok: z.literal(true),
173
- command: z.string(),
174
- deviceId: z.string(),
175
- result: z.unknown().describe('API response body from SwitchBot'),
230
+ command: z.string().optional(),
231
+ deviceId: z.string().optional(),
232
+ result: z.unknown().optional().describe('API response body from SwitchBot (absent on dryRun)'),
233
+ verification: z
234
+ .object({
235
+ verifiable: z.boolean(),
236
+ reason: z.string(),
237
+ suggestedFollowup: z.string(),
238
+ })
239
+ .optional()
240
+ .describe('Present when the target is an IR device. IR is unidirectional — agents should treat the success as "signal sent" not "state changed".'),
241
+ dryRun: z.literal(true).optional().describe('Present when dryRun:true was requested'),
242
+ wouldSend: z.object({
243
+ deviceId: z.string(),
244
+ command: z.string(),
245
+ parameter: z.unknown(),
246
+ commandType: z.string(),
247
+ }).optional().describe('The request shape that would have been POSTed (present when dryRun:true)'),
176
248
  },
177
- }, async ({ deviceId, command, parameter, commandType, confirm }) => {
249
+ }, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
178
250
  const effectiveType = commandType ?? 'command';
251
+ // dryRun early-return — no API call, no validation against live device list
252
+ if (dryRun) {
253
+ const wouldSend = {
254
+ deviceId,
255
+ command,
256
+ parameter: parameter ?? 'default',
257
+ commandType: effectiveType,
258
+ };
259
+ const structured = { ok: true, dryRun: true, wouldSend };
260
+ return {
261
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
262
+ structuredContent: structured,
263
+ };
264
+ }
179
265
  // Resolve the device's catalog type via cache or a fresh lookup so we
180
266
  // can evaluate destructive/validation without an extra round-trip if
181
267
  // the cache is warm.
@@ -217,8 +303,33 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
217
303
  if (!validation.ok) {
218
304
  return mcpError('usage', 2, validation.error.message, { hint: validation.error.hint, context: { validationKind: validation.error.kind } });
219
305
  }
220
- const result = await executeCommand(deviceId, command, parameter, effectiveType);
306
+ let result;
307
+ try {
308
+ result = await executeCommand(deviceId, command, parameter, effectiveType, undefined, {
309
+ idempotencyKey,
310
+ });
311
+ }
312
+ catch (err) {
313
+ if (err instanceof Error && err.name === 'IdempotencyConflictError') {
314
+ return mcpError('guard', 2, err.message, {
315
+ hint: 'Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).',
316
+ context: {
317
+ existingShape: err.existingShape,
318
+ newShape: err.newShape,
319
+ },
320
+ });
321
+ }
322
+ throw err;
323
+ }
324
+ const isIr = getCachedDevice(deviceId)?.category === 'ir';
221
325
  const structured = { ok: true, command, deviceId, result };
326
+ if (isIr) {
327
+ structured.verification = {
328
+ verifiable: false,
329
+ reason: 'IR transmission is unidirectional; no receipt acknowledgment is possible.',
330
+ suggestedFollowup: 'Confirm visible change manually or via a paired state sensor.',
331
+ };
332
+ }
222
333
  return {
223
334
  content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
224
335
  structuredContent: structured,
@@ -228,14 +339,31 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
228
339
  server.registerTool('run_scene', {
229
340
  title: 'Execute a manual scene',
230
341
  description: 'Execute a manual SwitchBot scene by its sceneId (from list_scenes).',
231
- inputSchema: {
342
+ _meta: { agentSafetyTier: 'action' },
343
+ inputSchema: z.object({
232
344
  sceneId: z.string().describe('Scene ID from list_scenes'),
233
- },
345
+ dryRun: z
346
+ .boolean()
347
+ .optional()
348
+ .describe('When true, do not call the API — return { ok:true, dryRun:true, wouldSend:{...} } instead.'),
349
+ }).strict(),
234
350
  outputSchema: {
235
351
  ok: z.literal(true),
236
- sceneId: z.string(),
352
+ sceneId: z.string().optional(),
353
+ dryRun: z.literal(true).optional().describe('Present when dryRun:true was requested'),
354
+ wouldSend: z.object({
355
+ sceneId: z.string(),
356
+ }).optional().describe('The request shape that would have been POSTed (present when dryRun:true)'),
237
357
  },
238
- }, async ({ sceneId }) => {
358
+ }, async ({ sceneId, dryRun }) => {
359
+ if (dryRun) {
360
+ const wouldSend = { sceneId };
361
+ const structured = { ok: true, dryRun: true, wouldSend };
362
+ return {
363
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
364
+ structuredContent: structured,
365
+ };
366
+ }
239
367
  await executeScene(sceneId);
240
368
  const structured = { ok: true, sceneId };
241
369
  return {
@@ -247,7 +375,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
247
375
  server.registerTool('list_scenes', {
248
376
  title: 'List all manual scenes',
249
377
  description: 'Fetch all manual scenes configured in the SwitchBot app.',
250
- inputSchema: {},
378
+ _meta: { agentSafetyTier: 'read' },
379
+ inputSchema: z.object({}).strict(),
251
380
  outputSchema: {
252
381
  scenes: z.array(z.object({ sceneId: z.string(), sceneName: z.string() })),
253
382
  },
@@ -262,10 +391,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
262
391
  server.registerTool('search_catalog', {
263
392
  title: 'Search the offline device catalog',
264
393
  description: 'Search the built-in device catalog by type name or alias. Returns matching entries with their commands, roles, destructive flags, and status fields. No API call.',
265
- inputSchema: {
394
+ _meta: { agentSafetyTier: 'read' },
395
+ inputSchema: z.object({
266
396
  query: z.string().describe('Search query (matches type and aliases, case-insensitive). Use empty string to list all.'),
267
397
  limit: z.number().int().min(1).max(100).optional().default(20).describe('Max entries returned (default 20)'),
268
- },
398
+ }).strict(),
269
399
  outputSchema: {
270
400
  results: z.array(z.object({
271
401
  type: z.string(),
@@ -297,10 +427,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
297
427
  server.registerTool('describe_device', {
298
428
  title: 'Describe a specific device',
299
429
  description: 'Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.',
300
- inputSchema: {
430
+ _meta: { agentSafetyTier: 'read' },
431
+ inputSchema: z.object({
301
432
  deviceId: z.string().describe('Device ID from list_devices'),
302
433
  live: z.boolean().optional().default(false).describe('Also fetch live /status values (costs 1 extra API call)'),
303
- },
434
+ }).strict(),
304
435
  outputSchema: {
305
436
  device: z.object({
306
437
  device: z.object({ deviceId: z.string(), deviceName: z.string() }).passthrough(),
@@ -338,11 +469,45 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
338
469
  throw err;
339
470
  }
340
471
  });
472
+ // ---- aggregate_device_history --------------------------------------------
473
+ server.registerTool('aggregate_device_history', {
474
+ title: 'Aggregate device history',
475
+ description: 'Bucketed statistics (count/min/max/avg/sum/p50/p95) over JSONL-recorded device history. Read-only; no network calls.',
476
+ _meta: { agentSafetyTier: 'read' },
477
+ inputSchema: z
478
+ .object({
479
+ deviceId: z.string().min(1),
480
+ since: z.string().optional(),
481
+ from: z.string().optional(),
482
+ to: z.string().optional(),
483
+ metrics: z.array(z.string().min(1)).min(1),
484
+ aggs: z.array(z.enum(ALL_AGG_FNS)).optional(),
485
+ bucket: z.string().optional(),
486
+ maxBucketSamples: z.number().int().positive().max(MAX_SAMPLE_CAP).optional(),
487
+ })
488
+ .strict(),
489
+ }, async (args) => {
490
+ const opts = {
491
+ since: args.since,
492
+ from: args.from,
493
+ to: args.to,
494
+ metrics: args.metrics,
495
+ aggs: args.aggs,
496
+ bucket: args.bucket,
497
+ maxBucketSamples: args.maxBucketSamples,
498
+ };
499
+ const res = await aggregateDeviceHistory(args.deviceId, opts);
500
+ return {
501
+ content: [{ type: 'text', text: JSON.stringify(res, null, 2) }],
502
+ structuredContent: res,
503
+ };
504
+ });
341
505
  // ---- account_overview ---------------------------------------------------
342
506
  server.registerTool('account_overview', {
343
507
  title: 'Bootstrap account overview',
344
508
  description: 'Get a complete account snapshot: devices, scenes, quota usage, cache status, and MQTT connection state. Use this for cold-start initialization or periodic health checks.',
345
- inputSchema: {},
509
+ _meta: { agentSafetyTier: 'read' },
510
+ inputSchema: z.object({}).strict(),
346
511
  outputSchema: {
347
512
  version: z.string(),
348
513
  schemaVersion: z.string(),
@@ -393,7 +558,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
393
558
  const cacheInfo = describeCache();
394
559
  const quota = todayUsage();
395
560
  const overview = {
396
- version: '2.0.0',
561
+ version: VERSION,
397
562
  schemaVersion: '1.1',
398
563
  devices: deviceList.deviceList.map(toMcpDeviceListShape),
399
564
  infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
@@ -454,15 +619,18 @@ export function registerMcpCommand(program) {
454
619
  .command('mcp')
455
620
  .description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
456
621
  .addHelpText('after', `
457
- The MCP server exposes eight tools:
458
- - list_devices fetch all physical + IR devices
459
- - get_device_status live status for a physical device
460
- - send_command control a device (destructive commands need confirm:true)
461
- - list_scenes list all manual scenes
462
- - run_scene execute a manual scene
463
- - search_catalog offline catalog search by type/alias
464
- - describe_device metadata + commands + (optionally) live status for one device
465
- - account_overview single cold-start snapshot: devices + scenes + quota + cache + MQTT state
622
+ The MCP server exposes eleven tools:
623
+ - list_devices fetch all physical + IR devices
624
+ - get_device_status live status for a physical device
625
+ - send_command control a device (destructive commands need confirm:true)
626
+ - list_scenes list all manual scenes
627
+ - run_scene execute a manual scene
628
+ - search_catalog offline catalog search by type/alias
629
+ - describe_device metadata + commands + (optionally) live status for one device
630
+ - account_overview single cold-start snapshot: devices + scenes + quota + cache + MQTT state
631
+ - get_device_history fetch raw JSONL history records for a device
632
+ - query_device_history filter + page history records with field/time predicates
633
+ - aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records
466
634
 
467
635
  Resource (read-only):
468
636
  - switchbot://events snapshot of recent MQTT shadow events from the ring buffer
@@ -571,7 +739,7 @@ Inspect locally:
571
739
  res.writeHead(200, { 'Content-Type': 'application/json' });
572
740
  res.end(JSON.stringify({
573
741
  ok: true,
574
- version: '2.0.0',
742
+ version: VERSION,
575
743
  pid: process.pid,
576
744
  uptimeSec: Math.floor(process.uptime()),
577
745
  }));
@@ -581,7 +749,7 @@ Inspect locally:
581
749
  const state = eventManager.getState();
582
750
  const ready = state !== 'failed' && state !== 'disabled';
583
751
  const status = ready ? 200 : 503;
584
- const body = { ready, version: '2.0.0', mqtt: state };
752
+ const body = { ready, version: VERSION, mqtt: state };
585
753
  if (!ready)
586
754
  body.reason = state === 'disabled' ? 'mqtt disabled' : 'mqtt failed';
587
755
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -182,7 +182,12 @@ Workflow:
182
182
  .command('schema')
183
183
  .description('Print the JSON Schema for the plan format')
184
184
  .action(() => {
185
- printJson(PLAN_JSON_SCHEMA);
185
+ printJson({
186
+ ...PLAN_JSON_SCHEMA,
187
+ agentNotes: {
188
+ deviceNameStrategy: "Plan step `deviceName` fields are resolved with the `require-unique` strategy (same default as `devices command`). Plans that expect a specific device should pin `deviceId` instead.",
189
+ },
190
+ });
186
191
  });
187
192
  plan
188
193
  .command('validate')
@@ -11,17 +11,19 @@ is a best-effort mirror of the SwitchBot 10,000/day limit — it does not
11
11
  include requests made outside this CLI (mobile app, other scripts).
12
12
 
13
13
  Subcommands:
14
- status Show today's usage and the last 7 days
14
+ status Show today's usage and the last 7 days (alias: show)
15
15
  reset Delete the local counter file
16
16
 
17
17
  Examples:
18
18
  $ switchbot quota status
19
+ $ switchbot quota show # alias of 'status'
19
20
  $ switchbot quota status --json
20
21
  $ switchbot quota reset
21
22
  `);
22
23
  quota
23
24
  .command('status')
24
- .description("Show today's usage and the last 7 days")
25
+ .alias('show')
26
+ .description("Show today's usage and the last 7 days (alias: show)")
25
27
  .action(() => {
26
28
  const usage = todayUsage();
27
29
  const history = loadQuota();
@@ -1,4 +1,4 @@
1
- import { printJson, isJsonMode, handleError } from '../utils/output.js';
1
+ import { printJson, isJsonMode, handleError, StructuredUsageError } from '../utils/output.js';
2
2
  import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
3
3
  import { fetchScenes, executeScene } from '../lib/scenes.js';
4
4
  export function registerScenesCommand(program) {
@@ -59,4 +59,46 @@ Example:
59
59
  handleError(error);
60
60
  }
61
61
  });
62
+ // switchbot scenes describe <sceneId>
63
+ scenes
64
+ .command('describe')
65
+ .description('Show metadata for a scene by its ID (SwitchBot API v1.1 does not expose step detail)')
66
+ .argument('<sceneId>', 'Scene ID from "scenes list"')
67
+ .addHelpText('after', `
68
+ Note: SwitchBot API v1.1 does not return scene step detail. Only the scene name is available.
69
+
70
+ Example:
71
+ $ switchbot scenes describe T12345678
72
+ `)
73
+ .action(async (sceneId) => {
74
+ try {
75
+ const sceneList = await fetchScenes();
76
+ const found = sceneList.find((s) => s.sceneId === sceneId);
77
+ if (!found) {
78
+ throw new StructuredUsageError(`scene not found: ${sceneId}`, {
79
+ error: 'scene_not_found',
80
+ sceneId,
81
+ candidates: sceneList.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })),
82
+ });
83
+ }
84
+ const result = {
85
+ sceneId: found.sceneId,
86
+ sceneName: found.sceneName,
87
+ stepCount: null,
88
+ note: 'SwitchBot API v1.1 does not expose scene steps — displayed name only',
89
+ };
90
+ if (isJsonMode()) {
91
+ printJson(result);
92
+ }
93
+ else {
94
+ console.log(`sceneId: ${result.sceneId}`);
95
+ console.log(`sceneName: ${result.sceneName}`);
96
+ console.log(`stepCount: (not available)`);
97
+ console.log(`note: ${result.note}`);
98
+ }
99
+ }
100
+ catch (error) {
101
+ handleError(error);
102
+ }
103
+ });
62
104
  }
@@ -1,6 +1,7 @@
1
1
  import { enumArg, stringArg } from '../utils/arg-parsers.js';
2
2
  import { printJson } from '../utils/output.js';
3
3
  import { getEffectiveCatalog } from '../devices/catalog.js';
4
+ import { loadCache } from '../devices/cache.js';
4
5
  function toSchemaEntry(e) {
5
6
  return {
6
7
  type: e.type,
@@ -24,6 +25,30 @@ function toSchemaCommand(c) {
24
25
  ...(c.exampleParams ? { exampleParams: c.exampleParams } : {}),
25
26
  };
26
27
  }
28
+ function toCompactEntry(e) {
29
+ return {
30
+ type: e.type,
31
+ category: e.category,
32
+ role: e.role ?? null,
33
+ 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
+ })),
41
+ statusFields: e.statusFields ?? [],
42
+ };
43
+ }
44
+ function projectFields(entry, fields) {
45
+ const out = {};
46
+ for (const f of fields) {
47
+ if (f in entry)
48
+ out[f] = entry[f];
49
+ }
50
+ return out;
51
+ }
27
52
  export function registerSchemaCommand(program) {
28
53
  const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'];
29
54
  const CATEGORIES = ['physical', 'ir'];
@@ -32,20 +57,41 @@ export function registerSchemaCommand(program) {
32
57
  .description('Export the device catalog as structured JSON (for agent prompts / tooling)');
33
58
  schema
34
59
  .command('export')
35
- .description('Print the full catalog as structured JSON (one object per type)')
60
+ .description('Print the catalog as structured JSON (one object per type)')
36
61
  .option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type'))
62
+ .option('--types <csv>', 'Restrict to multiple device types (comma-separated)', stringArg('--types'))
37
63
  .option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES))
38
64
  .option('--category <cat>', 'Restrict to "physical" or "ir"', enumArg('--category', CATEGORIES))
65
+ .option('--compact', 'Drop descriptions/aliases/example params — emit ~60% smaller payload. Useful for agent prompts.')
66
+ .option('--used', 'Restrict to device types present in the local devices cache (run "devices list" first)')
67
+ .option('--project <csv>', 'Project per-type fields (e.g. --project type,commands,statusFields)', stringArg('--project'))
39
68
  .addHelpText('after', `
40
69
  Output is always JSON (this command ignores --format). The output is a
41
70
  catalog export — not a formal JSON Schema standard document — suitable for
42
71
  pre-baking LLM prompts or regenerating docs when the catalog changes.
43
72
 
73
+ Size tips:
74
+ --compact --used Smallest realistic payload for a given account
75
+ (< 15 KB on most accounts).
76
+ --fields type,commands Strip statusFields / role / etc. when only
77
+ commands are needed.
78
+ --type + --compact Inspect one type with minimum footprint.
79
+
80
+ Common top-level fields:
81
+ schemaVersion CLI schema version (stable for agent contracts)
82
+ data.version Catalog schema version
83
+ data.types Array of SchemaEntry (or CompactSchemaEntry with --compact)
84
+ data._fetchedAt CLI-added; present on live-query responses ('devices status'),
85
+ not on this offline export.
86
+
44
87
  Examples:
45
88
  $ switchbot schema export > catalog.json
46
- $ switchbot schema export --type Bot | jq '.types[0].commands'
47
- $ switchbot schema export --role lighting | jq '[.types[].type]'
89
+ $ switchbot schema export --compact --used | wc -c # small prompt-ready payload
90
+ $ switchbot schema export --type Bot | jq '.data.types[0].commands'
91
+ $ switchbot schema export --types "Bot,Curtain,Color Bulb"
92
+ $ switchbot schema export --role lighting | jq '[.data.types[].type]'
48
93
  $ switchbot schema export --role security --category physical
94
+ $ switchbot schema export --project type,commands,statusFields
49
95
  `)
50
96
  .action((options) => {
51
97
  const catalog = getEffectiveCatalog();
@@ -55,6 +101,11 @@ Examples:
55
101
  filtered = filtered.filter((e) => e.type.toLowerCase() === q ||
56
102
  (e.aliases ?? []).some((a) => a.toLowerCase() === q));
57
103
  }
104
+ if (options.types) {
105
+ const set = new Set(options.types.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean));
106
+ filtered = filtered.filter((e) => set.has(e.type.toLowerCase()) ||
107
+ (e.aliases ?? []).some((a) => set.has(a.toLowerCase())));
108
+ }
58
109
  if (options.role) {
59
110
  const q = options.role.toLowerCase();
60
111
  filtered = filtered.filter((e) => (e.role ?? 'other') === q);
@@ -63,11 +114,56 @@ Examples:
63
114
  const q = options.category.toLowerCase();
64
115
  filtered = filtered.filter((e) => e.category === q);
65
116
  }
117
+ if (options.used) {
118
+ const cache = loadCache();
119
+ if (cache) {
120
+ const usedTypes = new Set(Object.values(cache.devices).map((d) => d.type.toLowerCase()));
121
+ filtered = filtered.filter((e) => usedTypes.has(e.type.toLowerCase()) ||
122
+ (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase())));
123
+ }
124
+ else {
125
+ filtered = [];
126
+ }
127
+ }
128
+ const mapped = options.compact
129
+ ? filtered.map(toCompactEntry)
130
+ : filtered.map(toSchemaEntry);
131
+ const projected = options.project
132
+ ? mapped.map((e) => projectFields(e, options.project.split(',').map((s) => s.trim()).filter(Boolean)))
133
+ : mapped;
66
134
  const payload = {
67
135
  version: '1.0',
68
- generatedAt: new Date().toISOString(),
69
- types: filtered.map(toSchemaEntry),
136
+ types: projected,
70
137
  };
138
+ if (!options.compact) {
139
+ payload.generatedAt = new Date().toISOString();
140
+ payload.cliAddedFields = [
141
+ {
142
+ field: '_fetchedAt',
143
+ appliesTo: ['devices status', 'devices describe'],
144
+ type: 'string (ISO-8601)',
145
+ description: 'CLI-synthesized timestamp indicating when this status response was fetched or served from the cache. Not part of the upstream SwitchBot API.',
146
+ },
147
+ {
148
+ field: 'replayed',
149
+ appliesTo: ['devices command (with --idempotency-key)'],
150
+ type: 'boolean',
151
+ description: 'CLI-synthesized flag — true when the response was served from the idempotency cache instead of re-executing the command.',
152
+ },
153
+ {
154
+ field: 'verification',
155
+ appliesTo: ['devices command'],
156
+ type: 'object',
157
+ description: 'CLI-synthesized receipt-acknowledgment metadata. For IR devices, verifiable:false signals that no device-side confirmation is possible.',
158
+ },
159
+ {
160
+ field: 'hints',
161
+ appliesTo: ['agent-bootstrap'],
162
+ type: 'string[]',
163
+ description: 'CLI-synthesized advisory messages for the calling agent. Always emitted; empty array ([]) means no hints to report — never null and not a disabled-field signal.',
164
+ },
165
+ ];
166
+ }
71
167
  printJson(payload);
72
168
  });
73
169
  }