@switchbot/openapi-cli 2.6.3 → 2.6.4
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/dist/commands/batch.js +3 -15
- package/dist/commands/catalog.js +8 -0
- package/dist/commands/config.js +5 -1
- package/dist/commands/devices.js +70 -56
- package/dist/commands/mcp.js +68 -60
- package/dist/config.js +65 -21
- package/dist/devices/catalog.js +1 -1
- package/dist/index.js +10 -4
- package/dist/lib/devices.js +6 -2
- package/dist/schema/field-aliases.js +36 -0
- package/dist/utils/filter.js +17 -4
- package/dist/utils/output.js +20 -0
- package/package.json +1 -1
package/dist/commands/batch.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { intArg, enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
2
|
-
import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, emitJsonError } from '../utils/output.js';
|
|
2
|
+
import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, emitJsonError, exitWithError } from '../utils/output.js';
|
|
3
3
|
import { fetchDeviceList, executeCommand, isDestructiveCommand, buildHubLocationMap, } from '../lib/devices.js';
|
|
4
4
|
import { createClient } from '../api/client.js';
|
|
5
5
|
import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js';
|
|
@@ -166,22 +166,10 @@ Examples:
|
|
|
166
166
|
}
|
|
167
167
|
catch (error) {
|
|
168
168
|
if (error instanceof FilterSyntaxError) {
|
|
169
|
-
|
|
170
|
-
emitJsonError({ code: 2, kind: 'usage', message: error.message });
|
|
171
|
-
}
|
|
172
|
-
else {
|
|
173
|
-
console.error(`Error: ${error.message}`);
|
|
174
|
-
}
|
|
175
|
-
process.exit(2);
|
|
169
|
+
exitWithError(`Error: ${error.message}`);
|
|
176
170
|
}
|
|
177
171
|
if (error instanceof Error && error.message.startsWith('No target devices')) {
|
|
178
|
-
|
|
179
|
-
emitJsonError({ code: 2, kind: 'usage', message: error.message });
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
console.error(`Error: ${error.message}`);
|
|
183
|
-
}
|
|
184
|
-
process.exit(2);
|
|
172
|
+
exitWithError(`Error: ${error.message}`);
|
|
185
173
|
}
|
|
186
174
|
handleError(error);
|
|
187
175
|
}
|
package/dist/commands/catalog.js
CHANGED
|
@@ -74,6 +74,14 @@ Examples:
|
|
|
74
74
|
.description("Show the effective catalog (or one entry). Alias: 'list'. Defaults to 'effective' source.")
|
|
75
75
|
.argument('[type...]', 'Optional device type/alias (case-insensitive, partial match)')
|
|
76
76
|
.option('--source <source>', 'Which catalog to show: built-in | overlay | effective (default)', enumArg('--source', SOURCES), 'effective')
|
|
77
|
+
.addHelpText('after', `
|
|
78
|
+
Examples:
|
|
79
|
+
$ switchbot catalog show
|
|
80
|
+
$ switchbot catalog show Bot
|
|
81
|
+
$ switchbot catalog show Robot Vacuum
|
|
82
|
+
$ switchbot catalog show --source built-in
|
|
83
|
+
$ switchbot catalog show --json
|
|
84
|
+
`)
|
|
77
85
|
.action((typeParts, options) => {
|
|
78
86
|
try {
|
|
79
87
|
const source = options.source;
|
package/dist/commands/config.js
CHANGED
|
@@ -3,7 +3,7 @@ import readline from 'node:readline';
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import { stringArg } from '../utils/arg-parsers.js';
|
|
5
5
|
import { intArg } from '../utils/arg-parsers.js';
|
|
6
|
-
import { saveConfig, showConfig, listProfiles, readProfileMeta } from '../config.js';
|
|
6
|
+
import { saveConfig, showConfig, getConfigSummary, listProfiles, readProfileMeta } from '../config.js';
|
|
7
7
|
import { isJsonMode, printJson, emitJsonError } from '../utils/output.js';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
function parseEnvFile(file) {
|
|
@@ -237,6 +237,10 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
237
237
|
.command('show')
|
|
238
238
|
.description('Show the current credential source and a masked secret')
|
|
239
239
|
.action(() => {
|
|
240
|
+
if (isJsonMode()) {
|
|
241
|
+
printJson(getConfigSummary());
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
240
244
|
showConfig();
|
|
241
245
|
});
|
|
242
246
|
config
|
package/dist/commands/devices.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
2
|
-
import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, emitJsonError } from '../utils/output.js';
|
|
2
|
+
import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, emitJsonError, exitWithError } from '../utils/output.js';
|
|
3
3
|
import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
|
|
4
4
|
import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js';
|
|
5
5
|
import { getCachedDevice, loadCache } from '../devices/cache.js';
|
|
@@ -15,6 +15,14 @@ import { registerExpandCommand } from './expand.js';
|
|
|
15
15
|
import { registerDevicesMetaCommand } from './device-meta.js';
|
|
16
16
|
import { isDryRun } from '../utils/flags.js';
|
|
17
17
|
import { DryRunSignal } from '../api/client.js';
|
|
18
|
+
import { resolveField, listSupportedFieldInputs } from '../schema/field-aliases.js';
|
|
19
|
+
const EXPAND_HINTS = {
|
|
20
|
+
'Air Conditioner': { command: 'setAll', flags: '--temp 26 --mode cool --fan low --power on' },
|
|
21
|
+
'Curtain': { command: 'setPosition', flags: '--position 50 --mode silent' },
|
|
22
|
+
'Curtain 3': { command: 'setPosition', flags: '--position 50' },
|
|
23
|
+
'Blind Tilt': { command: 'setPosition', flags: '--direction up --angle 50' },
|
|
24
|
+
'Relay Switch 2PM': { command: 'setMode', flags: '--channel 1 --mode edge' },
|
|
25
|
+
};
|
|
18
26
|
export function registerDevicesCommand(program) {
|
|
19
27
|
const COMMAND_TYPES = ['command', 'customize'];
|
|
20
28
|
const devices = program
|
|
@@ -80,7 +88,7 @@ Examples:
|
|
|
80
88
|
`)
|
|
81
89
|
.option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
|
|
82
90
|
.option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
|
|
83
|
-
.option('--filter <expr>', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys:
|
|
91
|
+
.option('--filter <expr>', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: deviceId/id, deviceName/name, deviceType/type, controlType, roomName/room, category.', stringArg('--filter'))
|
|
84
92
|
.action(async (options) => {
|
|
85
93
|
try {
|
|
86
94
|
const body = await fetchDeviceList();
|
|
@@ -90,11 +98,26 @@ Examples:
|
|
|
90
98
|
const hubLocation = buildHubLocationMap(deviceList);
|
|
91
99
|
// Parse --filter into a list of clauses. Shared grammar across
|
|
92
100
|
// `devices list`, `devices batch`, and `events tail` / `mqtt-tail`.
|
|
93
|
-
const LIST_KEYS = ['type', 'name', 'category', 'room'];
|
|
101
|
+
const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room', 'controlType'];
|
|
102
|
+
const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'controlType', 'roomName', 'category'];
|
|
103
|
+
const LIST_FILTER_TO_RUNTIME = {
|
|
104
|
+
deviceId: 'deviceId',
|
|
105
|
+
deviceName: 'name',
|
|
106
|
+
deviceType: 'type',
|
|
107
|
+
controlType: 'controlType',
|
|
108
|
+
roomName: 'room',
|
|
109
|
+
category: 'category',
|
|
110
|
+
};
|
|
94
111
|
let listClauses = null;
|
|
95
112
|
if (options.filter) {
|
|
96
113
|
try {
|
|
97
|
-
listClauses = parseFilterExpr(options.filter, LIST_KEYS
|
|
114
|
+
listClauses = parseFilterExpr(options.filter, LIST_KEYS, {
|
|
115
|
+
resolveKey: (input) => {
|
|
116
|
+
const canonical = resolveField(input, LIST_FILTER_CANONICAL);
|
|
117
|
+
return LIST_FILTER_TO_RUNTIME[canonical];
|
|
118
|
+
},
|
|
119
|
+
supportedKeys: listSupportedFieldInputs(LIST_FILTER_CANONICAL),
|
|
120
|
+
});
|
|
98
121
|
}
|
|
99
122
|
catch (err) {
|
|
100
123
|
if (err instanceof FilterSyntaxError)
|
|
@@ -114,10 +137,10 @@ Examples:
|
|
|
114
137
|
};
|
|
115
138
|
if (fmt === 'json' && process.argv.includes('--json')) {
|
|
116
139
|
if (listClauses) {
|
|
117
|
-
const filteredDeviceList = deviceList.filter((d) => matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }));
|
|
140
|
+
const filteredDeviceList = deviceList.filter((d) => matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }));
|
|
118
141
|
const filteredIrList = infraredRemoteList.filter((d) => {
|
|
119
142
|
const inherited = hubLocation.get(d.hubDeviceId);
|
|
120
|
-
return matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' });
|
|
143
|
+
return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' });
|
|
121
144
|
});
|
|
122
145
|
printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList });
|
|
123
146
|
}
|
|
@@ -134,7 +157,7 @@ Examples:
|
|
|
134
157
|
for (const d of deviceList) {
|
|
135
158
|
if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
|
|
136
159
|
continue;
|
|
137
|
-
if (!matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }))
|
|
160
|
+
if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }))
|
|
138
161
|
continue;
|
|
139
162
|
rows.push([
|
|
140
163
|
d.deviceId,
|
|
@@ -154,7 +177,7 @@ Examples:
|
|
|
154
177
|
if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
|
|
155
178
|
continue;
|
|
156
179
|
const inherited = hubLocation.get(d.hubDeviceId);
|
|
157
|
-
if (!matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' }))
|
|
180
|
+
if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' }))
|
|
158
181
|
continue;
|
|
159
182
|
rows.push([
|
|
160
183
|
d.deviceId,
|
|
@@ -177,9 +200,19 @@ Examples:
|
|
|
177
200
|
const defaultFields = options.wide ? undefined : narrowHeaders;
|
|
178
201
|
// Accept API field names and short aliases alongside canonical column names
|
|
179
202
|
const DEVICE_LIST_ALIASES = {
|
|
180
|
-
id: 'deviceId',
|
|
181
|
-
|
|
182
|
-
|
|
203
|
+
id: 'deviceId',
|
|
204
|
+
name: 'deviceName',
|
|
205
|
+
deviceType: 'type',
|
|
206
|
+
type: 'type',
|
|
207
|
+
roomName: 'room',
|
|
208
|
+
familyName: 'family',
|
|
209
|
+
hubDeviceId: 'hub',
|
|
210
|
+
enableCloudService: 'cloud',
|
|
211
|
+
controlType: 'controlType',
|
|
212
|
+
deviceName: 'deviceName',
|
|
213
|
+
deviceId: 'deviceId',
|
|
214
|
+
category: 'category',
|
|
215
|
+
alias: 'alias',
|
|
183
216
|
};
|
|
184
217
|
renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_LIST_ALIASES);
|
|
185
218
|
if (fmt === 'table') {
|
|
@@ -454,18 +487,10 @@ Examples:
|
|
|
454
487
|
if (cachedForParam && options.type === 'command' && !options.skipParamValidation) {
|
|
455
488
|
const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
|
|
456
489
|
if (!paramCheck.ok) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
message: paramCheck.error,
|
|
462
|
-
context: { command: cmd, deviceType: cachedForParam.type, deviceId, humanHint: paramCheck.error },
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
else {
|
|
466
|
-
console.error(`Error: ${paramCheck.error}`);
|
|
467
|
-
}
|
|
468
|
-
process.exit(2);
|
|
490
|
+
exitWithError({
|
|
491
|
+
message: `Error: ${paramCheck.error}`,
|
|
492
|
+
context: { command: cmd, deviceType: cachedForParam.type, deviceId, humanHint: paramCheck.error },
|
|
493
|
+
});
|
|
469
494
|
}
|
|
470
495
|
if (paramCheck.normalized !== undefined)
|
|
471
496
|
parameter = paramCheck.normalized;
|
|
@@ -476,24 +501,14 @@ Examples:
|
|
|
476
501
|
isDestructiveCommand(cachedForGuard?.type, cmd, options.type)) {
|
|
477
502
|
const typeLabel = cachedForGuard?.type ?? 'unknown';
|
|
478
503
|
const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
else {
|
|
491
|
-
console.error(`Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`);
|
|
492
|
-
if (reason)
|
|
493
|
-
console.error(`Reason: ${reason}`);
|
|
494
|
-
console.error(`Re-run with --yes to confirm, or --dry-run to preview without sending.`);
|
|
495
|
-
}
|
|
496
|
-
process.exit(2);
|
|
504
|
+
exitWithError({
|
|
505
|
+
kind: 'guard',
|
|
506
|
+
message: `Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`,
|
|
507
|
+
hint: reason
|
|
508
|
+
? `Re-run with --yes to confirm. Reason: ${reason}`
|
|
509
|
+
: 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
|
|
510
|
+
context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
|
|
511
|
+
});
|
|
497
512
|
}
|
|
498
513
|
// Warn when --yes is given but the command is not destructive (no-op flag)
|
|
499
514
|
if (options.yes && !isDestructiveCommand(cachedForGuard?.type, cmd, options.type) && !isDryRun()) {
|
|
@@ -702,7 +717,8 @@ JSON output shape (--json):
|
|
|
702
717
|
liveStatus: <status payload when --live was passed>
|
|
703
718
|
},
|
|
704
719
|
source: "catalog" | "live" | "catalog+live" | "none",
|
|
705
|
-
suggestedActions: [{command, parameter?, description}]
|
|
720
|
+
suggestedActions: [{command, parameter?, description}],
|
|
721
|
+
expandHint?: {command, flags, example} // present when the type supports 'devices expand'
|
|
706
722
|
}
|
|
707
723
|
|
|
708
724
|
Examples:
|
|
@@ -722,6 +738,7 @@ Examples:
|
|
|
722
738
|
const result = await describeDevice(deviceId, options);
|
|
723
739
|
const { device, isPhysical, typeName, controlType, catalog, capabilities, source, suggestedActions: picks } = result;
|
|
724
740
|
if (isJsonMode()) {
|
|
741
|
+
const expandHint = catalog ? EXPAND_HINTS[catalog.type] : undefined;
|
|
725
742
|
printJson({
|
|
726
743
|
device,
|
|
727
744
|
controlType,
|
|
@@ -729,6 +746,7 @@ Examples:
|
|
|
729
746
|
capabilities,
|
|
730
747
|
source,
|
|
731
748
|
suggestedActions: picks,
|
|
749
|
+
...(expandHint ? { expandHint: { command: expandHint.command, flags: expandHint.flags, example: `switchbot devices expand ${deviceId} ${expandHint.command} ${expandHint.flags}` } } : {}),
|
|
732
750
|
});
|
|
733
751
|
return;
|
|
734
752
|
}
|
|
@@ -785,20 +803,12 @@ Examples:
|
|
|
785
803
|
catch (error) {
|
|
786
804
|
if (error instanceof DeviceNotFoundError) {
|
|
787
805
|
const message = `${error.message} Try 'switchbot devices list' to see the full list.`;
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
transient: false,
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
else {
|
|
798
|
-
console.error(error.message);
|
|
799
|
-
console.error(`Try 'switchbot devices list' to see the full list.`);
|
|
800
|
-
}
|
|
801
|
-
process.exit(1);
|
|
806
|
+
exitWithError({
|
|
807
|
+
code: 1,
|
|
808
|
+
kind: 'runtime',
|
|
809
|
+
message,
|
|
810
|
+
extra: { errorClass: 'runtime', transient: false },
|
|
811
|
+
});
|
|
802
812
|
}
|
|
803
813
|
handleError(error);
|
|
804
814
|
}
|
|
@@ -853,4 +863,8 @@ function renderCatalogEntry(entry) {
|
|
|
853
863
|
console.log('\nStatus fields (from "devices status"):');
|
|
854
864
|
console.log(' ' + entry.statusFields.join(', '));
|
|
855
865
|
}
|
|
866
|
+
const expandHint = EXPAND_HINTS[entry.type];
|
|
867
|
+
if (expandHint) {
|
|
868
|
+
console.log(`\nTip: Use 'devices expand <id> ${expandHint.command} ${expandHint.flags}' for semantic flags instead of raw parameters.`);
|
|
869
|
+
}
|
|
856
870
|
}
|
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,
|
|
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';
|
|
@@ -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,19 +347,19 @@ 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
365
|
...(reason ? { destructiveReason: reason } : {}),
|
|
@@ -383,18 +369,24 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
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,
|
|
@@ -786,19 +778,19 @@ Inspect locally:
|
|
|
786
778
|
.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
779
|
.option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
|
|
788
780
|
.option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60')
|
|
781
|
+
.addHelpText('after', `
|
|
782
|
+
Examples:
|
|
783
|
+
$ switchbot mcp serve
|
|
784
|
+
$ switchbot mcp serve --port 8787
|
|
785
|
+
$ switchbot mcp serve --port 8787 --bind 127.0.0.1 --auth-token your-token
|
|
786
|
+
$ switchbot mcp serve --port 8787 --bind 0.0.0.0 --auth-token your-token
|
|
787
|
+
`)
|
|
789
788
|
.action(async (options) => {
|
|
790
789
|
try {
|
|
791
790
|
if (options.port) {
|
|
792
791
|
const port = Number(options.port);
|
|
793
792
|
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);
|
|
793
|
+
exitWithError(`Invalid --port "${options.port}". Must be 1-65535.`);
|
|
802
794
|
}
|
|
803
795
|
const bind = options.bind ?? '127.0.0.1';
|
|
804
796
|
const authToken = options.authToken ?? process.env.SWITCHBOT_MCP_TOKEN;
|
|
@@ -807,14 +799,7 @@ Inspect locally:
|
|
|
807
799
|
// Guard: refuse to bind non-localhost without auth
|
|
808
800
|
const isLocalhost = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1';
|
|
809
801
|
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);
|
|
802
|
+
exitWithError('Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token <token> or bind to localhost (default).');
|
|
818
803
|
}
|
|
819
804
|
const { createServer } = await import('node:http');
|
|
820
805
|
const rateLimitMap = new Map();
|
|
@@ -1033,6 +1018,29 @@ process_uptime_seconds ${Math.floor(process.uptime())}
|
|
|
1033
1018
|
const server = createSwitchBotMcpServer({ eventManager });
|
|
1034
1019
|
const transport = new StdioServerTransport();
|
|
1035
1020
|
await server.connect(transport);
|
|
1021
|
+
let isShuttingDown = false;
|
|
1022
|
+
const gracefulShutdown = async () => {
|
|
1023
|
+
if (isShuttingDown)
|
|
1024
|
+
return;
|
|
1025
|
+
isShuttingDown = true;
|
|
1026
|
+
console.error('Shutting down...');
|
|
1027
|
+
// Force exit after 30s if shutdown hangs (e.g. stuck MQTT disconnect).
|
|
1028
|
+
const forceExit = setTimeout(() => {
|
|
1029
|
+
console.error('Force exiting after 30s timeout');
|
|
1030
|
+
process.exit(1);
|
|
1031
|
+
}, 30000);
|
|
1032
|
+
forceExit.unref();
|
|
1033
|
+
try {
|
|
1034
|
+
await eventManager.shutdown();
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
console.error('Error during shutdown:', err instanceof Error ? err.message : String(err));
|
|
1038
|
+
}
|
|
1039
|
+
process.exit(0);
|
|
1040
|
+
};
|
|
1041
|
+
process.on('SIGTERM', gracefulShutdown);
|
|
1042
|
+
process.on('SIGINT', gracefulShutdown);
|
|
1043
|
+
process.stdin.on('end', gracefulShutdown);
|
|
1036
1044
|
}
|
|
1037
1045
|
catch (error) {
|
|
1038
1046
|
handleError(error);
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { getConfigPath } from './utils/flags.js';
|
|
5
5
|
import { getActiveProfile } from './lib/request-context.js';
|
|
6
|
+
import { emitJsonError, isJsonMode } from './utils/output.js';
|
|
6
7
|
function sanitizeOptionalString(v) {
|
|
7
8
|
if (typeof v !== 'string')
|
|
8
9
|
return undefined;
|
|
@@ -51,20 +52,36 @@ export function loadConfig() {
|
|
|
51
52
|
const hint = profile
|
|
52
53
|
? `No credentials configured for profile "${profile}". Run: switchbot --profile ${profile} config set-token <token> <secret>`
|
|
53
54
|
: 'No credentials configured. Run: switchbot config set-token <token> <secret>';
|
|
54
|
-
|
|
55
|
+
const msg = `${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`;
|
|
56
|
+
if (isJsonMode()) {
|
|
57
|
+
emitJsonError({ code: 1, kind: 'runtime', message: hint });
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.error(msg);
|
|
61
|
+
}
|
|
55
62
|
process.exit(1);
|
|
56
63
|
}
|
|
57
64
|
try {
|
|
58
65
|
const raw = fs.readFileSync(file, 'utf-8');
|
|
59
66
|
const cfg = JSON.parse(raw);
|
|
60
67
|
if (!cfg.token || !cfg.secret) {
|
|
61
|
-
|
|
68
|
+
if (isJsonMode()) {
|
|
69
|
+
emitJsonError({ code: 1, kind: 'runtime', message: 'Invalid config format. Please re-run: switchbot config set-token' });
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
console.error('Invalid config format. Please re-run: switchbot config set-token');
|
|
73
|
+
}
|
|
62
74
|
process.exit(1);
|
|
63
75
|
}
|
|
64
76
|
return cfg;
|
|
65
77
|
}
|
|
66
78
|
catch {
|
|
67
|
-
|
|
79
|
+
if (isJsonMode()) {
|
|
80
|
+
emitJsonError({ code: 1, kind: 'runtime', message: 'Failed to read config file. Please re-run: switchbot config set-token' });
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.error('Failed to read config file. Please re-run: switchbot config set-token');
|
|
84
|
+
}
|
|
68
85
|
process.exit(1);
|
|
69
86
|
}
|
|
70
87
|
}
|
|
@@ -156,36 +173,63 @@ export function readProfileMeta(profile) {
|
|
|
156
173
|
}
|
|
157
174
|
}
|
|
158
175
|
export function showConfig() {
|
|
176
|
+
const summary = getConfigSummary();
|
|
177
|
+
if (summary.source === 'env') {
|
|
178
|
+
console.log('Credential source: environment variables');
|
|
179
|
+
console.log(`token : ${summary.token ?? ''}`);
|
|
180
|
+
console.log(`secret: ${summary.secret ?? ''}`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (summary.source === 'none') {
|
|
184
|
+
console.log('No credentials configured');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (summary.source === 'invalid') {
|
|
188
|
+
console.error('Failed to read config file');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
console.log(`Credential source: ${summary.path}`);
|
|
192
|
+
if (summary.label)
|
|
193
|
+
console.log(`label : ${summary.label}`);
|
|
194
|
+
if (summary.description)
|
|
195
|
+
console.log(`desc : ${summary.description}`);
|
|
196
|
+
console.log(`token : ${summary.token ?? ''}`);
|
|
197
|
+
console.log(`secret: ${summary.secret ?? ''}`);
|
|
198
|
+
if (summary.dailyCap)
|
|
199
|
+
console.log(`limits: dailyCap=${summary.dailyCap}`);
|
|
200
|
+
if (summary.defaultFlags?.length)
|
|
201
|
+
console.log(`defaults: ${summary.defaultFlags.join(' ')}`);
|
|
202
|
+
}
|
|
203
|
+
export function getConfigSummary() {
|
|
159
204
|
const envToken = process.env.SWITCHBOT_TOKEN;
|
|
160
205
|
const envSecret = process.env.SWITCHBOT_SECRET;
|
|
161
206
|
if (envToken && envSecret) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
207
|
+
return {
|
|
208
|
+
source: 'env',
|
|
209
|
+
token: maskCredential(envToken),
|
|
210
|
+
secret: maskSecret(envSecret),
|
|
211
|
+
};
|
|
166
212
|
}
|
|
167
213
|
const file = configFilePath();
|
|
168
214
|
if (!fs.existsSync(file)) {
|
|
169
|
-
|
|
170
|
-
return;
|
|
215
|
+
return { source: 'none' };
|
|
171
216
|
}
|
|
172
217
|
try {
|
|
173
218
|
const raw = fs.readFileSync(file, 'utf-8');
|
|
174
219
|
const cfg = JSON.parse(raw);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
console.log(`defaults: ${cfg.defaults.flags.join(' ')}`);
|
|
220
|
+
return {
|
|
221
|
+
source: 'file',
|
|
222
|
+
path: file,
|
|
223
|
+
label: cfg.label,
|
|
224
|
+
description: cfg.description,
|
|
225
|
+
token: maskCredential(cfg.token),
|
|
226
|
+
secret: maskSecret(cfg.secret),
|
|
227
|
+
dailyCap: cfg.limits?.dailyCap,
|
|
228
|
+
defaultFlags: cfg.defaults?.flags,
|
|
229
|
+
};
|
|
186
230
|
}
|
|
187
231
|
catch {
|
|
188
|
-
|
|
232
|
+
return { source: 'invalid', path: file };
|
|
189
233
|
}
|
|
190
234
|
}
|
|
191
235
|
function maskCredential(token) {
|
package/dist/devices/catalog.js
CHANGED
|
@@ -219,7 +219,7 @@ export const DEVICE_CATALOG = [
|
|
|
219
219
|
category: 'physical',
|
|
220
220
|
description: 'Entry-level robot vacuum with start/stop/dock and four suction power levels.',
|
|
221
221
|
role: 'cleaning',
|
|
222
|
-
aliases: ['Robot Vacuum Cleaner S1 Plus', 'K10+'],
|
|
222
|
+
aliases: ['Robot Vacuum', 'Robot Vacuum Cleaner S1 Plus', 'K10+'],
|
|
223
223
|
commands: [
|
|
224
224
|
{ command: 'start', parameter: '—', description: 'Start cleaning', idempotent: true },
|
|
225
225
|
{ command: 'stop', parameter: '—', description: 'Stop cleaning', idempotent: true },
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
|
|
6
6
|
import { parseDurationToMs } from './utils/flags.js';
|
|
7
|
+
import { emitJsonError, isJsonMode } from './utils/output.js';
|
|
7
8
|
import { registerConfigCommand } from './commands/config.js';
|
|
8
9
|
import { registerDevicesCommand } from './commands/devices.js';
|
|
9
10
|
import { registerScenesCommand } from './commands/scenes.js';
|
|
@@ -28,6 +29,11 @@ if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) {
|
|
|
28
29
|
chalk.level = 0;
|
|
29
30
|
}
|
|
30
31
|
const program = new Command();
|
|
32
|
+
if (isJsonMode()) {
|
|
33
|
+
// In --json mode, commander writes plain-text usage errors by default.
|
|
34
|
+
// Silence that channel and emit a single structured error in the catch block.
|
|
35
|
+
program.configureOutput({ writeErr: () => { } });
|
|
36
|
+
}
|
|
31
37
|
// Top-level subcommand names. Used by stringArg to produce clearer errors when
|
|
32
38
|
// a value is omitted and the next argv token turns out to be a subcommand name.
|
|
33
39
|
const TOP_LEVEL_COMMANDS = [
|
|
@@ -130,10 +136,7 @@ Docs: https://github.com/OpenWonderLabs/SwitchBotAPI
|
|
|
130
136
|
// per-command: subcommand errors won't bubble to the root override, so walk
|
|
131
137
|
// every registered command and apply the same handler.
|
|
132
138
|
const usageExitHandler = (err) => {
|
|
133
|
-
|
|
134
|
-
process.exit(0);
|
|
135
|
-
}
|
|
136
|
-
process.exit(2);
|
|
139
|
+
throw err;
|
|
137
140
|
};
|
|
138
141
|
function applyExitOverride(cmd) {
|
|
139
142
|
cmd.exitOverride(usageExitHandler);
|
|
@@ -158,6 +161,9 @@ catch (err) {
|
|
|
158
161
|
if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
|
|
159
162
|
process.exit(0);
|
|
160
163
|
}
|
|
164
|
+
if (isJsonMode()) {
|
|
165
|
+
emitJsonError({ code: 2, kind: 'usage', message: err.message });
|
|
166
|
+
}
|
|
161
167
|
process.exit(2);
|
|
162
168
|
}
|
|
163
169
|
throw err;
|
package/dist/lib/devices.js
CHANGED
|
@@ -151,8 +151,12 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
|
|
|
151
151
|
if (!match || Array.isArray(match))
|
|
152
152
|
return { ok: true };
|
|
153
153
|
const builtinCommands = match.commands.filter((c) => c.commandType !== 'customize');
|
|
154
|
-
if (builtinCommands.length === 0)
|
|
155
|
-
return {
|
|
154
|
+
if (match.readOnly || builtinCommands.length === 0) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: new CommandValidationError(`${cached.name} (${cached.type}) is a read-only sensor — it has no control commands.`, 'read-only-device', `Use 'switchbot devices status ${deviceId}' to read this device instead.`),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
156
160
|
let spec = builtinCommands.find((c) => c.command === cmd);
|
|
157
161
|
let caseNormalizedFrom;
|
|
158
162
|
let normalizedCmd = cmd;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { UsageError } from '../utils/output.js';
|
|
2
|
+
export const FIELD_ALIASES = {
|
|
3
|
+
deviceId: ['id'],
|
|
4
|
+
deviceName: ['name'],
|
|
5
|
+
deviceType: ['type'],
|
|
6
|
+
controlType: ['control'],
|
|
7
|
+
roomName: ['room'],
|
|
8
|
+
roomID: ['roomid'],
|
|
9
|
+
familyName: ['family'],
|
|
10
|
+
hubDeviceId: ['hub'],
|
|
11
|
+
enableCloudService: ['cloud'],
|
|
12
|
+
alias: ['alias'],
|
|
13
|
+
};
|
|
14
|
+
export function resolveField(input, allowedCanonical) {
|
|
15
|
+
const normalized = input.trim().toLowerCase();
|
|
16
|
+
if (!normalized) {
|
|
17
|
+
throw new UsageError('Field name cannot be empty.');
|
|
18
|
+
}
|
|
19
|
+
for (const canonical of allowedCanonical) {
|
|
20
|
+
if (canonical.toLowerCase() === normalized)
|
|
21
|
+
return canonical;
|
|
22
|
+
const aliases = FIELD_ALIASES[canonical] ?? [];
|
|
23
|
+
if (aliases.some((a) => a.toLowerCase() === normalized))
|
|
24
|
+
return canonical;
|
|
25
|
+
}
|
|
26
|
+
throw new UsageError(`Unknown field "${input}". Supported: ${listSupportedFieldInputs(allowedCanonical).join(', ')}`);
|
|
27
|
+
}
|
|
28
|
+
export function listSupportedFieldInputs(allowedCanonical) {
|
|
29
|
+
const out = new Set();
|
|
30
|
+
for (const canonical of allowedCanonical) {
|
|
31
|
+
out.add(canonical);
|
|
32
|
+
for (const alias of FIELD_ALIASES[canonical] ?? [])
|
|
33
|
+
out.add(alias);
|
|
34
|
+
}
|
|
35
|
+
return [...out];
|
|
36
|
+
}
|
package/dist/utils/filter.js
CHANGED
|
@@ -20,7 +20,7 @@ export class FilterSyntaxError extends Error {
|
|
|
20
20
|
* {type,name,category,room}; `devices batch` uses {type,family,room,category};
|
|
21
21
|
* `events tail` uses {deviceId,type}.
|
|
22
22
|
*/
|
|
23
|
-
export function parseFilterExpr(expr, allowedKeys) {
|
|
23
|
+
export function parseFilterExpr(expr, allowedKeys, options) {
|
|
24
24
|
if (!expr)
|
|
25
25
|
return [];
|
|
26
26
|
const parts = expr.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
|
|
@@ -72,10 +72,23 @@ export function parseFilterExpr(expr, allowedKeys) {
|
|
|
72
72
|
if (!raw) {
|
|
73
73
|
throw new FilterSyntaxError(`Empty value for filter clause "${part}"`);
|
|
74
74
|
}
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
let resolvedKey = key;
|
|
76
|
+
if (options?.resolveKey) {
|
|
77
|
+
try {
|
|
78
|
+
resolvedKey = options.resolveKey(key);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if (err instanceof Error) {
|
|
82
|
+
throw new FilterSyntaxError(err.message);
|
|
83
|
+
}
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!allowedKeys.includes(resolvedKey)) {
|
|
88
|
+
const printableKeys = options?.supportedKeys ?? allowedKeys;
|
|
89
|
+
throw new FilterSyntaxError(`Unknown filter key "${key}" – supported: ${printableKeys.join(', ')}`);
|
|
77
90
|
}
|
|
78
|
-
clauses.push({ key, op, raw, regex });
|
|
91
|
+
clauses.push({ key: resolvedKey, op, raw, regex });
|
|
79
92
|
}
|
|
80
93
|
return clauses;
|
|
81
94
|
}
|
package/dist/utils/output.js
CHANGED
|
@@ -28,6 +28,26 @@ export function emitJsonError(errorPayload) {
|
|
|
28
28
|
console.error(chalk.red(msg));
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
export function exitWithError(messageOrOpts) {
|
|
32
|
+
const opts = typeof messageOrOpts === 'string' ? { message: messageOrOpts } : messageOrOpts;
|
|
33
|
+
const { message, kind = 'usage', code = 2, hint, context, extra } = opts;
|
|
34
|
+
if (isJsonMode()) {
|
|
35
|
+
const payload = { code, kind, message };
|
|
36
|
+
if (hint)
|
|
37
|
+
payload.hint = hint;
|
|
38
|
+
if (context)
|
|
39
|
+
payload.context = context;
|
|
40
|
+
if (extra)
|
|
41
|
+
Object.assign(payload, extra);
|
|
42
|
+
emitJsonError(payload);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
console.error(message);
|
|
46
|
+
if (hint)
|
|
47
|
+
console.error(hint);
|
|
48
|
+
}
|
|
49
|
+
process.exit(code);
|
|
50
|
+
}
|
|
31
51
|
function escapeMarkdownCell(s) {
|
|
32
52
|
// Pipes break markdown table layout; backslash-escape them. Collapse
|
|
33
53
|
// newlines into <br> so each row stays on one line.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchbot/openapi-cli",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.4",
|
|
4
4
|
"description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"switchbot",
|