@switchbot/openapi-cli 2.4.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.
@@ -116,6 +116,9 @@ Examples:
116
116
  scope: cachedDevices.length > 0 ? 'used' : 'all',
117
117
  types: catalogTypes,
118
118
  },
119
+ // hints: empty array means no hints to report; always emitted, never null.
120
+ // An empty array signals "nothing to act on" — agents should not treat
121
+ // it as a disabled or missing field.
119
122
  hints: cachedDevices.length === 0
120
123
  ? ['Run `switchbot devices list` once to populate the device cache for richer bootstrap output.']
121
124
  : [],
@@ -100,7 +100,7 @@ export function registerBatchCommand(devices) {
100
100
  .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
101
101
  .option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
102
102
  .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
103
- .option('--idempotency-key-prefix <prefix>', 'Prefix for idempotency keys (key per device: <prefix>-<deviceId>)', stringArg('--idempotency-key-prefix'))
103
+ .option('--idempotency-key-prefix <prefix>', 'Client-supplied prefix for idempotency keys (key per device: <prefix>-<deviceId>). process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key-prefix'))
104
104
  .addHelpText('after', `
105
105
  Targets are resolved in this priority order:
106
106
  1. --ids when present (explicit deviceIds)
@@ -46,6 +46,7 @@ Examples:
46
46
  `);
47
47
  cache
48
48
  .command('show')
49
+ .alias('status')
49
50
  .description('Summarize the cache files (paths, ages, entry counts)')
50
51
  .action(() => {
51
52
  const summary = describeCache();
@@ -28,6 +28,7 @@ const COMMAND_META = {
28
28
  // scenes
29
29
  'scenes list': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
30
30
  'scenes execute': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1500 },
31
+ 'scenes describe': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
31
32
  // webhook
32
33
  'webhook setup': { mutating: true, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'action', verifiability: 'local', typicalLatencyMs: 500 },
33
34
  'webhook query': { mutating: false, consumesQuota: true, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 500 },
@@ -51,6 +52,7 @@ const COMMAND_META = {
51
52
  'history replay': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 1000 },
52
53
  'history range': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 50 },
53
54
  'history stats': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 20 },
55
+ 'history aggregate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 80 },
54
56
  'plan run': { mutating: true, consumesQuota: true, idempotencySupported: true, agentSafetyTier: 'action', verifiability: 'deviceDependent', typicalLatencyMs: 2000 },
55
57
  'plan validate': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
56
58
  'plan schema': { mutating: false, consumesQuota: false, idempotencySupported: false, agentSafetyTier: 'read', verifiability: 'local', typicalLatencyMs: 10 },
@@ -88,6 +90,7 @@ const MCP_TOOLS = [
88
90
  'account_overview',
89
91
  'get_device_history',
90
92
  'query_device_history',
93
+ 'aggregate_device_history',
91
94
  ];
