@switchbot/openapi-cli 2.6.3 → 2.7.2
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 +2 -2
- package/dist/api/client.js +13 -12
- package/dist/commands/agent-bootstrap.js +21 -15
- package/dist/commands/batch.js +28 -35
- package/dist/commands/capabilities.js +29 -21
- package/dist/commands/catalog.js +12 -3
- package/dist/commands/config.js +32 -38
- package/dist/commands/devices.js +124 -83
- package/dist/commands/doctor.js +355 -19
- package/dist/commands/events.js +112 -23
- package/dist/commands/expand.js +7 -15
- package/dist/commands/explain.js +10 -6
- package/dist/commands/history.js +12 -18
- package/dist/commands/identity.js +59 -0
- package/dist/commands/mcp.js +168 -73
- package/dist/commands/plan.js +1 -1
- package/dist/commands/schema.js +22 -12
- package/dist/commands/watch.js +15 -2
- package/dist/config.js +65 -21
- package/dist/devices/catalog.js +125 -12
- package/dist/devices/resources.js +270 -0
- package/dist/index.js +25 -6
- package/dist/lib/devices.js +22 -7
- package/dist/schema/field-aliases.js +131 -0
- package/dist/utils/filter.js +17 -4
- package/dist/utils/help-json.js +54 -0
- package/dist/utils/output.js +37 -0
- package/package.json +1 -1
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,
|
|
2
|
+
import { handleError, isJsonMode, printJson, UsageError, exitWithError } 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';
|
|
@@ -92,20 +92,12 @@ Examples:
|
|
|
92
92
|
}
|
|
93
93
|
if (!options.yes && !isDryRun() && isDestructiveCommand(deviceType, command, 'command')) {
|
|
94
94
|
const reason = getDestructiveReason(deviceType, command, 'command');
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
console.error(`Refusing to run destructive command "${command}" without --yes.`);
|
|
105
|
-
if (reason)
|
|
106
|
-
console.error(`Reason: ${reason}`);
|
|
107
|
-
}
|
|
108
|
-
process.exit(2);
|
|
95
|
+
exitWithError({
|
|
96
|
+
code: 2,
|
|
97
|
+
kind: 'guard',
|
|
98
|
+
message: `"${command}" on ${deviceType || 'device'} is destructive and requires --yes.`,
|
|
99
|
+
hint: reason ? `Re-run with --yes. Reason: ${reason}` : 'Re-run with --yes to confirm.',
|
|
100
|
+
});
|
|
109
101
|
}
|
|
110
102
|
const body = await executeCommand(deviceId, command, parameter, 'command');
|
|
111
103
|
const isIr = cached?.category === 'ir';
|
package/dist/commands/explain.js
CHANGED
|
@@ -43,12 +43,16 @@ Examples:
|
|
|
43
43
|
}
|
|
44
44
|
const caps = desc.capabilities;
|
|
45
45
|
const commands = caps && 'commands' in caps
|
|
46
|
-
? caps.commands.map((c) =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
? caps.commands.map((c) => {
|
|
47
|
+
const tier = c.safetyTier;
|
|
48
|
+
return {
|
|
49
|
+
command: c.command,
|
|
50
|
+
parameter: c.parameter,
|
|
51
|
+
idempotent: c.idempotent,
|
|
52
|
+
...(tier ? { safetyTier: tier } : {}),
|
|
53
|
+
destructive: c.destructive,
|
|
54
|
+
};
|
|
55
|
+
})
|
|
52
56
|
: [];
|
|
53
57
|
const statusFields = caps && 'statusFields' in caps ? caps.statusFields : [];
|
|
54
58
|
const liveStatus = caps && 'liveStatus' in caps ? caps.liveStatus : undefined;
|
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,
|
|
4
|
+
import { printJson, isJsonMode, handleError, UsageError, exitWithError } 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';
|
|
@@ -10,7 +10,7 @@ const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log');
|
|
|
10
10
|
export function registerHistoryCommand(program) {
|
|
11
11
|
const history = program
|
|
12
12
|
.command('history')
|
|
13
|
-
.description('View and replay commands recorded via --audit-log')
|
|
13
|
+
.description('View and replay SwitchBot commands recorded via --audit-log')
|
|
14
14
|
.addHelpText('after', `
|
|
15
15
|
Every 'devices command' run with --audit-log is appended as JSONL to the
|
|
16
16
|
audit file (default ~/.switchbot/audit.log). 'history show' prints the file,
|
|
@@ -70,25 +70,19 @@ Examples:
|
|
|
70
70
|
const entries = readAudit(file);
|
|
71
71
|
const idx = Number(indexArg);
|
|
72
72
|
if (!Number.isInteger(idx) || idx < 1 || idx > entries.length) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
console.error(msg);
|
|
79
|
-
}
|
|
80
|
-
process.exit(2);
|
|
73
|
+
exitWithError({
|
|
74
|
+
code: 2,
|
|
75
|
+
kind: 'usage',
|
|
76
|
+
message: `Invalid index ${indexArg}. Log has ${entries.length} entries.`,
|
|
77
|
+
});
|
|
81
78
|
}
|
|
82
79
|
const entry = entries[idx - 1];
|
|
83
80
|
if (entry.kind !== 'command') {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
console.error(msg);
|
|
90
|
-
}
|
|
91
|
-
process.exit(2);
|
|
81
|
+
exitWithError({
|
|
82
|
+
code: 2,
|
|
83
|
+
kind: 'usage',
|
|
84
|
+
message: `Entry ${idx} is not a command (kind=${entry.kind}).`,
|
|
85
|
+
});
|
|
92
86
|
}
|
|
93
87
|
try {
|
|
94
88
|
const result = await executeCommand(entry.deviceId, entry.command, entry.parameter, entry.commandType);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for SwitchBot product identity.
|
|
3
|
+
*
|
|
4
|
+
* Consumed by:
|
|
5
|
+
* - `program.description()` / `--help` (via PRODUCT_TAGLINE in src/index.ts)
|
|
6
|
+
* - `--help --json` root (via src/utils/help-json.ts)
|
|
7
|
+
* - `switchbot capabilities` / `--json` (identity block)
|
|
8
|
+
* - `switchbot agent-bootstrap --json` (identity block)
|
|
9
|
+
*
|
|
10
|
+
* Keeping this in one file prevents drift between those four surfaces.
|
|
11
|
+
*
|
|
12
|
+
* IMPORTANT: the SwitchBot CLI only talks to the SwitchBot Cloud API over
|
|
13
|
+
* HTTPS. It does NOT drive BLE radios directly — BLE-only devices are
|
|
14
|
+
* reached by going through a SwitchBot Hub, which the Cloud API already
|
|
15
|
+
* handles transparently. Please do not reintroduce the word "BLE" into the
|
|
16
|
+
* tagline / README: it is misleading for AI agents reading `--help`.
|
|
17
|
+
*/
|
|
18
|
+
export const IDENTITY = {
|
|
19
|
+
product: 'SwitchBot',
|
|
20
|
+
domain: 'IoT smart home device control',
|
|
21
|
+
vendor: 'Wonderlabs, Inc.',
|
|
22
|
+
apiVersion: 'v1.1',
|
|
23
|
+
apiDocs: 'https://github.com/OpenWonderLabs/SwitchBotAPI',
|
|
24
|
+
// Product category keywords. AI agents scan these to judge scope
|
|
25
|
+
// ("does SwitchBot control door locks? air conditioners?") without
|
|
26
|
+
// parsing the full device catalog.
|
|
27
|
+
productCategories: [
|
|
28
|
+
'lights (bulbs / strips / color)',
|
|
29
|
+
'locks / keypads',
|
|
30
|
+
'curtains / blinds / shades',
|
|
31
|
+
'sensors (motion / contact / climate / water-leak)',
|
|
32
|
+
'plugs / strips',
|
|
33
|
+
'bots / mechanical pushers',
|
|
34
|
+
'robot vacuums',
|
|
35
|
+
'IR appliances via Hub (TV / AC / fan / projector)',
|
|
36
|
+
],
|
|
37
|
+
deviceCategories: {
|
|
38
|
+
physical: 'Wi-Fi-connected and Hub-mediated devices — controlled via Cloud API (CLI does not drive BLE directly)',
|
|
39
|
+
ir: 'IR remote devices learned by a SwitchBot Hub (TV, AC, fan, etc.)',
|
|
40
|
+
},
|
|
41
|
+
constraints: {
|
|
42
|
+
quotaPerDay: 10000,
|
|
43
|
+
hubRequiredForBle: true,
|
|
44
|
+
transport: 'Cloud API v1.1 (HTTPS)',
|
|
45
|
+
authMethod: 'HMAC-SHA256 token+secret',
|
|
46
|
+
},
|
|
47
|
+
agentGuide: 'docs/agent-guide.md',
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* One-line product description used for `program.description()` (the first
|
|
51
|
+
* line an AI agent sees when running `switchbot --help`).
|
|
52
|
+
*
|
|
53
|
+
* Structure: "SwitchBot smart home CLI — <product categories> via <transport>;
|
|
54
|
+
* <verbs: scenes, events, MCP>." Keep categories in sync with
|
|
55
|
+
* IDENTITY.productCategories above.
|
|
56
|
+
*/
|
|
57
|
+
export const PRODUCT_TAGLINE = 'SwitchBot smart home CLI — control lights, locks, curtains, sensors, plugs, ' +
|
|
58
|
+
'and IR appliances (TV/AC/fan) via Cloud API v1.1; run scenes, stream real-time ' +
|
|
59
|
+
'events, and integrate AI agents via MCP.';
|
package/dist/commands/mcp.js
CHANGED
|
@@ -3,11 +3,11 @@ 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,
|
|
6
|
+
import { handleError, buildErrorPayload, exitWithError } 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';
|
|
10
|
-
import { findCatalogEntry } from '../devices/catalog.js';
|
|
10
|
+
import { findCatalogEntry, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
|
|
11
11
|
import { getCachedDevice } from '../devices/cache.js';
|
|
12
12
|
import { validateParameter } from '../devices/param-validator.js';
|
|
13
13
|
import { EventSubscriptionManager } from '../mcp/events-subscription.js';
|
|
@@ -274,6 +274,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
274
274
|
},
|
|
275
275
|
}, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
|
|
276
276
|
const effectiveType = commandType ?? 'command';
|
|
277
|
+
let effectiveCommand = command;
|
|
277
278
|
let effectiveParameter = parameter;
|
|
278
279
|
// stringifiedParam mirrors the CLI form that validateCommand /
|
|
279
280
|
// validateParameter expect — B-1 runs on the string representation.
|
|
@@ -291,52 +292,37 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
291
292
|
context: { deviceId },
|
|
292
293
|
});
|
|
293
294
|
}
|
|
295
|
+
const dryValidation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
|
|
296
|
+
if (!dryValidation.ok) {
|
|
297
|
+
return mcpError('usage', 2, dryValidation.error.message, {
|
|
298
|
+
hint: dryValidation.error.hint,
|
|
299
|
+
context: {
|
|
300
|
+
validationKind: dryValidation.error.kind,
|
|
301
|
+
deviceType: cached.type,
|
|
302
|
+
command: effectiveCommand,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (dryValidation.normalized) {
|
|
307
|
+
effectiveCommand = dryValidation.normalized;
|
|
308
|
+
}
|
|
294
309
|
// R-2: run B-1 param validation in dry-run too, so dry-run doesn't
|
|
295
310
|
// falsely accept inputs the live API would reject.
|
|
296
311
|
if (effectiveType !== 'customize') {
|
|
297
|
-
const pv = validateParameter(cached.type,
|
|
312
|
+
const pv = validateParameter(cached.type, effectiveCommand, stringifiedParam);
|
|
298
313
|
if (!pv.ok) {
|
|
299
314
|
return mcpError('usage', 2, pv.error, {
|
|
300
315
|
hint: 'Dry-run rejected the parameter client-side; the API would reject it too.',
|
|
301
|
-
context: { deviceType: cached.type, command, parameter: stringifiedParam },
|
|
316
|
+
context: { deviceType: cached.type, command: effectiveCommand, parameter: stringifiedParam },
|
|
302
317
|
});
|
|
303
318
|
}
|
|
304
319
|
if (pv.normalized !== undefined) {
|
|
305
320
|
effectiveParameter = pv.normalized;
|
|
306
321
|
}
|
|
307
322
|
}
|
|
308
|
-
// Bug #55: validateCommand is lenient by design (passes unknown device
|
|
309
|
-
// types, ambiguous catalog matches). For dry-run we need stricter
|
|
310
|
-
// checking — query the catalog directly and reject unknown commands
|
|
311
|
-
// when the catalog has a definitive match.
|
|
312
|
-
if (effectiveType !== 'customize') {
|
|
313
|
-
const catalogMatch = findCatalogEntry(cached.type);
|
|
314
|
-
if (catalogMatch && !Array.isArray(catalogMatch)) {
|
|
315
|
-
const builtinCmds = catalogMatch.commands.filter((c) => c.commandType !== 'customize');
|
|
316
|
-
if (builtinCmds.length > 0) {
|
|
317
|
-
const exactMatch = builtinCmds.find((c) => c.command === command);
|
|
318
|
-
const caseMatch = !exactMatch
|
|
319
|
-
? builtinCmds.find((c) => c.command.toLowerCase() === command.toLowerCase())
|
|
320
|
-
: null;
|
|
321
|
-
if (!exactMatch && !caseMatch) {
|
|
322
|
-
const supported = [...new Set(builtinCmds.map((c) => c.command))].join(', ');
|
|
323
|
-
return mcpError('usage', 2, `"${command}" is not a supported command for ${cached.name} (${cached.type}).`, {
|
|
324
|
-
hint: `Supported commands: ${supported}`,
|
|
325
|
-
context: { validationKind: 'unknown-command', deviceType: cached.type, command },
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
else if (catalogMatch.readOnly) {
|
|
330
|
-
return mcpError('usage', 2, `${cached.name} (${cached.type}) is a read-only sensor — it has no control commands.`, {
|
|
331
|
-
hint: "Use 'get_device_status' to read this device instead.",
|
|
332
|
-
context: { validationKind: 'read-only-device', deviceType: cached.type, command },
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
323
|
const wouldSend = {
|
|
338
324
|
deviceId,
|
|
339
|
-
command,
|
|
325
|
+
command: effectiveCommand,
|
|
340
326
|
parameter: effectiveParameter ?? 'default',
|
|
341
327
|
commandType: effectiveType,
|
|
342
328
|
};
|
|
@@ -361,40 +347,46 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
361
347
|
}
|
|
362
348
|
typeName = physical ? physical.deviceType : ir.remoteType;
|
|
363
349
|
}
|
|
364
|
-
if (isDestructiveCommand(typeName,
|
|
365
|
-
const reason = getDestructiveReason(typeName,
|
|
350
|
+
if (isDestructiveCommand(typeName, effectiveCommand, effectiveType) && !confirm) {
|
|
351
|
+
const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
|
|
366
352
|
const entry = typeName ? findCatalogEntry(typeName) : null;
|
|
367
353
|
const spec = entry && !Array.isArray(entry)
|
|
368
|
-
? entry.commands.find((c) => c.command ===
|
|
354
|
+
? entry.commands.find((c) => c.command === effectiveCommand)
|
|
369
355
|
: undefined;
|
|
370
356
|
const hint = reason
|
|
371
357
|
? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}`
|
|
372
358
|
: 'Re-issue the call with confirm:true to proceed.';
|
|
373
|
-
return mcpError('guard', 3, `Command "${
|
|
359
|
+
return mcpError('guard', 3, `Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`, {
|
|
374
360
|
hint,
|
|
375
361
|
context: {
|
|
376
|
-
command,
|
|
362
|
+
command: effectiveCommand,
|
|
377
363
|
deviceType: typeName,
|
|
378
364
|
description: spec?.description ?? null,
|
|
379
|
-
...(reason ? { destructiveReason: reason } : {}),
|
|
365
|
+
...(reason ? { safetyReason: reason, destructiveReason: reason } : {}),
|
|
380
366
|
},
|
|
381
367
|
});
|
|
382
368
|
}
|
|
383
369
|
// validateCommand covers command existence + required/unexpected-parameter.
|
|
384
370
|
// stringifiedParam was computed once at the top of the handler so dry-run
|
|
385
371
|
// and live paths share the same shape.
|
|
386
|
-
const validation = validateCommand(deviceId,
|
|
372
|
+
const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
|
|
387
373
|
if (!validation.ok) {
|
|
388
|
-
return mcpError('usage', 2, validation.error.message, {
|
|
374
|
+
return mcpError('usage', 2, validation.error.message, {
|
|
375
|
+
hint: validation.error.hint,
|
|
376
|
+
context: { validationKind: validation.error.kind, deviceType: typeName, command: effectiveCommand },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (validation.normalized) {
|
|
380
|
+
effectiveCommand = validation.normalized;
|
|
389
381
|
}
|
|
390
382
|
// R-2: run B-1 client-side parameter validator (range/format checks).
|
|
391
383
|
// Customize commands (user-defined IR buttons) opt out — the catalog
|
|
392
384
|
// cannot know their expected shape.
|
|
393
385
|
if (effectiveType !== 'customize') {
|
|
394
|
-
const pv = validateParameter(typeName,
|
|
386
|
+
const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
|
|
395
387
|
if (!pv.ok) {
|
|
396
388
|
return mcpError('usage', 2, pv.error, {
|
|
397
|
-
context: { deviceType: typeName, command, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
|
|
389
|
+
context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
|
|
398
390
|
});
|
|
399
391
|
}
|
|
400
392
|
if (pv.normalized !== undefined) {
|
|
@@ -403,7 +395,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
403
395
|
}
|
|
404
396
|
let result;
|
|
405
397
|
try {
|
|
406
|
-
result = await executeCommand(deviceId,
|
|
398
|
+
result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, undefined, {
|
|
407
399
|
idempotencyKey,
|
|
408
400
|
});
|
|
409
401
|
}
|
|
@@ -420,7 +412,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
420
412
|
return apiErrorToMcpError(err);
|
|
421
413
|
}
|
|
422
414
|
const isIr = getCachedDevice(deviceId)?.category === 'ir';
|
|
423
|
-
const structured = { ok: true, command, deviceId, result };
|
|
415
|
+
const structured = { ok: true, command: effectiveCommand, deviceId, result };
|
|
424
416
|
if (isIr) {
|
|
425
417
|
structured.verification = {
|
|
426
418
|
verifiable: false,
|
|
@@ -524,6 +516,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
524
516
|
description: z.string(),
|
|
525
517
|
commandType: z.enum(['command', 'customize']).optional(),
|
|
526
518
|
idempotent: z.boolean().optional(),
|
|
519
|
+
safetyTier: z.enum(['read', 'mutation', 'ir-fire-forget', 'destructive', 'maintenance']).optional(),
|
|
520
|
+
safetyReason: z.string().optional(),
|
|
527
521
|
destructive: z.boolean().optional(),
|
|
528
522
|
}).passthrough()),
|
|
529
523
|
aliases: z.array(z.string()).optional(),
|
|
@@ -540,9 +534,22 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
540
534
|
});
|
|
541
535
|
}
|
|
542
536
|
const hits = searchCatalog(query, limit);
|
|
543
|
-
const
|
|
537
|
+
const normalised = hits.map((e) => ({
|
|
538
|
+
...e,
|
|
539
|
+
commands: e.commands.map((c) => {
|
|
540
|
+
const tier = deriveSafetyTier(c, e);
|
|
541
|
+
const reason = getCommandSafetyReason(c);
|
|
542
|
+
return {
|
|
543
|
+
...c,
|
|
544
|
+
safetyTier: tier,
|
|
545
|
+
destructive: tier === 'destructive',
|
|
546
|
+
...(reason ? { safetyReason: reason } : {}),
|
|
547
|
+
};
|
|
548
|
+
}),
|
|
549
|
+
}));
|
|
550
|
+
const structured = { results: normalised, total: normalised.length };
|
|
544
551
|
return {
|
|
545
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
552
|
+
content: [{ type: 'text', text: JSON.stringify(normalised, null, 2) }],
|
|
546
553
|
structuredContent: structured,
|
|
547
554
|
};
|
|
548
555
|
});
|
|
@@ -599,16 +606,64 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
599
606
|
_meta: { agentSafetyTier: 'read' },
|
|
600
607
|
inputSchema: z
|
|
601
608
|
.object({
|
|
602
|
-
deviceId: z.string().min(1),
|
|
603
|
-
since: z
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
609
|
+
deviceId: z.string().min(1).describe('Device ID to aggregate over (must exist in ~/.switchbot/device-history/).'),
|
|
610
|
+
since: z
|
|
611
|
+
.string()
|
|
612
|
+
.optional()
|
|
613
|
+
.describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
|
|
614
|
+
from: z.string().optional().describe('Range start (ISO-8601). Requires `to`.'),
|
|
615
|
+
to: z.string().optional().describe('Range end (ISO-8601). Requires `from`.'),
|
|
616
|
+
metrics: z
|
|
617
|
+
.array(z.string().min(1))
|
|
618
|
+
.min(1)
|
|
619
|
+
.describe('One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).'),
|
|
620
|
+
aggs: z
|
|
621
|
+
.array(z.enum(ALL_AGG_FNS))
|
|
622
|
+
.optional()
|
|
623
|
+
.describe('Aggregation functions to apply per metric (default: ["count","avg"]).'),
|
|
624
|
+
bucket: z
|
|
625
|
+
.string()
|
|
626
|
+
.optional()
|
|
627
|
+
.describe('Bucket width like "5m", "1h", "1d". Omit for a single bucket spanning the full range.'),
|
|
628
|
+
maxBucketSamples: z
|
|
629
|
+
.number()
|
|
630
|
+
.int()
|
|
631
|
+
.positive()
|
|
632
|
+
.max(MAX_SAMPLE_CAP)
|
|
633
|
+
.optional()
|
|
634
|
+
.describe(`Sample cap per bucket to bound memory (default ${10_000}, max ${MAX_SAMPLE_CAP}). partial=true in the result when any bucket was capped.`),
|
|
610
635
|
})
|
|
611
636
|
.strict(),
|
|
637
|
+
outputSchema: {
|
|
638
|
+
deviceId: z.string(),
|
|
639
|
+
bucket: z.string().optional().describe('Bucket width echoed back when specified; omitted for single-bucket results.'),
|
|
640
|
+
from: z.string().describe('Effective range start (ISO-8601).'),
|
|
641
|
+
to: z.string().describe('Effective range end (ISO-8601).'),
|
|
642
|
+
metrics: z.array(z.string()).describe('Metrics that were requested.'),
|
|
643
|
+
aggs: z
|
|
644
|
+
.array(z.enum(ALL_AGG_FNS))
|
|
645
|
+
.describe('Aggregation functions that were applied.'),
|
|
646
|
+
buckets: z
|
|
647
|
+
.array(z.object({
|
|
648
|
+
t: z.string().describe('Bucket start timestamp (ISO-8601).'),
|
|
649
|
+
metrics: z
|
|
650
|
+
.record(z.string(), z
|
|
651
|
+
.object({
|
|
652
|
+
count: z.number().optional(),
|
|
653
|
+
min: z.number().optional(),
|
|
654
|
+
max: z.number().optional(),
|
|
655
|
+
avg: z.number().optional(),
|
|
656
|
+
sum: z.number().optional(),
|
|
657
|
+
p50: z.number().optional(),
|
|
658
|
+
p95: z.number().optional(),
|
|
659
|
+
})
|
|
660
|
+
.describe('Per-aggregate function result for this metric in this bucket.'))
|
|
661
|
+
.describe('Per-metric result keyed by metric name.'),
|
|
662
|
+
}))
|
|
663
|
+
.describe('Time-ordered buckets; empty when no records match.'),
|
|
664
|
+
partial: z.boolean().describe('True if any bucket was sample-capped; retry with a higher maxBucketSamples or a narrower range for exact values.'),
|
|
665
|
+
notes: z.array(z.string()).describe('Human-readable notes about the aggregation (e.g. "metric X is non-numeric").'),
|
|
666
|
+
},
|
|
612
667
|
}, async (args) => {
|
|
613
668
|
const opts = {
|
|
614
669
|
since: args.since,
|
|
@@ -620,9 +675,21 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
620
675
|
maxBucketSamples: args.maxBucketSamples,
|
|
621
676
|
};
|
|
622
677
|
const res = await aggregateDeviceHistory(args.deviceId, opts);
|
|
678
|
+
const structured = {
|
|
679
|
+
deviceId: res.deviceId,
|
|
680
|
+
from: res.from,
|
|
681
|
+
to: res.to,
|
|
682
|
+
metrics: res.metrics,
|
|
683
|
+
aggs: res.aggs,
|
|
684
|
+
buckets: res.buckets,
|
|
685
|
+
partial: res.partial,
|
|
686
|
+
notes: res.notes,
|
|
687
|
+
};
|
|
688
|
+
if (res.bucket !== undefined)
|
|
689
|
+
structured.bucket = res.bucket;
|
|
623
690
|
return {
|
|
624
691
|
content: [{ type: 'text', text: JSON.stringify(res, null, 2) }],
|
|
625
|
-
structuredContent:
|
|
692
|
+
structuredContent: structured,
|
|
626
693
|
};
|
|
627
694
|
});
|
|
628
695
|
// ---- account_overview ---------------------------------------------------
|
|
@@ -737,6 +804,18 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
737
804
|
}
|
|
738
805
|
return server;
|
|
739
806
|
}
|
|
807
|
+
/**
|
|
808
|
+
* P10: list the tool names registered on an McpServer instance. Used by
|
|
809
|
+
* `doctor`'s dry-run check. The MCP SDK keeps `_registeredTools` private,
|
|
810
|
+
* so we reach through a narrow cast — safe because this only runs in
|
|
811
|
+
* diagnostic code and the shape is stable across SDK versions.
|
|
812
|
+
*/
|
|
813
|
+
export function listRegisteredTools(server) {
|
|
814
|
+
const internal = server;
|
|
815
|
+
if (!internal._registeredTools)
|
|
816
|
+
return [];
|
|
817
|
+
return Object.keys(internal._registeredTools).sort();
|
|
818
|
+
}
|
|
740
819
|
export function registerMcpCommand(program) {
|
|
741
820
|
const mcp = program
|
|
742
821
|
.command('mcp')
|
|
@@ -786,19 +865,19 @@ Inspect locally:
|
|
|
786
865
|
.option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)', stringArg('--auth-token'))
|
|
787
866
|
.option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
|
|
788
867
|
.option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60')
|
|
868
|
+
.addHelpText('after', `
|
|
869
|
+
Examples:
|
|
870
|
+
$ switchbot mcp serve
|
|
871
|
+
$ switchbot mcp serve --port 8787
|
|
872
|
+
$ switchbot mcp serve --port 8787 --bind 127.0.0.1 --auth-token your-token
|
|
873
|
+
$ switchbot mcp serve --port 8787 --bind 0.0.0.0 --auth-token your-token
|
|
874
|
+
`)
|
|
789
875
|
.action(async (options) => {
|
|
790
876
|
try {
|
|
791
877
|
if (options.port) {
|
|
792
878
|
const port = Number(options.port);
|
|
793
879
|
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
794
|
-
|
|
795
|
-
if (isJsonMode()) {
|
|
796
|
-
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
797
|
-
}
|
|
798
|
-
else {
|
|
799
|
-
console.error(msg);
|
|
800
|
-
}
|
|
801
|
-
process.exit(2);
|
|
880
|
+
exitWithError(`Invalid --port "${options.port}". Must be 1-65535.`);
|
|
802
881
|
}
|
|
803
882
|
const bind = options.bind ?? '127.0.0.1';
|
|
804
883
|
const authToken = options.authToken ?? process.env.SWITCHBOT_MCP_TOKEN;
|
|
@@ -807,14 +886,7 @@ Inspect locally:
|
|
|
807
886
|
// Guard: refuse to bind non-localhost without auth
|
|
808
887
|
const isLocalhost = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1';
|
|
809
888
|
if (!isLocalhost && !authToken) {
|
|
810
|
-
|
|
811
|
-
if (isJsonMode()) {
|
|
812
|
-
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
813
|
-
}
|
|
814
|
-
else {
|
|
815
|
-
console.error(msg);
|
|
816
|
-
}
|
|
817
|
-
process.exit(2);
|
|
889
|
+
exitWithError('Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token <token> or bind to localhost (default).');
|
|
818
890
|
}
|
|
819
891
|
const { createServer } = await import('node:http');
|
|
820
892
|
const rateLimitMap = new Map();
|
|
@@ -1033,6 +1105,29 @@ process_uptime_seconds ${Math.floor(process.uptime())}
|
|
|
1033
1105
|
const server = createSwitchBotMcpServer({ eventManager });
|
|
1034
1106
|
const transport = new StdioServerTransport();
|
|
1035
1107
|
await server.connect(transport);
|
|
1108
|
+
let isShuttingDown = false;
|
|
1109
|
+
const gracefulShutdown = async () => {
|
|
1110
|
+
if (isShuttingDown)
|
|
1111
|
+
return;
|
|
1112
|
+
isShuttingDown = true;
|
|
1113
|
+
console.error('Shutting down...');
|
|
1114
|
+
// Force exit after 30s if shutdown hangs (e.g. stuck MQTT disconnect).
|
|
1115
|
+
const forceExit = setTimeout(() => {
|
|
1116
|
+
console.error('Force exiting after 30s timeout');
|
|
1117
|
+
process.exit(1);
|
|
1118
|
+
}, 30000);
|
|
1119
|
+
forceExit.unref();
|
|
1120
|
+
try {
|
|
1121
|
+
await eventManager.shutdown();
|
|
1122
|
+
}
|
|
1123
|
+
catch (err) {
|
|
1124
|
+
console.error('Error during shutdown:', err instanceof Error ? err.message : String(err));
|
|
1125
|
+
}
|
|
1126
|
+
process.exit(0);
|
|
1127
|
+
};
|
|
1128
|
+
process.on('SIGTERM', gracefulShutdown);
|
|
1129
|
+
process.on('SIGINT', gracefulShutdown);
|
|
1130
|
+
process.stdin.on('end', gracefulShutdown);
|
|
1036
1131
|
}
|
|
1037
1132
|
catch (error) {
|
|
1038
1133
|
handleError(error);
|
package/dist/commands/plan.js
CHANGED
|
@@ -160,7 +160,7 @@ function readStdin() {
|
|
|
160
160
|
export function registerPlanCommand(program) {
|
|
161
161
|
const plan = program
|
|
162
162
|
.command('plan')
|
|
163
|
-
.description('
|
|
163
|
+
.description('Author, validate, and run SwitchBot batch plans (JSON schema for AI agents)')
|
|
164
164
|
.addHelpText('after', `
|
|
165
165
|
A "plan" is a JSON document describing a sequence of commands/scenes/waits.
|
|
166
166
|
The schema is fixed — agents emit plans, the CLI executes them. No LLM inside.
|
package/dist/commands/schema.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
2
2
|
import { printJson } from '../utils/output.js';
|
|
3
|
-
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
3
|
+
import { getEffectiveCatalog, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
|
|
4
|
+
import { RESOURCE_CATALOG } from '../devices/resources.js';
|
|
4
5
|
import { loadCache } from '../devices/cache.js';
|
|
5
6
|
function toSchemaEntry(e) {
|
|
6
7
|
return {
|
|
@@ -10,18 +11,22 @@ function toSchemaEntry(e) {
|
|
|
10
11
|
aliases: e.aliases ?? [],
|
|
11
12
|
role: e.role ?? null,
|
|
12
13
|
readOnly: e.readOnly ?? false,
|
|
13
|
-
commands: e.commands.map(toSchemaCommand),
|
|
14
|
+
commands: e.commands.map((c) => toSchemaCommand(c, e)),
|
|
14
15
|
statusFields: e.statusFields ?? [],
|
|
15
16
|
};
|
|
16
17
|
}
|
|
17
|
-
function toSchemaCommand(c) {
|
|
18
|
+
function toSchemaCommand(c, entry) {
|
|
19
|
+
const tier = deriveSafetyTier(c, entry);
|
|
20
|
+
const reason = getCommandSafetyReason(c);
|
|
18
21
|
return {
|
|
19
22
|
command: c.command,
|
|
20
23
|
parameter: c.parameter,
|
|
21
24
|
description: c.description,
|
|
22
25
|
commandType: (c.commandType ?? 'command'),
|
|
23
26
|
idempotent: Boolean(c.idempotent),
|
|
24
|
-
|
|
27
|
+
safetyTier: tier,
|
|
28
|
+
destructive: tier === 'destructive',
|
|
29
|
+
...(reason ? { safetyReason: reason } : {}),
|
|
25
30
|
...(c.exampleParams ? { exampleParams: c.exampleParams } : {}),
|
|
26
31
|
};
|
|
27
32
|
}
|
|
@@ -31,13 +36,17 @@ function toCompactEntry(e) {
|
|
|
31
36
|
category: e.category,
|
|
32
37
|
role: e.role ?? null,
|
|
33
38
|
readOnly: e.readOnly ?? false,
|
|
34
|
-
commands: e.commands.map((c) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
commands: e.commands.map((c) => {
|
|
40
|
+
const tier = deriveSafetyTier(c, e);
|
|
41
|
+
return {
|
|
42
|
+
command: c.command,
|
|
43
|
+
parameter: c.parameter,
|
|
44
|
+
commandType: (c.commandType ?? 'command'),
|
|
45
|
+
idempotent: Boolean(c.idempotent),
|
|
46
|
+
safetyTier: tier,
|
|
47
|
+
destructive: tier === 'destructive',
|
|
48
|
+
};
|
|
49
|
+
}),
|
|
41
50
|
statusFields: e.statusFields ?? [],
|
|
42
51
|
};
|
|
43
52
|
}
|
|
@@ -54,7 +63,7 @@ export function registerSchemaCommand(program) {
|
|
|
54
63
|
const CATEGORIES = ['physical', 'ir'];
|
|
55
64
|
const schema = program
|
|
56
65
|
.command('schema')
|
|
57
|
-
.description('Export the device catalog as structured JSON (for agent prompts / tooling)');
|
|
66
|
+
.description('Export the SwitchBot device catalog as structured JSON (for AI agent prompts / tooling)');
|
|
58
67
|
schema
|
|
59
68
|
.command('export')
|
|
60
69
|
.description('Print the catalog as structured JSON (one object per type)')
|
|
@@ -137,6 +146,7 @@ Examples:
|
|
|
137
146
|
};
|
|
138
147
|
if (!options.compact) {
|
|
139
148
|
payload.generatedAt = new Date().toISOString();
|
|
149
|
+
payload.resources = RESOURCE_CATALOG;
|
|
140
150
|
payload.cliAddedFields = [
|
|
141
151
|
{
|
|
142
152
|
field: '_fetchedAt',
|