@switchbot/openapi-cli 2.5.0 → 2.6.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.
@@ -1,7 +1,9 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
3
  import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
4
- import { intArg, stringArg } from '../utils/arg-parsers.js';
4
+ import { intArg, stringArg, durationArg } from '../utils/arg-parsers.js';
5
+ import { parseDurationToMs } from '../utils/flags.js';
6
+ import { parseFilterExpr, matchClause, FilterSyntaxError } from '../utils/filter.js';
5
7
  import { SwitchBotMqttClient } from '../mqtt/client.js';
6
8
  import { fetchMqttCredential } from '../mqtt/credential.js';
7
9
  import { tryLoadConfig } from '../config.js';
@@ -28,45 +30,42 @@ function extractEventId(parsed) {
28
30
  return ctx.eventId;
29
31
  return null;
30
32
  }
31
- function matchFilter(body, filter) {
32
- if (!filter)
33
+ function matchFilter(body, clauses) {
34
+ if (!clauses || clauses.length === 0)
33
35
  return true;
34
36
  if (!body || typeof body !== 'object')
35
37
  return false;
36
38
  const b = body;
37
39
  const ctx = (b.context ?? b);
38
- if (filter.deviceId && ctx.deviceMac !== filter.deviceId && ctx.deviceId !== filter.deviceId) {
39
- return false;
40
- }
41
- if (filter.type && ctx.deviceType !== filter.type) {
42
- return false;
40
+ for (const c of clauses) {
41
+ let candidate;
42
+ if (c.key === 'deviceId') {
43
+ const mac = ctx.deviceMac;
44
+ const id = ctx.deviceId;
45
+ candidate = String(typeof mac === 'string' && mac ? mac : typeof id === 'string' ? id : '');
46
+ }
47
+ else {
48
+ const t = ctx.deviceType;
49
+ candidate = typeof t === 'string' ? t : '';
50
+ }
51
+ if (!matchClause(candidate, c))
52
+ return false;
43
53
  }
44
54
  return true;
45
55
  }
56
+ const EVENT_FILTER_KEYS = ['deviceId', 'type'];
46
57
  function parseFilter(flag) {
47
58
  if (!flag)
48
59
  return null;
49
- const allowed = new Set(['deviceId', 'type']);
50
- const out = {};
51
- for (const pair of flag.split(',')) {
52
- const eq = pair.indexOf('=');
53
- if (eq === -1 || eq === 0) {
54
- throw new UsageError(`Invalid --filter pair "${pair.trim()}". Expected "key=value". Supported keys: deviceId, type.`);
55
- }
56
- const k = pair.slice(0, eq).trim();
57
- const v = pair.slice(eq + 1).trim();
58
- if (!v) {
59
- throw new UsageError(`Empty value for --filter key "${k}". Expected "key=value". Supported keys: deviceId, type.`);
60
- }
61
- if (!allowed.has(k)) {
62
- throw new UsageError(`Unknown --filter key "${k}". Supported keys: deviceId, type.`);
60
+ try {
61
+ return parseFilterExpr(flag, EVENT_FILTER_KEYS);
62
+ }
63
+ catch (e) {
64
+ if (e instanceof FilterSyntaxError) {
65
+ throw new UsageError(e.message);
63
66
  }
64
- if (k === 'deviceId')
65
- out.deviceId = v;
66
- else if (k === 'type')
67
- out.type = v;
67
+ throw e;
68
68
  }
69
- return out;
70
69
  }
71
70
  export function startReceiver(port, pathMatch, filter, onEvent) {
72
71
  const server = http.createServer((req, res) => {
@@ -133,8 +132,9 @@ export function registerEventsCommand(program) {
133
132
  .description('Run a local HTTP receiver and print incoming webhook events as JSONL')
134
133
  .option('--port <n>', `Local port to listen on (default ${DEFAULT_PORT})`, intArg('--port', { min: 1, max: 65535 }), String(DEFAULT_PORT))
135
134
  .option('--path <p>', `HTTP path to match (default "${DEFAULT_PATH}"; use "*" for all paths)`, stringArg('--path'), DEFAULT_PATH)
136
- .option('--filter <expr>', 'Filter events, e.g. "deviceId=ABC123" or "type=Bot" (comma-separated)', stringArg('--filter'))
135
+ .option('--filter <expr>', 'Filter events by deviceId / type. Grammar: "key=value" (substring), "key~value" (substring), "key=/regex/" (regex). Comma-separated clauses are AND-ed.', stringArg('--filter'))
137
136
  .option('--max <n>', 'Stop after N matching events (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
137
+ .option('--for <dur>', 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg('--for'))
138
138
  .addHelpText('after', `
139
139
  SwitchBot posts events to a single webhook URL configured via:
140
140
  $ switchbot webhook setup https://<your-public-host>/<path>
@@ -147,14 +147,20 @@ Output (JSONL, one event per line):
147
147
  { "t": "<ISO>", "remote": "<ip:port>", "path": "/",
148
148
  "body": <parsed JSON or raw string>, "matched": true }
149
149
 
150
- Filter grammar: comma-separated "key=value" pairs. Supported keys:
151
- deviceId=<id> match by context.deviceMac / context.deviceId
152
- type=<type> match by context.deviceType (e.g. "Bot", "WoMeter")
150
+ Filter grammar: comma-separated clauses (AND-ed). Each clause is one of
151
+ key=value — case-insensitive substring
152
+ key~value — explicit case-insensitive substring
153
+ key=/regex/ — case-insensitive regex
154
+
155
+ Supported keys:
156
+ deviceId match by context.deviceMac / context.deviceId
157
+ type match by context.deviceType (e.g. "Bot", "WoMeter")
153
158
 
154
159
  Examples:
155
160
  $ switchbot events tail --port 3000
156
161
  $ switchbot events tail --port 3000 --filter deviceId=ABC123
157
- $ switchbot events tail --filter 'type=WoMeter' --max 5 --json
162
+ $ switchbot events tail --filter 'type~Meter' --max 5 --json
163
+ $ switchbot events tail --filter 'type=/Bot|Meter/'
158
164
  `)
159
165
  .action(async (options) => {
160
166
  try {
@@ -166,9 +172,13 @@ Examples:
166
172
  if (maxMatched !== null && (!Number.isFinite(maxMatched) || maxMatched < 1)) {
167
173
  throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`);
168
174
  }
175
+ const forMs = options.for ? parseDurationToMs(options.for) : null;
169
176
  const filter = parseFilter(options.filter);
170
177
  let matchedCount = 0;
171
178
  const ac = new AbortController();
179
+ const forTimer = forMs !== null && forMs > 0
180
+ ? setTimeout(() => ac.abort(), forMs)
181
+ : null;
172
182
  await new Promise((resolve, reject) => {
173
183
  let server = null;
174
184
  try {
@@ -197,6 +207,8 @@ Examples:
197
207
  if (!isJsonMode())
198
208
  console.error(startMsg);
199
209
  const cleanup = () => {
210
+ if (forTimer)
211
+ clearTimeout(forTimer);
200
212
  server?.close();
201
213
  resolve();
202
214
  };
@@ -214,6 +226,7 @@ Examples:
214
226
  .description('Subscribe to SwitchBot MQTT shadow events and stream them as JSONL')
215
227
  .option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)', stringArg('--topic'))
216
228
  .option('--max <n>', 'Stop after N events (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
229
+ .option('--for <dur>', 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg('--for'))
217
230
  .option('--sink <type>', 'Output sink: stdout (default), file, webhook, openclaw, telegram, homeassistant (repeatable)', (val, prev) => [...prev, val], [])
218
231
  .option('--sink-file <path>', 'File path for file sink', stringArg('--sink-file'))
219
232
  .option('--webhook-url <url>', 'Webhook URL for webhook sink', stringArg('--webhook-url'))
@@ -235,6 +248,7 @@ Output (JSONL, one event per line):
235
248
  { "t": "<ISO>", "eventId": "<uuid>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
236
249
 
237
250
  Control records (interleaved, no "payload" field — use type-prefix to filter):
251
+ { "type": "__session_start", "at": "<ISO>", "eventId": "<uuid>", "state": "connecting" } before credential fetch (JSON mode only)
238
252
  { "type": "__connect", "at": "<ISO>", "eventId": "<uuid>" } first successful connect
239
253
  { "type": "__reconnect", "at": "<ISO>", "eventId": "<uuid>" } connect after a disconnect
240
254
  { "type": "__disconnect", "at": "<ISO>", "eventId": "<uuid>" } reconnecting or failed
@@ -272,6 +286,7 @@ Examples:
272
286
  if (maxEvents !== null && (!Number.isInteger(maxEvents) || maxEvents < 1)) {
273
287
  throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`);
274
288
  }
289
+ const forMs = options.for ? parseDurationToMs(options.for) : null;
275
290
  const loaded = tryLoadConfig();
276
291
  if (!loaded) {
277
292
  throw new UsageError('No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.');
@@ -331,10 +346,24 @@ Examples:
331
346
  if (!isJsonMode()) {
332
347
  console.error('Fetching MQTT credentials from SwitchBot service…');
333
348
  }
349
+ // Emit a __session_start envelope immediately (before any credential
350
+ // fetch) so JSON consumers can distinguish "connecting" from "never
351
+ // connected" even when mqtt-tail exits before the broker connects.
352
+ if (isJsonMode()) {
353
+ printJson({
354
+ type: '__session_start',
355
+ at: new Date().toISOString(),
356
+ eventId: crypto.randomUUID(),
357
+ state: 'connecting',
358
+ });
359
+ }
334
360
  const credential = await fetchMqttCredential(loaded.token, loaded.secret);
335
361
  const topic = options.topic ?? credential.topics.status;
336
362
  let eventCount = 0;
337
363
  const ac = new AbortController();
364
+ const forTimer = forMs !== null && forMs > 0
365
+ ? setTimeout(() => ac.abort(), forMs)
366
+ : null;
338
367
  const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(loaded.token, loaded.secret));
339
368
  const unsub = client.onMessage((msgTopic, payload) => {
340
369
  let parsed;
@@ -420,6 +449,8 @@ Examples:
420
449
  }
421
450
  await new Promise((resolve) => {
422
451
  const cleanup = () => {
452
+ if (forTimer)
453
+ clearTimeout(forTimer);
423
454
  process.removeListener('SIGINT', cleanup);
424
455
  process.removeListener('SIGTERM', cleanup);
425
456
  unsub();
@@ -1,5 +1,5 @@
1
1
  import { intArg, stringArg } from '../utils/arg-parsers.js';
2
- import { handleError, isJsonMode, printJson, UsageError } from '../utils/output.js';
2
+ import { handleError, isJsonMode, printJson, UsageError, emitJsonError } 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';
@@ -93,10 +93,12 @@ Examples:
93
93
  if (!options.yes && !isDryRun() && isDestructiveCommand(deviceType, command, 'command')) {
94
94
  const reason = getDestructiveReason(deviceType, command, 'command');
95
95
  if (isJsonMode()) {
96
- console.error(JSON.stringify({ error: { code: 2, kind: 'guard',
97
- message: `"${command}" on ${deviceType || 'device'} is destructive and requires --yes.`,
98
- hint: reason ? `Re-run with --yes. Reason: ${reason}` : 'Re-run with --yes to confirm.',
99
- } }));
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
+ });
100
102
  }
101
103
  else {
102
104
  console.error(`Refusing to run destructive command "${command}" without --yes.`);
@@ -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 } from '../utils/output.js';
4
+ import { printJson, isJsonMode, handleError, UsageError, emitJsonError } 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';
@@ -72,7 +72,7 @@ Examples:
72
72
  if (!Number.isInteger(idx) || idx < 1 || idx > entries.length) {
73
73
  const msg = `Invalid index ${indexArg}. Log has ${entries.length} entries.`;
74
74
  if (isJsonMode()) {
75
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
75
+ emitJsonError({ code: 2, kind: 'usage', message: msg });
76
76
  }
77
77
  else {
78
78
  console.error(msg);
@@ -83,7 +83,7 @@ Examples:
83
83
  if (entry.kind !== 'command') {
84
84
  const msg = `Entry ${idx} is not a command (kind=${entry.kind}).`;
85
85
  if (isJsonMode()) {
86
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
86
+ emitJsonError({ code: 2, kind: 'usage', message: msg });
87
87
  }
88
88
  else {
89
89
  console.error(msg);
@@ -258,15 +258,12 @@ Examples:
258
258
  .option('--since <duration>', 'Relative window ending now, e.g. "1h", "7d" (mutually exclusive with --from/--to)', stringArg('--since'))
259
259
  .option('--from <iso>', 'Range start (ISO-8601)', stringArg('--from'))
260
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), [])
261
+ .requiredOption('--metric <name>', 'Payload field to aggregate (repeat for multiple; required)', (v, acc = []) => acc.concat(v))
262
262
  .option('--agg <csv>', 'Comma-separated aggregation functions (count,min,max,avg,sum,p50,p95)', stringArg('--agg'))
263
263
  .option('--bucket <duration>', 'Bucket width, e.g. "15m", "1h", "1d"', stringArg('--bucket'))
264
264
  .option('--max-bucket-samples <n>', 'Max samples per bucket for quantiles (1–100000)', intArg('--max-bucket-samples', { min: 1, max: 100_000 }))
265
265
  .action(async (deviceId, options) => {
266
266
  const metrics = options.metric ?? [];
267
- if (metrics.length === 0) {
268
- handleError(new UsageError('at least one --metric is required.'));
269
- }
270
267
  if (options.since && (options.from || options.to)) {
271
268
  handleError(new UsageError('--since is mutually exclusive with --from/--to.'));
272
269
  }
@@ -3,7 +3,7 @@ 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 } from '../utils/output.js';
6
+ import { handleError, isJsonMode, buildErrorPayload, emitJsonError } 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';
@@ -26,11 +26,36 @@ function mcpError(kind, code, message, options) {
26
26
  obj.retryable = true;
27
27
  if (options?.context)
28
28
  obj.context = options.context;
29
+ if (options?.subKind !== undefined)
30
+ obj.subKind = options.subKind;
31
+ if (options?.errorClass !== undefined)
32
+ obj.errorClass = options.errorClass;
33
+ if (options?.transient !== undefined)
34
+ obj.transient = options.transient;
35
+ if (options?.retryAfterMs !== undefined)
36
+ obj.retryAfterMs = options.retryAfterMs;
29
37
  return {
30
38
  isError: true,
31
39
  content: [{ type: 'text', text: JSON.stringify({ error: obj }, null, 2) }],
40
+ structuredContent: { error: obj },
32
41
  };
33
42
  }
43
+ /**
44
+ * Convert any thrown error into a structured MCP tool-error response,
45
+ * preserving all ErrorPayload fields (subKind, transient, hint, etc.).
46
+ */
47
+ function apiErrorToMcpError(err) {
48
+ const payload = buildErrorPayload(err);
49
+ return mcpError(payload.kind, payload.code, payload.message, {
50
+ hint: payload.hint,
51
+ retryable: payload.retryable,
52
+ context: payload.context,
53
+ subKind: payload.subKind,
54
+ errorClass: payload.errorClass,
55
+ transient: payload.transient,
56
+ retryAfterMs: payload.retryAfterMs,
57
+ });
58
+ }
34
59
  export function createSwitchBotMcpServer(options) {
35
60
  const eventManager = options?.eventManager;
36
61
  const server = new McpServer({
@@ -248,8 +273,19 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
248
273
  },
249
274
  }, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
250
275
  const effectiveType = commandType ?? 'command';
251
- // dryRun early-return — no API call, no validation against live device list
276
+ // dryRun early-return — no API call. We still preflight the deviceId
277
+ // against the local cache so fabricated IDs don't silently pass
278
+ // validation (bug #SYS-3). Dry-run is meant to catch bad inputs; a
279
+ // dry-run that accepts anything is worse than no dry-run at all.
252
280
  if (dryRun) {
281
+ const cached = getCachedDevice(deviceId);
282
+ if (!cached) {
283
+ return mcpError('usage', 2, `Device "${deviceId}" not found in local cache.`, {
284
+ subKind: 'device-not-found',
285
+ hint: "Run 'list_devices' first to warm the cache, then retry with dryRun:true.",
286
+ context: { deviceId },
287
+ });
288
+ }
253
289
  const wouldSend = {
254
290
  deviceId,
255
291
  command,
@@ -319,7 +355,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
319
355
  },
320
356
  });
321
357
  }
322
- throw err;
358
+ return apiErrorToMcpError(err);
323
359
  }
324
360
  const isIr = getCachedDevice(deviceId)?.category === 'ir';
325
361
  const structured = { ok: true, command, deviceId, result };
@@ -364,7 +400,12 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
364
400
  structuredContent: structured,
365
401
  };
366
402
  }
367
- await executeScene(sceneId);
403
+ try {
404
+ await executeScene(sceneId);
405
+ }
406
+ catch (err) {
407
+ return apiErrorToMcpError(err);
408
+ }
368
409
  const structured = { ok: true, sceneId };
369
410
  return {
370
411
  content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
@@ -393,7 +434,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
393
434
  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.',
394
435
  _meta: { agentSafetyTier: 'read' },
395
436
  inputSchema: z.object({
396
- query: z.string().describe('Search query (matches type and aliases, case-insensitive). Use empty string to list all.'),
437
+ query: z.string().describe('Search query (matches type and aliases, case-insensitive). Must be non-empty; use list_catalog_types to enumerate instead.'),
397
438
  limit: z.number().int().min(1).max(100).optional().default(20).describe('Max entries returned (default 20)'),
398
439
  }).strict(),
399
440
  outputSchema: {
@@ -416,6 +457,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
416
457
  total: z.number().int().describe('Number of entries returned'),
417
458
  },
418
459
  }, async ({ query, limit }) => {
460
+ if (query.trim() === '') {
461
+ return mcpError('usage', 2, 'search_catalog requires a non-empty query.', {
462
+ hint: "Pass a search term like 'Bot' or 'Hub', or call list_catalog_types to enumerate all types without a query.",
463
+ });
464
+ }
419
465
  const hits = searchCatalog(query, limit);
420
466
  const structured = { results: hits, total: hits.length };
421
467
  return {
@@ -466,7 +512,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
466
512
  context: { deviceId },
467
513
  });
468
514
  }
469
- throw err;
515
+ return apiErrorToMcpError(err);
470
516
  }
471
517
  });
472
518
  // ---- aggregate_device_history --------------------------------------------
@@ -670,7 +716,7 @@ Inspect locally:
670
716
  if (!Number.isFinite(port) || port < 1 || port > 65535) {
671
717
  const msg = `Invalid --port "${options.port}". Must be 1-65535.`;
672
718
  if (isJsonMode()) {
673
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
719
+ emitJsonError({ code: 2, kind: 'usage', message: msg });
674
720
  }
675
721
  else {
676
722
  console.error(msg);
@@ -686,7 +732,7 @@ Inspect locally:
686
732
  if (!isLocalhost && !authToken) {
687
733
  const msg = 'Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token <token> or bind to localhost (default).';
688
734
  if (isJsonMode()) {
689
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
735
+ emitJsonError({ code: 2, kind: 'usage', message: msg });
690
736
  }
691
737
  else {
692
738
  console.error(msg);
@@ -191,8 +191,13 @@ Workflow:
191
191
  });
192
192
  plan
193
193
  .command('validate')
194
- .description('Validate a plan file (or stdin) against the schema')
194
+ .description('Validate a plan file (or stdin) against the schema (structural only; does not verify device or scene existence)')
195
195
  .argument('[file]', 'Path to plan.json, or "-" / omit to read stdin')
196
+ .addHelpText('after', `
197
+ To check semantic validity (e.g., that deviceIds and sceneIds actually exist),
198
+ use 'plan run --dry-run' which exercises name resolution and device lookup
199
+ against the live API without executing any mutations.
200
+ `)
196
201
  .action(async (file) => {
197
202
  let raw;
198
203
  try {
@@ -47,6 +47,15 @@ Example:
47
47
  `)
48
48
  .action(async (sceneId) => {
49
49
  try {
50
+ const sceneList = await fetchScenes();
51
+ const found = sceneList.find((s) => s.sceneId === sceneId);
52
+ if (!found) {
53
+ throw new StructuredUsageError(`scene not found: ${sceneId}`, {
54
+ error: 'scene_not_found',
55
+ sceneId,
56
+ candidates: sceneList.map((s) => ({ sceneId: s.sceneId, sceneName: s.sceneName })),
57
+ });
58
+ }
50
59
  await executeScene(sceneId);
51
60
  if (isJsonMode()) {
52
61
  printJson({ ok: true, sceneId });
@@ -61,6 +61,7 @@ export function registerWatchCommand(devices) {
61
61
  .option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)', stringArg('--name'))
62
62
  .option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, durationArg('--interval'), '30s')
63
63
  .option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
64
+ .option('--for <dur>', 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg('--for'))
64
65
  .option('--include-unchanged', 'Emit a tick even when no field changed')
65
66
  .addHelpText('after', `
66
67
  Each poll emits one JSON line per deviceId with the shape:
@@ -99,11 +100,15 @@ Examples:
99
100
  }
100
101
  maxTicks = Math.floor(n);
101
102
  }
103
+ const forMs = options.for ? parseDurationToMs(options.for) : null;
102
104
  const fields = getFields() ?? null;
103
105
  const ac = new AbortController();
104
106
  const onSig = () => ac.abort();
105
107
  process.on('SIGINT', onSig);
106
108
  process.on('SIGTERM', onSig);
109
+ const forTimer = forMs !== null && forMs > 0
110
+ ? setTimeout(() => ac.abort(), forMs)
111
+ : null;
107
112
  try {
108
113
  const prev = new Map();
109
114
  const client = createClient();
@@ -163,6 +168,8 @@ Examples:
163
168
  handleError(err);
164
169
  }
165
170
  finally {
171
+ if (forTimer)
172
+ clearTimeout(forTimer);
166
173
  process.off('SIGINT', onSig);
167
174
  process.off('SIGTERM', onSig);
168
175
  }
@@ -1,47 +1,78 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import { createHash } from 'node:crypto';
4
5
  import { getConfigPath } from '../utils/flags.js';
6
+ import { getActiveProfile } from '../lib/request-context.js';
7
+ /**
8
+ * Returns the directory where cache files should be stored.
9
+ *
10
+ * - If a profile is active, scopes into a per-profile sub-directory so that
11
+ * rotating credentials or switching profiles never serves stale inventory
12
+ * from a prior session (Bug #37).
13
+ * - If no profile is active (unnamed / default), returns `baseDir` unchanged
14
+ * so the existing legacy path (~/.switchbot/devices.json) is preserved.
15
+ *
16
+ * Only called when `getConfigPath()` returns undefined — the --config-path
17
+ * override takes full precedence and bypasses this helper entirely.
18
+ */
19
+ function scopedCacheDir(baseDir) {
20
+ const profile = getActiveProfile();
21
+ if (profile === undefined)
22
+ return baseDir;
23
+ const hash = createHash('sha256').update(profile).digest('hex').slice(0, 8);
24
+ const dir = path.join(baseDir, 'cache', hash);
25
+ if (!fs.existsSync(dir))
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ return dir;
28
+ }
5
29
  /** GC cutoff for status entries: evict anything older than this. */
6
30
  const DEFAULT_STATUS_GC_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
7
31
  function cacheFilePath() {
8
32
  const override = getConfigPath();
9
33
  const dir = override
10
34
  ? path.dirname(path.resolve(override))
11
- : path.join(os.homedir(), '.switchbot');
35
+ : scopedCacheDir(path.join(os.homedir(), '.switchbot'));
12
36
  return path.join(dir, 'devices.json');
13
37
  }
14
- // In-memory hot-cache: undefined = not yet loaded, null = loaded but empty.
15
- let _listCache = undefined;
16
- let _statusCache = undefined;
38
+ // In-memory hot-cache keyed by active profile (or '__default__' for no profile).
39
+ // Using Maps instead of module-level singletons ensures that mcp serve, which
40
+ // rotates profiles per request via withRequestContext, never leaks inventory
41
+ // across profiles within the same process (Bug #37).
42
+ const _listCacheByProfile = new Map();
43
+ const _statusCacheByProfile = new Map();
44
+ function cacheKey() {
45
+ return getActiveProfile() ?? '__default__';
46
+ }
17
47
  /** Force the next loadCache() call to re-read from disk. Used in tests. */
18
48
  export function resetListCache() {
19
- _listCache = undefined;
49
+ _listCacheByProfile.clear();
20
50
  }
21
51
  /** Force the next loadStatusCache() call to re-read from disk. Used in tests. */
22
52
  export function resetStatusCache() {
23
- _statusCache = undefined;
53
+ _statusCacheByProfile.clear();
24
54
  }
25
55
  export function loadCache() {
26
- if (_listCache !== undefined)
27
- return _listCache;
56
+ const key = cacheKey();
57
+ if (_listCacheByProfile.has(key))
58
+ return _listCacheByProfile.get(key);
28
59
  const file = cacheFilePath();
29
60
  if (!fs.existsSync(file)) {
30
- _listCache = null;
61
+ _listCacheByProfile.set(key, null);
31
62
  return null;
32
63
  }
33
64
  try {
34
65
  const raw = fs.readFileSync(file, 'utf-8');
35
66
  const cache = JSON.parse(raw);
36
67
  if (!cache || typeof cache.devices !== 'object' || cache.devices === null) {
37
- _listCache = null;
68
+ _listCacheByProfile.set(key, null);
38
69
  return null;
39
70
  }
40
- _listCache = cache;
71
+ _listCacheByProfile.set(key, cache);
41
72
  return cache;
42
73
  }
43
74
  catch {
44
- _listCache = null;
75
+ _listCacheByProfile.set(key, null);
45
76
  return null;
46
77
  }
47
78
  }
@@ -109,7 +140,7 @@ export function updateCacheFromDeviceList(body) {
109
140
  if (!fs.existsSync(dir))
110
141
  fs.mkdirSync(dir, { recursive: true });
111
142
  fs.writeFileSync(file, JSON.stringify(cache, null, 2), { mode: 0o600 });
112
- _listCache = cache;
143
+ _listCacheByProfile.set(cacheKey(), cache);
113
144
  }
114
145
  catch {
115
146
  // Cache write failures must not break the command that triggered them.
@@ -119,7 +150,7 @@ export function clearCache() {
119
150
  const file = cacheFilePath();
120
151
  if (fs.existsSync(file))
121
152
  fs.unlinkSync(file);
122
- _listCache = null;
153
+ _listCacheByProfile.set(cacheKey(), null);
123
154
  }
124
155
  // ---- Device list freshness -------------------------------------------------
125
156
  /** Age of the on-disk list cache in ms, or null if there is no cache. */
@@ -143,34 +174,38 @@ function statusCacheFilePath() {
143
174
  const override = getConfigPath();
144
175
  const dir = override
145
176
  ? path.dirname(path.resolve(override))
146
- : path.join(os.homedir(), '.switchbot');
177
+ : scopedCacheDir(path.join(os.homedir(), '.switchbot'));
147
178
  return path.join(dir, 'status.json');
148
179
  }
149
180
  export function loadStatusCache() {
150
- if (_statusCache !== undefined)
151
- return _statusCache;
181
+ const key = cacheKey();
182
+ if (_statusCacheByProfile.has(key))
183
+ return _statusCacheByProfile.get(key);
152
184
  const file = statusCacheFilePath();
153
185
  if (!fs.existsSync(file)) {
154
- _statusCache = { entries: {} };
155
- return _statusCache;
186
+ const empty = { entries: {} };
187
+ _statusCacheByProfile.set(key, empty);
188
+ return empty;
156
189
  }
157
190
  try {
158
191
  const raw = fs.readFileSync(file, 'utf-8');
159
192
  const parsed = JSON.parse(raw);
160
193
  if (!parsed || typeof parsed.entries !== 'object' || parsed.entries === null) {
161
- _statusCache = { entries: {} };
162
- return _statusCache;
194
+ const empty = { entries: {} };
195
+ _statusCacheByProfile.set(key, empty);
196
+ return empty;
163
197
  }
164
- _statusCache = parsed;
198
+ _statusCacheByProfile.set(key, parsed);
165
199
  return parsed;
166
200
  }
167
201
  catch {
168
- _statusCache = { entries: {} };
169
- return _statusCache;
202
+ const empty = { entries: {} };
203
+ _statusCacheByProfile.set(key, empty);
204
+ return empty;
170
205
  }
171
206
  }
172
207
  function saveStatusCache(cache) {
173
- _statusCache = cache;
208
+ _statusCacheByProfile.set(cacheKey(), cache);
174
209
  try {
175
210
  const file = statusCacheFilePath();
176
211
  const dir = path.dirname(file);
@@ -220,7 +255,7 @@ export function clearStatusCache() {
220
255
  const file = statusCacheFilePath();
221
256
  if (fs.existsSync(file))
222
257
  fs.unlinkSync(file);
223
- _statusCache = { entries: {} };
258
+ _statusCacheByProfile.set(cacheKey(), { entries: {} });
224
259
  }
225
260
  export function describeCache(now = Date.now()) {
226
261
  const listFile = cacheFilePath();