92
95
  const IDEMPOTENCY_CONTRACT = {
93
96
  flag: '--idempotency-key <key>',
@@ -307,7 +307,7 @@ Examples:
307
307
  .option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
308
308
  .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
309
309
  .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
310
- .option('--idempotency-key <key>', 'Idempotency key for deduplication (60s window; same key replays cached result)', stringArg('--idempotency-key'))
310
+ .option('--idempotency-key <key>', 'Client-supplied key to dedupe retries. process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key'))
311
311
  .addHelpText('after', `
312
312
  ────────────────────────────────────────────────────────────────────────
313
313
  For the full list of commands a specific device supports — and their
@@ -385,6 +385,13 @@ Examples:
385
385
  else {
386
386
  console.log(JSON.stringify(ctl));
387
387
  }
388
+ // Persist to __control.jsonl — best-effort, never blocks the stream.
389
+ try {
390
+ deviceHistoryStore.recordControl(ctl);
391
+ }
392
+ catch {
393
+ // swallow
394
+ }
388
395
  };
389
396
  const unsubState = client.onStateChange((state) => {
390
397
  if (!isJsonMode()) {
@@ -5,6 +5,7 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.
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';
8
+ import { aggregateDeviceHistory, ALL_AGG_FNS, } from '../devices/history-agg.js';
8
9
  const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
9
10
  export function registerHistoryCommand(program) {
10
11
  const history = program
@@ -186,8 +187,8 @@ Examples:
186
187
  .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
187
188
  .addHelpText('after', `
188
189
  See docs/audit-log.md for the audit log format. Exit code:
189
- 0 every line parses and carries the current auditVersion
190
- 1 one or more lines are malformed OR the file is missing
190
+ 0 every line parses and carries the current auditVersion, or file is missing (warn)
191
+ 1 one or more lines are malformed or schema drift detected
191
192
  2 (usage) — not emitted by this subcommand
192
193
 
193
194
  Examples:
@@ -197,30 +198,132 @@ Examples:
197
198
  .action((options) => {
198
199
  const file = options.file ?? DEFAULT_AUDIT;
199
200
  const report = verifyAudit(file);
201
+ // Determine status and exit code
202
+ let status = 'ok';
203
+ let exitCode = 0;
204
+ if (report.fileMissing) {
205
+ status = 'warn';
206
+ }
207
+ else if (report.malformedLines > 0 || report.unversionedEntries > 0) {
208
+ status = 'fail';
209
+ exitCode = 1;
210
+ }
200
211
  if (isJsonMode()) {
201
- printJson(report);
212
+ const output = {
213
+ status,
214
+ fileMissing: report.fileMissing === true,
215
+ parsed: report.parsedLines,
216
+ malformed: report.malformedLines,
217
+ unversioned: report.unversionedEntries,
218
+ message: report.fileMissing
219
+ ? 'Audit log file not found (fresh install)'
220
+ : report.malformedLines > 0 || report.unversionedEntries > 0
221
+ ? 'Audit log has malformed or unversioned entries'
222
+ : 'Audit log is valid',
223
+ };
224
+ printJson(output);
202
225
  }
203
226
  else {
204
- console.log(`Audit log: ${report.file}`);
205
- console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`);
206
- console.log(`Malformed: ${report.malformedLines}`);
207
- console.log(`Unversioned: ${report.unversionedEntries}`);
208
- const versions = Object.entries(report.versionCounts)
209
- .map(([v, n]) => `${v}:${n}`)
210
- .join(', ');
211
- console.log(`Version counts: ${versions || '—'}`);
212
- if (report.earliest)
213
- console.log(`Earliest: ${report.earliest}`);
214
- if (report.latest)
215
- console.log(`Latest: ${report.latest}`);
216
- if (report.problems.length > 0) {
217
- console.log('\nProblems:');
218
- for (const p of report.problems) {
219
- console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`);
227
+ if (report.fileMissing) {
228
+ console.log(`Audit log: ${report.file} (missing — fresh install)`);
229
+ console.log(`Status: ✓ warn (expected for new accounts)`);
230
+ }
231
+ else {
232
+ console.log(`Audit log: ${report.file}`);
233
+ console.log(`Parsed lines: ${report.parsedLines} / ${report.totalLines}`);
234
+ console.log(`Malformed: ${report.malformedLines}`);
235
+ console.log(`Unversioned: ${report.unversionedEntries}`);
236
+ const versions = Object.entries(report.versionCounts)
237
+ .map(([v, n]) => `${v}:${n}`)
238
+ .join(', ');
239
+ console.log(`Version counts: ${versions || '—'}`);
240
+ if (report.earliest)
241
+ console.log(`Earliest: ${report.earliest}`);
242
+ if (report.latest)
243
+ console.log(`Latest: ${report.latest}`);
244
+ if (report.problems.length > 0) {
245
+ console.log('\nProblems:');
246
+ for (const p of report.problems) {
247
+ console.log(` line ${p.line}: ${p.reason}${p.preview ? ` — "${p.preview}"` : ''}`);
248
+ }
220
249
  }
221
250
  }
222
251
  }
223
- const ok = report.malformedLines === 0 && report.problems.length === 0;
224
- process.exit(ok ? 0 : 1);
252
+ process.exit(exitCode);
253
+ });
254
+ history
255
+ .command('aggregate')
256
+ .description('Aggregate time-ranged device history metrics into buckets')
257
+ .argument('<deviceId>', 'Device ID to aggregate')
258
+ .option('--since <duration>', 'Relative window ending now, e.g. "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
259
+ .option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
260
+ .option('--to <iso>', 'Range end (ISO-8601)', stringArg('--to'))
261
+ .option('--metric <name>', 'Payload field to aggregate (repeat for multiple)', (v, acc = []) => acc.concat(v), [])
262
+ .option('--agg <csv>', 'Comma-separated aggregation functions (count,min,max,avg,sum,p50,p95)', stringArg('--agg'))
263
+ .option('--bucket <duration>', 'Bucket width, e.g. "15m", "1h", "1d"', stringArg('--bucket'))
264
+ .option('--max-bucket-samples <n>', 'Max samples per bucket for quantiles (1–100000)', intArg('--max-bucket-samples', { min: 1, max: 100_000 }))
265
+ .action(async (deviceId, options) => {
266
+ const metrics = options.metric ?? [];
267
+ if (metrics.length === 0) {
268
+ handleError(new UsageError('at least one --metric is required.'));
269
+ }
270
+ if (options.since && (options.from || options.to)) {
271
+ handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
272
+ }
273
+ let aggs;
274
+ if (options.agg !== undefined) {
275
+ const parts = options.agg.split(',').map((s) => s.trim()).filter(Boolean);
276
+ const unknown = parts.filter((p) => !ALL_AGG_FNS.includes(p));
277
+ if (unknown.length > 0) {
278
+ handleError(new UsageError(`Unknown aggregation function(s): ${unknown.join(', ')}. Legal values: ${ALL_AGG_FNS.join(', ')}.`));
279
+ }
280
+ aggs = parts;
281
+ }
282
+ const aggOpts = {
283
+ metrics,
284
+ aggs,
285
+ since: options.since,
286
+ from: options.from,
287
+ to: options.to,
288
+ bucket: options.bucket,
289
+ maxBucketSamples: options.maxBucketSamples !== undefined ? Number(options.maxBucketSamples) : undefined,
290
+ };
291
+ try {
292
+ const res = await aggregateDeviceHistory(deviceId, aggOpts);
293
+ if (isJsonMode()) {
294
+ printJson(res);
295
+ return;
296
+ }
297
+ if (res.buckets.length === 0) {
298
+ console.log(`(no history records for ${deviceId} in requested range)`);
299
+ return;
300
+ }
301
+ const aggCols = res.aggs;
302
+ const cols = ['t', ...res.metrics.flatMap((m) => aggCols.map((a) => `${m}.${a}`))];
303
+ console.log(cols.join('\t'));
304
+ for (const bkt of res.buckets) {
305
+ const row = cols.map((col) => {
306
+ if (col === 't')
307
+ return bkt.t;
308
+ const [metric, agg] = col.split('.');
309
+ const val = bkt.metrics[metric]?.[agg];
310
+ return val !== undefined ? String(val) : '\u2014';
311
+ });
312
+ console.log(row.join('\t'));
313
+ }
314
+ if (res.partial) {
315
+ for (const note of res.notes) {
316
+ console.error('note: ' + note);
317
+ }
318
+ }
319
+ }
320
+ catch (err) {
321
+ if (err instanceof Error) {
322
+ if (/bucket/i.test(err.message) || /--since/i.test(err.message) || /--from/i.test(err.message) || /--to/i.test(err.message)) {
323
+ handleError(new UsageError(err.message));
324
+ }
325
+ }
326
+ handleError(err);
327
+ }
225
328
  });
226
329
  }
