@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.
- package/README.md +69 -7
- package/dist/api/client.js +35 -3
- 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 +36 -0
- package/dist/commands/catalog.js +60 -2
- package/dist/commands/config.js +6 -6
- package/dist/commands/device-meta.js +18 -1
- package/dist/commands/devices.js +148 -68
- 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/devices/param-validator.js +170 -0
- package/dist/utils/arg-parsers.js +2 -1
- package/dist/utils/filter.js +120 -39
- package/dist/utils/flags.js +27 -1
- package/dist/utils/format.js +2 -2
- package/dist/utils/name-resolver.js +6 -3
- package/dist/utils/output.js +64 -4
- package/package.json +1 -1
package/dist/commands/events.js
CHANGED
|
@@ -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,
|
|
32
|
-
if (!
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
throw new UsageError(
|
|
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
|
-
|
|
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
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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();
|
package/dist/commands/expand.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.`);
|
package/dist/commands/history.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
}
|
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();
|