@switchbot/openapi-cli 2.5.0 → 2.5.1

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.
@@ -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();
@@ -37,7 +37,8 @@ export function durationArg(flagName) {
37
37
  }
38
38
  const ms = parseDurationToMs(value);
39
39
  if (ms === null) {
40
- throw new InvalidArgumentError(`${flagName} must look like "30s", "1m", "500ms", "1h" (got "${value}")`);
40
+ throw new InvalidArgumentError(`${flagName} must look like "30s", "1m", "500ms", "1h", "7d", "2w" ` +
41
+ `(supported units: ms, s, m, h, d, w — got "${value}")`);
41
42
  }
42
43
  return value;
43
44
  };
@@ -4,42 +4,109 @@ export class FilterSyntaxError extends Error {
4
4
  this.name = 'FilterSyntaxError';
5
5
  }
6
6
  }
7
- const VALID_KEYS = ['type', 'family', 'room', 'category'];
8
7
  /**
9
- * Parse a filter expression like "type=Bot,family=Home" into discrete clauses.
8
+ * Parse a comma-separated filter expression into discrete clauses.
10
9
  *
11
- * Grammar:
12
- * expr := clause ("," clause)*
13
- * clause := KEY OP VALUE
14
- * KEY := type | family | room | category
15
- * OP := "=" | "~="
16
- * VALUE := any non-empty string (no comma — split at the clause boundary)
10
+ * Grammar (per clause, recognition order):
11
+ * 1. key=/pattern/ → regex (case-insensitive); invalid regex throws.
12
+ * 2. key~value → substring (case-insensitive).
13
+ * 3. key=value → 'eq' op (substring; caller decides whether to treat
14
+ * as exact for specific keys via matchClause's
15
+ * `exactKeys` option).
17
16
  *
18
- * Whitespace around keys / values is trimmed. Empty expressions return [].
17
+ * `allowedKeys` is command-specific: `devices list` uses
18
+ * {type,name,category,room}; `devices batch` uses {type,family,room,category};
19
+ * `events tail` uses {deviceId,type}.
19
20
  */