@@ -4,6 +4,7 @@ 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';
@@ -11,6 +12,7 @@ import { getCachedDevice } from '../devices/cache.js';
11
12
  import { EventSubscriptionManager } from '../mcp/events-subscription.js';
12
13
  import { deviceHistoryStore } from '../mcp/device-history.js';
13
14
  import { queryDeviceHistory } from '../devices/history-query.js';
15
+ import { aggregateDeviceHistory, ALL_AGG_FNS, MAX_SAMPLE_CAP, } from '../devices/history-agg.js';
14
16
  import { todayUsage } from '../utils/quota.js';
15
17
  import { describeCache } from '../devices/cache.js';
16
18
  import { withRequestContext } from '../lib/request-context.js';
@@ -33,7 +35,7 @@ export function createSwitchBotMcpServer(options) {
33
35
  const eventManager = options?.eventManager;
34
36
  const server = new McpServer({
35
37
  name: 'switchbot',
36
- version: '2.0.0',
38
+ version: VERSION,
37
39
  }, {
38
40
  capabilities: { tools: {}, resources: {} },
39
41
  instructions: `SwitchBot is an IoT smart home brand by Wonderlabs, Inc. This MCP server controls physical devices \
@@ -60,7 +62,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
60
62
  server.registerTool('list_devices', {
61
63
  title: 'List all devices on the account',
62
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.',
63
- inputSchema: {},
65
+ _meta: { agentSafetyTier: 'read' },
66
+ inputSchema: z.object({}).strict(),
64
67
  outputSchema: {
65
68
  deviceList: z.array(z.object({
66
69
  deviceId: z.string(),
@@ -95,9 +98,10 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
95
98
  server.registerTool('get_device_status', {
96
99
  title: 'Get live status for a device',
97
100
  description: 'Query the real-time status payload for a physical device. IR remotes have no status channel and will error.',
98
- inputSchema: {
101
+ _meta: { agentSafetyTier: 'read' },
102
+ inputSchema: z.object({
99
103
  deviceId: z.string().describe('Device ID from list_devices'),
100
- },
104
+ }).strict(),
101
105
  outputSchema: {
102
106
  status: z.object({
103
107
  deviceId: z.string().optional(),
@@ -119,10 +123,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
119
123
  description: 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' +
120
124
  'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' +
121
125
  'Omit deviceId to list all devices with stored history.',
122
- inputSchema: {
126
+ _meta: { agentSafetyTier: 'read' },
127
+ inputSchema: z.object({
123
128
  deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'),
124
129
  limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'),
125
- },
130
+ }).strict(),
126
131
  outputSchema: {
127
132
  deviceId: z.string().optional(),
128
133
  latest: z.unknown().optional(),
@@ -153,14 +158,15 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
153
158
  description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) ' +
154
159
  'filtered by a relative duration (since) or absolute ISO-8601 range (from/to). ' +
155
160
  'No API call — zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
156
- inputSchema: {
161
+ _meta: { agentSafetyTier: 'read' },
162
+ inputSchema: z.object({
157
163
  deviceId: z.string().describe('Device ID to query'),
158
164
  since: z.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
159
165
  from: z.string().optional().describe('Range start (ISO-8601).'),
160
166
  to: z.string().optional().describe('Range end (ISO-8601).'),
161
167
  fields: z.array(z.string()).optional().describe('Project these payload fields; omit for the full payload.'),
162
168
  limit: z.number().int().min(1).max(10000).optional().describe('Max records to return (default 1000).'),
163
- },
169
+ }).strict(),
164
170
  outputSchema: {
165
171
  deviceId: z.string(),
166
172
  count: z.number().int(),
@@ -192,7 +198,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
192
198
  server.registerTool('send_command', {
193
199
  title: 'Send a control command to a device',
194
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.',
195
- inputSchema: {
201
+ _meta: { agentSafetyTier: 'action' },
202
+ inputSchema: z.object({
196
203
  deviceId: z.string().describe('Device ID from list_devices'),
197
204
  command: z.string().describe('Command name, case-sensitive (e.g. turnOn, setColor, unlock)'),
198
205
  parameter: z
@@ -213,12 +220,16 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
213
220
  .string()
214
221
  .optional()
215
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.'),
216
- },
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(),
217
228
  outputSchema: {
218
229
  ok: z.literal(true),
219
- command: z.string(),
220
- deviceId: z.string(),
221
- 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)'),
222
233
  verification: z
223
234
  .object({
224
235
  verifiable: z.boolean(),
@@ -227,9 +238,30 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
227
238
  })
228
239
  .optional()
229
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)'),
230
248
  },
231
- }, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey }) => {
249
+ }, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
232
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
+ }
233
265
  // Resolve the device's catalog type via cache or a fresh lookup so we
234
266
  // can evaluate destructive/validation without an extra round-trip if
235
267
  // the cache is warm.
@@ -307,14 +339,31 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
307
339
  server.registerTool('run_scene', {
308
340
  title: 'Execute a manual scene',
309
341
  description: 'Execute a manual SwitchBot scene by its sceneId (from list_scenes).',
310
- inputSchema: {
342
+ _meta: { agentSafetyTier: 'action' },
343
+ inputSchema: z.object({
311
344
  sceneId: z.string().describe('Scene ID from list_scenes'),
312
- },
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(),
313
350
  outputSchema: {
314
351
  ok: z.literal(true),
315
- 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)'),
316
357
  },
317
- }, 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
+ }
318
367
  await executeScene(sceneId);
319
368
  const structured = { ok: true, sceneId };
320
369
  return {
@@ -326,7 +375,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
326
375
  server.registerTool('list_scenes', {
327
376
  title: 'List all manual scenes',
328
377
  description: 'Fetch all manual scenes configured in the SwitchBot app.',
329
- inputSchema: {},
378
+ _meta: { agentSafetyTier: 'read' },
379
+ inputSchema: z.object({}).strict(),
330
380
  outputSchema: {
331
381
  scenes: z.array(z.object({ sceneId: z.string(), sceneName: z.string() })),
332
382
  },
@@ -341,10 +391,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
341
391
  server.registerTool('search_catalog', {
342
392
  title: 'Search the offline device catalog',
343
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.',
344
- inputSchema: {
394
+ _meta: { agentSafetyTier: 'read' },
395
+ inputSchema: z.object({
345
396
  query: z.string().describe('Search query (matches type and aliases, case-insensitive). Use empty string to list all.'),
346
397
  limit: z.number().int().min(1).max(100).optional().default(20).describe('Max entries returned (default 20)'),
347
- },
398
+ }).strict(),
348
399
  outputSchema: {
349
400
  results: z.array(z.object({
350
401
  type: z.string(),
@@ -376,10 +427,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
376
427
  server.registerTool('describe_device', {
377
428
  title: 'Describe a specific device',
378
429
  description: 'Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.',
379
- inputSchema: {
430
+ _meta: { agentSafetyTier: 'read' },
431
+ inputSchema: z.object({
380
432
  deviceId: z.string().describe('Device ID from list_devices'),
381
433
  live: z.boolean().optional().default(false).describe('Also fetch live /status values (costs 1 extra API call)'),
382
- },
434
+ }).strict(),
383
435
  outputSchema: {
384
436
  device: z.object({
385
437
  device: z.object({ deviceId: z.string(), deviceName: z.string() }).passthrough(),
@@ -417,11 +469,45 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
417
469
  throw err;
418
470
  }
419
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
+ });
420
505
  // ---- account_overview ---------------------------------------------------
421
506
  server.registerTool('account_overview', {
422
507
  title: 'Bootstrap account overview',
423
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.',
424
- inputSchema: {},
509
+ _meta: { agentSafetyTier: 'read' },
510
+ inputSchema: z.object({}).strict(),
425
511
  outputSchema: {
426
512
  version: z.string(),
427
513
  schemaVersion: z.string(),
@@ -472,7 +558,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
472
558
  const cacheInfo = describeCache();
473
559
  const quota = todayUsage();
474
560
  const overview = {
475
- version: '2.0.0',
561
+ version: VERSION,
476
562
  schemaVersion: '1.1',
477
563
  devices: deviceList.deviceList.map(toMcpDeviceListShape),
478
564
  infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
@@ -533,15 +619,18 @@ export function registerMcpCommand(program) {
533
619
  .command('mcp')
534
620
  .description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
535
621
  .addHelpText('after', `
536
- The MCP server exposes eight tools:
537
- - list_devices fetch all physical + IR devices
538
- - get_device_status live status for a physical device
539
- - send_command control a device (destructive commands need confirm:true)
540
- - list_scenes list all manual scenes
541
- - run_scene execute a manual scene
542
- - search_catalog offline catalog search by type/alias
543
- - describe_device metadata + commands + (optionally) live status for one device
544
- - 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
545
634
 
546
635
  Resource (read-only):
547
636
  - switchbot://events snapshot of recent MQTT shadow events from the ring buffer
@@ -650,7 +739,7 @@ Inspect locally:
650
739
  res.writeHead(200, { 'Content-Type': 'application/json' });
651
740
  res.end(JSON.stringify({
652
741
  ok: true,
653
- version: '2.0.0',
742
+ version: VERSION,
654
743
  pid: process.pid,
655
744
  uptimeSec: Math.floor(process.uptime()),
656
745
  }));
@@ -660,7 +749,7 @@ Inspect locally:
660
749
  const state = eventManager.getState();
661
750
  const ready = state !== 'failed' && state !== 'disabled';
662
751
  const status = ready ? 200 : 503;
663
- const body = { ready, version: '2.0.0', mqtt: state };
752
+ const body = { ready, version: VERSION, mqtt: state };
664
753
  if (!ready)
665
754
  body.reason = state === 'disabled' ? 'mqtt disabled' : 'mqtt failed';
666
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')
@@ -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
  }
@@ -156,6 +156,12 @@ Examples:
156
156
  type: 'object',
157
157
  description: 'CLI-synthesized receipt-acknowledgment metadata. For IR devices, verifiable:false signals that no device-side confirmation is possible.',
158
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
+ },
159
165
  ];
