@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.
- package/README.md +45 -4
- package/dist/commands/agent-bootstrap.js +3 -0
- package/dist/commands/batch.js +64 -24
- package/dist/commands/cache.js +17 -1
- package/dist/commands/capabilities.js +5 -0
- package/dist/commands/config.js +6 -6
- package/dist/commands/device-meta.js +18 -1
- package/dist/commands/devices.js +124 -52
- package/dist/commands/events.js +63 -32
- package/dist/commands/expand.js +7 -5
- package/dist/commands/history.js +4 -7
- package/dist/commands/mcp.js +54 -8
- package/dist/commands/plan.js +6 -1
- package/dist/commands/scenes.js +9 -0
- package/dist/commands/watch.js +7 -0
- package/dist/devices/cache.js +61 -26
- package/dist/utils/arg-parsers.js +2 -1
- package/dist/utils/filter.js +102 -39
- package/dist/utils/flags.js +3 -1
- package/dist/utils/format.js +2 -2
- package/dist/utils/name-resolver.js +1 -1
- package/dist/utils/output.js +35 -4
- package/package.json +1 -1
package/dist/commands/mcp.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
735
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
690
736
|
}
|
|
691
737
|
else {
|
|
692
738
|
console.error(msg);
|
package/dist/commands/plan.js
CHANGED
|
@@ -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 {
|
package/dist/commands/scenes.js
CHANGED
|
@@ -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 });
|
package/dist/commands/watch.js
CHANGED
|
@@ -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
|
}
|
package/dist/devices/cache.js
CHANGED
|
@@ -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
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
+
_statusCacheByProfile.clear();
|
|
24
54
|
}
|
|
25
55
|
export function loadCache() {
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
+
_listCacheByProfile.set(key, null);
|
|
38
69
|
return null;
|
|
39
70
|
}
|
|
40
|
-
|
|
71
|
+
_listCacheByProfile.set(key, cache);
|
|
41
72
|
return cache;
|
|
42
73
|
}
|
|
43
74
|
catch {
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
162
|
-
|
|
194
|
+
const empty = { entries: {} };
|
|
195
|
+
_statusCacheByProfile.set(key, empty);
|
|
196
|
+
return empty;
|
|
163
197
|
}
|
|
164
|
-
|
|
198
|
+
_statusCacheByProfile.set(key, parsed);
|
|
165
199
|
return parsed;
|
|
166
200
|
}
|
|
167
201
|
catch {
|
|
168
|
-
|
|
169
|
-
|
|
202
|
+
const empty = { entries: {} };
|
|
203
|
+
_statusCacheByProfile.set(key, empty);
|
|
204
|
+
return empty;
|
|
170
205
|
}
|
|
171
206
|
}
|
|
172
207
|
function saveStatusCache(cache) {
|
|
173
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
};
|
package/dist/utils/filter.js
CHANGED
|
@@ -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
|
|
8
|
+
* Parse a comma-separated filter expression into discrete clauses.
|
|
10
9
|
*
|
|
11
|
-
* Grammar:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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) =>
|
|
157
|
+
return candidates.filter((c) => clauses.every((clause) => matchClause(candidateFor(c, clause.key), clause)));
|
|
95
158
|
}
|
package/dist/utils/flags.js
CHANGED
|
@@ -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
|
}
|
package/dist/utils/format.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|