20
- export function parseFilter(expr) {
21
+ export function parseFilterExpr(expr, allowedKeys) {
21
22
  if (!expr)
22
23
  return [];
23
24
  const parts = expr.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
24
25
  const clauses = [];
25
26
  for (const part of parts) {
26
- const m = /^([a-zA-Z_]+)\s*(~=|=)\s*(.+)$/.exec(part);
27
- if (!m) {
28
- throw new FilterSyntaxError(`Invalid filter clause "${part}" — expected "<key>=<value>" or "<key>~=<pattern>"`);
27
+ const regexMatch = /^([^=~]+)=\/(.*)\/$/.exec(part);
28
+ const tildeIdx = part.indexOf('~');
29
+ const eqIdx = part.indexOf('=');
30
+ let key;
31
+ let op;
32
+ let raw;
33
+ let regex;
34
+ if (regexMatch) {
35
+ key = regexMatch[1].trim();
36
+ op = 'regex';
37
+ raw = regexMatch[2];
38
+ try {
39
+ regex = new RegExp(raw, 'i');
40
+ }
41
+ catch (err) {
42
+ throw new FilterSyntaxError(`Invalid regex in --filter "${part}": ${err.message}`);
43
+ }
44
+ }
45
+ else if (tildeIdx !== -1 && (eqIdx === -1 || tildeIdx < eqIdx)) {
46
+ key = part.slice(0, tildeIdx).trim();
47
+ op = 'sub';
48
+ raw = part.slice(tildeIdx + 1).trim();
49
+ if (raw.startsWith('=')) {
50
+ throw new FilterSyntaxError(`Invalid filter clause "${part}" — "~=" is no longer supported. Use "${key}~${raw.slice(1)}" instead.`);
51
+ }
52
+ }
53
+ else if (eqIdx !== -1) {
54
+ key = part.slice(0, eqIdx).trim();
55
+ op = 'eq';
56
+ raw = part.slice(eqIdx + 1).trim();
29
57
  }
30
- const key = m[1];
31
- const op = m[2];
32
- const value = m[3].trim();
33
- if (!VALID_KEYS.includes(key)) {
34
- throw new FilterSyntaxError(`Unknown filter key "${key}" — supported: ${VALID_KEYS.join(', ')}`);
58
+ else {
59
+ throw new FilterSyntaxError(`Invalid filter clause "${part}" — expected "<key>=<value>", "<key>~<value>", or "<key>=/<regex>/"`);
35
60
  }
36
- if (!value) {
61
+ if (!key) {
62
+ throw new FilterSyntaxError(`Empty key in filter clause "${part}"`);
63
+ }
64
+ if (!raw) {
37
65
  throw new FilterSyntaxError(`Empty value for filter clause "${part}"`);
38
66
  }
39
- clauses.push({ key, op, value });
67
+ if (!allowedKeys.includes(key)) {
68
+ throw new FilterSyntaxError(`Unknown filter key "${key}" — supported: ${allowedKeys.join(', ')}`);
69
+ }
70
+ clauses.push({ key, op, raw, regex });
40
71
  }
41
72
  return clauses;
42
73
  }
74
+ /**
75
+ * Match a single candidate string against a clause.
76
+ *
77
+ * - `regex` → RegExp.test against the candidate (case-insensitive by construction).
78
+ * - `sub` → case-insensitive substring.
79
+ * - `eq` → case-insensitive substring, except for keys listed in
80
+ * `exactKeys`, which get case-insensitive exact comparison.
81
+ * Default `exactKeys` is `['category']` to preserve the existing
82
+ * list/batch behavior for that key.
83
+ */
84
+ export function matchClause(candidate, clause, options) {
85
+ if (candidate === undefined)
86
+ return false;
87
+ if (clause.op === 'regex') {
88
+ return clause.regex.test(candidate);
89
+ }
90
+ const cLower = candidate.toLowerCase();
91
+ const vLower = clause.raw.toLowerCase();
92
+ if (clause.op === 'sub') {
93
+ return cLower.includes(vLower);
94
+ }
95
+ const exactKeys = options?.exactKeys ?? ['category'];
96
+ if (exactKeys.includes(clause.key)) {
97
+ return cLower === vLower;
98
+ }
99
+ return cLower.includes(vLower);
100
+ }
101
+ const BATCH_KEYS = ['type', 'family', 'room', 'category'];
102
+ /**
103
+ * Back-compat narrow signature: parses with the batch key set. Callers that
104
+ * need a different key set (list, events tail) should call parseFilterExpr
105
+ * directly.
106
+ */
107
+ export function parseFilter(expr) {
108
+ return parseFilterExpr(expr, BATCH_KEYS);
109
+ }
43
110
  /** Normalize a physical / IR device entry to the shape the filter matcher expects. */
44
111
  function toFilterable(d, isPhysical, hubLocation) {
45
112
  if (isPhysical) {
@@ -62,27 +129,23 @@ function toFilterable(d, isPhysical, hubLocation) {
62
129
  category: 'ir',
63
130
  };
64
131
  }
65
- function matches(d, clause) {
66
- const candidate = clause.key === 'type'
67
- ? d.type
68
- : clause.key === 'family'
69
- ? d.family
70
- : clause.key === 'room'
71
- ? d.room
72
- : d.category;
73
- if (candidate === undefined)
74
- return false;
75
- if (clause.op === '=')
76
- return candidate.toLowerCase() === clause.value.toLowerCase();
77
- // '~=' — case-insensitive substring match on the candidate.
78
- return candidate.toLowerCase().includes(clause.value.toLowerCase());
132
+ function candidateFor(d, key) {
133
+ switch (key) {
134
+ case 'type':
135
+ return d.type;
136
+ case 'family':
137
+ return d.family;
138
+ case 'room':
139
+ return d.room;
140
+ case 'category':
141
+ return d.category;
142
+ default:
143
+ return undefined;
144
+ }
79
145
  }
80
146
  /**
81
147
  * Apply the parsed clauses to a mixed list of physical devices + IR remotes.
82
- * Returns the deviceIds of the entries that satisfy every clause.
83
- *
84
- * `hubLocation` (optional) allows family/room filters to match IR remotes by
85
- * the Hub-inherited location.
148
+ * Returns the filterable entries that satisfy every clause.
86
149
  */
87
150
  export function applyFilter(clauses, deviceList, infraredRemoteList, hubLocation) {
88
151
  const candidates = [
@@ -91,5 +154,5 @@ export function applyFilter(clauses, deviceList, infraredRemoteList, hubLocation
91
154
  ];
92
155
  if (clauses.length === 0)
93
156
  return candidates;
94
- return candidates.filter((c) => clauses.every((clause) => matches(c, clause)));
157
+ return candidates.filter((c) => clauses.every((clause) => matchClause(candidateFor(c, clause.key), clause)));
95
158
  }
@@ -92,7 +92,7 @@ export function isQuotaDisabled() {
92
92
  }
93
93
  const DEFAULT_LIST_TTL_MS = 60 * 60 * 1000;
94
94
  function parseDurationToMs(v) {
95
- const m = /^(\d+)(ms|s|m|h)?$/.exec(v.trim().toLowerCase());
95
+ const m = /^(\d+)(ms|s|m|h|d|w)?$/.exec(v.trim().toLowerCase());
96
96
  if (!m)
97
97
  return null;
98
98
  const n = Number(m[1]);
@@ -104,6 +104,8 @@ function parseDurationToMs(v) {
104
104
  case 's': return n * 1000;
105
105
  case 'm': return n * 60 * 1000;
106
106
  case 'h': return n * 60 * 60 * 1000;
107
+ case 'd': return n * 24 * 60 * 60 * 1000;
108
+ case 'w': return n * 7 * 24 * 60 * 60 * 1000;
107
109
  default: return null;
108
110
  }
109
111
  }
@@ -1,4 +1,4 @@
1
- import { printTable, printJson, isJsonMode, UsageError } from './output.js';
1
+ import { printTable, printJson, isJsonMode, UsageError, emitJsonError } from './output.js';
2
2
  import { getFormat, getFields } from './flags.js';
3
3
  import { dump as yamlDump } from 'js-yaml';
4
4
  export function parseFormat(flag) {
@@ -16,7 +16,7 @@ export function parseFormat(flag) {
16
16
  default: {
17
17
  const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id, markdown.`;
18
18
  if (isJsonMode()) {
19
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
19
+ emitJsonError({ code: 2, kind: 'usage', message: msg });
20
20
  }
21
21
  else {
22
22
  console.error(msg);
@@ -2,7 +2,7 @@ import { loadCache } from '../devices/cache.js';
2
2
  import { loadDeviceMeta } from '../devices/device-meta.js';
3
3
  import { levenshtein, normalizeDeviceName } from './string.js';
4
4
  import { UsageError, StructuredUsageError } from './output.js';
5
- const ALL_STRATEGIES = [
5
+ export const ALL_STRATEGIES = [
6
6
  'exact', 'prefix', 'substring', 'fuzzy', 'first', 'require-unique',
7
7
  ];
8
8
  export function isValidStrategy(s) {