160
166
  }
161
167
  printJson(payload);
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs';
2
+ import readline from 'node:readline';
3
+ import { jsonlFilesForDevice, parseDurationToMs, resolveRange } from './history-query.js';
4
+ export const ALL_AGG_FNS = ['count', 'min', 'max', 'avg', 'sum', 'p50', 'p95'];
5
+ export const DEFAULT_AGGS = ['count', 'avg'];
6
+ export const DEFAULT_SAMPLE_CAP = 10_000;
7
+ export const MAX_SAMPLE_CAP = 100_000;
8
+ export async function aggregateDeviceHistory(deviceId, opts) {
9
+ const { fromMs, toMs } = resolveRange(opts);
10
+ const aggs = (opts.aggs && opts.aggs.length > 0) ? opts.aggs : [...DEFAULT_AGGS];
11
+ const needQuantile = aggs.includes('p50') || aggs.includes('p95');
12
+ let bucketMs = null;
13
+ if (opts.bucket !== undefined) {
14
+ bucketMs = parseDurationToMs(opts.bucket);
15
+ if (bucketMs === null) {
16
+ throw new Error(`Invalid --bucket "${opts.bucket}". Expected e.g. "15m", "1h", "1d".`);
17
+ }
18
+ }
19
+ const sampleCap = Math.max(1, Math.min(opts.maxBucketSamples ?? DEFAULT_SAMPLE_CAP, MAX_SAMPLE_CAP));
20
+ let partial = false;
21
+ const notes = [];
22
+ // bucketKey (epoch ms; 0 when no --bucket) → metric name → Acc
23
+ const buckets = new Map();
24
+ for (const file of jsonlFilesForDevice(deviceId)) {
25
+ try {
26
+ const st = fs.statSync(file);
27
+ if (st.mtimeMs < fromMs)
28
+ continue;
29
+ }
30
+ catch {
31
+ continue;
32
+ }
33
+ const stream = fs.createReadStream(file, { encoding: 'utf-8' });
34
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
35
+ for await (const line of rl) {
36
+ if (!line)
37
+ continue;
38
+ let rec;
39
+ try {
40
+ rec = JSON.parse(line);
41
+ }
42
+ catch {
43
+ continue;
44
+ }
45
+ const tMs = Date.parse(rec.t);
46
+ if (!Number.isFinite(tMs) || tMs < fromMs || tMs > toMs)
47
+ continue;
48
+ const key = bucketMs !== null ? Math.floor(tMs / bucketMs) * bucketMs : 0;
49
+ let bkt = buckets.get(key);
50
+ if (!bkt) {
51
+ bkt = new Map();
52
+ buckets.set(key, bkt);
53
+ }
54
+ for (const metric of opts.metrics) {
55
+ const v = rec.payload?.[metric];
56
+ if (typeof v !== 'number' || !Number.isFinite(v))
57
+ continue;
58
+ let acc = bkt.get(metric);
59
+ if (!acc) {
60
+ acc = {
61
+ min: v,
62
+ max: v,
63
+ sum: 0,
64
+ count: 0,
65
+ samples: needQuantile ? [] : null,
66
+ sampleCapHit: false,
67
+ };
68
+ bkt.set(metric, acc);
69
+ }
70
+ acc.min = Math.min(acc.min, v);
71
+ acc.max = Math.max(acc.max, v);
72
+ acc.sum += v;
73
+ acc.count += 1;
74
+ if (acc.samples) {
75
+ if (acc.samples.length < sampleCap) {
76
+ acc.samples.push(v);
77
+ }
78
+ else if (!acc.sampleCapHit) {
79
+ acc.sampleCapHit = true;
80
+ partial = true;
81
+ notes.push(`bucket ${new Date(key).toISOString()} metric ${metric}: sample cap ${sampleCap} reached, quantiles approximate`);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ return finalize(deviceId, opts, aggs, buckets, partial, notes, fromMs, toMs);
88
+ }
89
+ function finalize(deviceId, opts, aggs, buckets, partial, notes, fromMs, toMs) {
90
+ const fromIso = Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : new Date(0).toISOString();
91
+ const toIso = Number.isFinite(toMs) ? new Date(toMs).toISOString() : new Date(Date.now()).toISOString();
92
+ const keys = [...buckets.keys()].sort((a, b) => a - b);
93
+ const outBuckets = [];
94
+ for (const key of keys) {
95
+ const perMetric = buckets.get(key);
96
+ const metricsOut = {};
97
+ for (const [metric, acc] of perMetric.entries()) {
98
+ if (acc.count === 0)
99
+ continue;
100
+ const r = {};
101
+ if (aggs.includes('count'))
102
+ r.count = acc.count;
103
+ if (aggs.includes('min'))
104
+ r.min = acc.min;
105
+ if (aggs.includes('max'))
106
+ r.max = acc.max;
107
+ if (aggs.includes('avg'))
108
+ r.avg = acc.sum / acc.count;
109
+ if (aggs.includes('sum'))
110
+ r.sum = acc.sum;
111
+ if ((aggs.includes('p50') || aggs.includes('p95')) && acc.samples) {
112
+ const sorted = [...acc.samples].sort((a, b) => a - b);
113
+ if (aggs.includes('p50'))
114
+ r.p50 = sorted[Math.floor(0.5 * (sorted.length - 1))];
115
+ if (aggs.includes('p95'))
116
+ r.p95 = sorted[Math.floor(0.95 * (sorted.length - 1))];
117
+ }
118
+ metricsOut[metric] = r;
119
+ }
120
+ if (Object.keys(metricsOut).length === 0)
121
+ continue;
122
+ outBuckets.push({
123
+ t: new Date(key).toISOString(),
124
+ metrics: metricsOut,
125
+ });
126
+ }
127
+ return {
128
+ deviceId,
129
+ bucket: opts.bucket,
130
+ from: fromIso,
131
+ to: toIso,
132
+ metrics: [...opts.metrics],
133
+ aggs: [...aggs],
134
+ buckets: outBuckets,
135
+ partial,
136
+ notes,
137
+ };
138
+ }
@@ -24,7 +24,7 @@ export function parseInstantToMs(spec) {
24
24
  const ms = Date.parse(spec);
25
25
  return Number.isFinite(ms) ? ms : null;
26
26
  }
27
- function resolveRange(opts) {
27
+ export function resolveRange(opts) {
28
28
  let fromMs = Number.NEGATIVE_INFINITY;
29
29
  let toMs = Number.POSITIVE_INFINITY;
30
30
  if (opts.since && (opts.from || opts.to)) {
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, CommanderError, InvalidArgumentError } from 'commander';
3
3
  import { createRequire } from 'node:module';
4
+ import chalk from 'chalk';
4
5
  import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
5
6
  import { parseDurationToMs } from './utils/flags.js';
6
7
  import { registerConfigCommand } from './commands/config.js';
@@ -21,6 +22,11 @@ import { registerCapabilitiesCommand } from './commands/capabilities.js';
21
22
  import { registerAgentBootstrapCommand } from './commands/agent-bootstrap.js';
22
23
  const require = createRequire(import.meta.url);
23
24
  const { version: pkgVersion } = require('../package.json');
25
+ // Early initialization: check for --no-color flag or NO_COLOR env var and disable chalk.
26
+ // This must happen before any commands run so all chalk output is affected.
27
+ if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) {
28
+ chalk.level = 0;
29
+ }
24
30
  const program = new Command();
25
31
  // Top-level subcommand names. Used by stringArg to produce clearer errors when
26
32
  // a value is omitted and the next argv token turns out to be a subcommand name.
@@ -44,6 +50,7 @@ program
44
50
  .name('switchbot')
45
51
  .description('Command-line tool for SwitchBot API v1.1')
46
52
  .version(pkgVersion)
53
+ .option('--no-color', 'Disable ANSI colors in output')
47
54
  .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
48
55
  .option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id, markdown', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id', 'markdown']))
49
56
  .option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
@@ -31,19 +31,30 @@ export class DeviceHistoryStore {
31
31
  existing.history = [entry, ...existing.history].slice(0, MAX_HISTORY);
32
32
  fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 });
33
33
  // 2. Append-only JSONL for range queries.
34
- this.appendJsonl(deviceId, entry);
34
+ this.writeJsonl(deviceId, entry);
35
35
  }
36
36
  catch {
37
37
  // best-effort — history loss is non-fatal
38
38
  }
39
39
  }
40
- appendJsonl(deviceId, entry) {
40
+ /** Append a mqtt control event (no deviceId) to the dedicated __control.jsonl file. */
41
+ recordControl(event) {
41
42
  try {
42
- const jsonlPath = path.join(this.dir, `${deviceId}.jsonl`);
43
- const line = JSON.stringify(entry) + '\n';
43
+ if (!fs.existsSync(this.dir))
44
+ fs.mkdirSync(this.dir, { recursive: true });
45
+ this.writeJsonl('__control', event);
46
+ }
47
+ catch {
48
+ // best-effort — never block the event stream
49
+ }
50
+ }
51
+ writeJsonl(fileKey, record) {
52
+ try {
53
+ const jsonlPath = path.join(this.dir, `${fileKey}.jsonl`);
54
+ const line = JSON.stringify(record) + '\n';
44
55
  const lineBytes = Buffer.byteLength(line, 'utf-8');
45
56
  // Seed size counter from disk on first touch (avoids drift across restarts).
46
- let size = this.jsonlSizes.get(deviceId);
57
+ let size = this.jsonlSizes.get(fileKey);
47
58
  if (size === undefined) {
48
59
  try {
49
60
  size = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0;
@@ -53,18 +64,18 @@ export class DeviceHistoryStore {
53
64
  }
54
65
  }
55
66
  if (size + lineBytes > JSONL_ROTATE_BYTES) {
56
- this.rotateJsonl(deviceId);
67
+ this.rotateJsonl(fileKey);
57
68
  size = 0;
58
69
  }
59
70
  fs.appendFileSync(jsonlPath, line, { mode: 0o600 });
60
- this.jsonlSizes.set(deviceId, size + lineBytes);
71
+ this.jsonlSizes.set(fileKey, size + lineBytes);
61
72
  }
62
73
  catch {
63
74
  // best-effort
64
75
  }
65
76
  }
66
- rotateJsonl(deviceId) {
67
- const base = path.join(this.dir, `${deviceId}.jsonl`);
77
+ rotateJsonl(fileKey) {
78
+ const base = path.join(this.dir, `${fileKey}.jsonl`);
68
79
  // .jsonl.3 is dropped; .2 → .3, .1 → .2, current → .1
69
80
  try {
70
81
  const oldest = `${base}.${JSONL_KEEP_ROTATIONS}`;
@@ -55,7 +55,7 @@ export function verifyAudit(file) {
55
55
  problems: [],
56
56
  };
57
57
  if (!fs.existsSync(file)) {
58
- report.problems.push({ line: 0, reason: 'audit log file does not exist' });
58
+ report.fileMissing = true;
59
59
  return report;
60
60
  }
61
61
  const raw = fs.readFileSync(file, 'utf-8');
@@ -12,8 +12,9 @@ export function parseFormat(flag) {
12
12
  case 'tsv': return 'tsv';
13
13
  case 'yaml': return 'yaml';
14
14
  case 'id': return 'id';
15
+ case 'markdown': return 'markdown';
15
16
  default: {
16
- const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id.`;
17
+ const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id, markdown.`;
17
18
  if (isJsonMode()) {
18
19
  console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
19
20
  }
@@ -67,6 +68,13 @@ export function renderRows(headers, rows, format, fields, aliases) {
67
68
  const filtered = filterFields(headers, rows, fields, aliases);
68
69
  const h = filtered.headers;
69
70
  const r = filtered.rows;
71
+ // Markdown format is rendered as table with markdown style forced regardless
72
+ // of the user's --table-style, so `--format markdown` is a self-contained
73
+ // contract (bug #8).
74
+ if (format === 'markdown') {
75
+ printTable(h, r, 'markdown');
76
+ return;
77
+ }
70
78
  switch (format) {
71
79
  case 'table':
72
80
  printTable(h, r);
@@ -34,9 +34,17 @@ function resolveDeviceByName(query, opts = {}) {
34
34
  const alias = meta.devices[deviceId]?.alias;
35
35
  const rawName = normalizeDeviceName(device.name);
36
36
  const normAlias = alias ? normalizeDeviceName(alias) : null;
37
- // exact alias/name wins regardless of strategy
37
+ // exact alias/name wins immediately for lenient strategies.
38
+ // Under require-unique we must NOT short-circuit: there may be other devices
39
+ // that also match (e.g. via substring), making the result ambiguous. Collect
40
+ // the exact hit as a candidate and let the full ambiguity check decide below.
38
41
  if ((normAlias && normAlias === q) || rawName === q) {
39
- return { ok: true, deviceId };
42
+ if (strategy !== 'require-unique') {
43
+ return { ok: true, deviceId };
44
+ }
45
+ // require-unique: treat exact match as a high-priority candidate (score 0)
46
+ candidates.push({ deviceId, name: device.name, score: 0 });
47
+ continue;
40
48
  }
41
49
  if (strategy === 'exact')
42
50
  continue;
@@ -46,8 +46,8 @@ const ASCII_BORDER_CHARS = {
46
46
  left: '|', 'left-mid': '+', mid: '-', 'mid-mid': '+',
47
47
  right: '|', 'right-mid': '+', middle: '|',
48
48
  };
49
- export function printTable(headers, rows) {
50
- const style = getTableStyle();
49
+ export function printTable(headers, rows, styleOverride) {
50
+ const style = styleOverride ?? getTableStyle();
51
51
  if (style === 'markdown') {
52
52
  console.log(renderMarkdownTable(headers, rows));
53
53
  return;
@@ -0,0 +1,4 @@
1
+ import { createRequire } from 'module';
2
+ const require = createRequire(import.meta.url);
3
+ const { version: VERSION } = require('../package.json');
4
+ export { VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchbot/openapi-cli",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
5
5
  "keywords": [
6
6
  "switchbot",