@switchbot/openapi-cli 2.6.2 → 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/README.md +6 -3
- 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 -38
- 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/README.md
CHANGED
|
@@ -184,7 +184,10 @@ switchbot devices command --help
|
|
|
184
184
|
|
|
185
185
|
Intercepts every non-GET request: the CLI prints the URL/body it would have
|
|
186
186
|
sent, then exits `0` without contacting the API. `GET` requests (list, status,
|
|
187
|
-
query) are still executed so you can preview the state involved.
|
|
187
|
+
query) are still executed so you can preview the state involved. Dry-run also
|
|
188
|
+
validates command names against the device catalog and rejects unknown commands
|
|
189
|
+
(exit 2) when the device type has a known catalog entry. Commands sent to
|
|
190
|
+
read-only sensors (e.g. Meter) are likewise rejected.
|
|
188
191
|
|
|
189
192
|
```bash
|
|
190
193
|
switchbot devices command ABC123 turnOn --dry-run
|
|
@@ -305,7 +308,7 @@ Generic parameter shapes (which one applies is decided by the device — see the
|
|
|
305
308
|
|
|
306
309
|
Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), `setMode` (Relay Switch), `setBrightness` (dimmable lights), and `setColor` (Color Bulb / Strip Light / Ceiling Light) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. `setColor` accepts `R:G:B`, `R,G,B`, `#RRGGBB`, `#RGB`, and CSS named colors (`red`, `blue`, …); all normalize to `R:G:B` before hitting the API. Pass `--skip-param-validation` to bypass (escape hatch — prefer fixing the argument). Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list.
|
|
307
310
|
|
|
308
|
-
Unknown deviceIds (not in the local cache) exit 2 by default so `--dry-run` is a reliable pre-flight gate. Run `switchbot devices list` first, or pass `--allow-unknown-device` for scripted pass-through.
|
|
311
|
+
Unknown deviceIds (not in the local cache) exit 2 by default so `--dry-run` is a reliable pre-flight gate. Unknown command names and commands on read-only sensors are also rejected during dry-run when the device type has a catalog entry. Run `switchbot devices list` first, or pass `--allow-unknown-device` for scripted pass-through.
|
|
309
312
|
|
|
310
313
|
Negative numeric parameters (e.g. `setBrightness -1` for a probe) are passed through to the command validator instead of being swallowed by the flag parser as an unknown option.
|
|
311
314
|
|
|
@@ -717,7 +720,7 @@ switchbot cache clear --key status
|
|
|
717
720
|
|
|
718
721
|
| Code | Meaning |
|
|
719
722
|
| ---- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
720
|
-
| `0` | Success (including `--dry-run` intercept)
|
|
723
|
+
| `0` | Success (including `--dry-run` intercept when validation passes) |
|
|
721
724
|
| `1` | Runtime error — API error, network failure, missing credentials |
|
|
722
725
|
| `2` | Usage error — bad flag, missing/invalid argument, unknown subcommand, unknown device type, invalid URL, conflicting flags |
|
|
723
726
|
|
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,30 +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
|
-
const cmdVal = validateCommand(deviceId, command, stringifiedParam, effectiveType);
|
|
309
|
-
if (!cmdVal.ok) {
|
|
310
|
-
return mcpError('usage', 2, cmdVal.error.message, {
|
|
311
|
-
hint: cmdVal.error.hint,
|
|
312
|
-
context: { validationKind: cmdVal.error.kind },
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
323
|
const wouldSend = {
|
|
316
324
|
deviceId,
|
|
317
|
-
command,
|
|
325
|
+
command: effectiveCommand,
|
|
318
326
|
parameter: effectiveParameter ?? 'default',
|
|
319
327
|
commandType: effectiveType,
|
|
320
328
|
};
|
|
@@ -339,19 +347,19 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
339
347
|
}
|
|
340
348
|
typeName = physical ? physical.deviceType : ir.remoteType;
|
|
341
349
|
}
|
|
342
|
-
if (isDestructiveCommand(typeName,
|
|
343
|
-
const reason = getDestructiveReason(typeName,
|
|
350
|
+
if (isDestructiveCommand(typeName, effectiveCommand, effectiveType) && !confirm) {
|
|
351
|
+
const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
|
|
344
352
|
const entry = typeName ? findCatalogEntry(typeName) : null;
|
|
345
353
|
const spec = entry && !Array.isArray(entry)
|
|
346
|
-
? entry.commands.find((c) => c.command ===
|
|
354
|
+
? entry.commands.find((c) => c.command === effectiveCommand)
|
|
347
355
|
: undefined;
|
|
348
356
|
const hint = reason
|
|
349
357
|
? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}`
|
|
350
358
|
: 'Re-issue the call with confirm:true to proceed.';
|
|
351
|
-
return mcpError('guard', 3, `Command "${
|
|
359
|
+
return mcpError('guard', 3, `Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`, {
|
|
352
360
|
hint,
|
|
353
361
|
context: {
|
|
354
|
-
command,
|
|
362
|
+
command: effectiveCommand,
|
|
355
363
|
deviceType: typeName,
|
|
356
364
|
description: spec?.description ?? null,
|
|
357
365
|
...(reason ? { destructiveReason: reason } : {}),
|
|
@@ -361,18 +369,24 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
361
369
|
// validateCommand covers command existence + required/unexpected-parameter.
|
|
362
370
|
// stringifiedParam was computed once at the top of the handler so dry-run
|
|
363
371
|
// and live paths share the same shape.
|
|
364
|
-
const validation = validateCommand(deviceId,
|
|
372
|
+
const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
|
|
365
373
|
if (!validation.ok) {
|
|
366
|
-
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;
|
|
367
381
|
}
|
|
368
382
|
// R-2: run B-1 client-side parameter validator (range/format checks).
|
|
369
383
|
// Customize commands (user-defined IR buttons) opt out — the catalog
|
|
370
384
|
// cannot know their expected shape.
|
|
371
385
|
if (effectiveType !== 'customize') {
|
|
372
|
-
const pv = validateParameter(typeName,
|
|
386
|
+
const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
|
|
373
387
|
if (!pv.ok) {
|
|
374
388
|
return mcpError('usage', 2, pv.error, {
|
|
375
|
-
context: { deviceType: typeName, command, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
|
|
389
|
+
context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
|
|
376
390
|
});
|
|
377
391
|
}
|
|
378
392
|
if (pv.normalized !== undefined) {
|
|
@@ -381,7 +395,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
381
395
|
}
|
|
382
396
|
let result;
|
|
383
397
|
try {
|
|
384
|
-
result = await executeCommand(deviceId,
|
|
398
|
+
result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, undefined, {
|
|
385
399
|
idempotencyKey,
|
|
386
400
|
});
|
|
387
401
|
}
|
|
@@ -398,7 +412,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
398
412
|
return apiErrorToMcpError(err);
|
|
399
413
|
}
|
|
400
414
|
const isIr = getCachedDevice(deviceId)?.category === 'ir';
|
|
401
|
-
const structured = { ok: true, command, deviceId, result };
|
|
415
|
+
const structured = { ok: true, command: effectiveCommand, deviceId, result };
|
|
402
416
|
if (isIr) {
|
|
403
417
|
structured.verification = {
|
|
404
418
|
verifiable: false,
|
|
@@ -764,19 +778,19 @@ Inspect locally:
|
|
|
764
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'))
|
|
765
779
|
.option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
|
|
766
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
|
+
`)
|
|
767
788
|
.action(async (options) => {
|
|
768
789
|
try {
|
|
769
790
|
if (options.port) {
|
|
770
791
|
const port = Number(options.port);
|
|
771
792
|
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
772
|
-
|
|
773
|
-
if (isJsonMode()) {
|
|
774
|
-
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
775
|
-
}
|
|
776
|
-
else {
|
|
777
|
-
console.error(msg);
|
|
778
|
-
}
|
|
779
|
-
process.exit(2);
|
|
793
|
+
exitWithError(`Invalid --port "${options.port}". Must be 1-65535.`);
|
|
780
794
|
}
|
|
781
795
|
const bind = options.bind ?? '127.0.0.1';
|
|
782
796
|
const authToken = options.authToken ?? process.env.SWITCHBOT_MCP_TOKEN;
|
|
@@ -785,14 +799,7 @@ Inspect locally:
|
|
|
785
799
|
// Guard: refuse to bind non-localhost without auth
|
|
786
800
|
const isLocalhost = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1';
|
|
787
801
|
if (!isLocalhost && !authToken) {
|
|
788
|
-
|
|
789
|
-
if (isJsonMode()) {
|
|
790
|
-
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
791
|
-
}
|
|
792
|
-
else {
|
|
793
|
-
console.error(msg);
|
|
794
|
-
}
|
|
795
|
-
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).');
|
|
796
803
|
}
|
|
797
804
|
const { createServer } = await import('node:http');
|
|
798
805
|
const rateLimitMap = new Map();
|
|
@@ -1011,6 +1018,29 @@ process_uptime_seconds ${Math.floor(process.uptime())}
|
|
|
1011
1018
|
const server = createSwitchBotMcpServer({ eventManager });
|
|
1012
1019
|
const transport = new StdioServerTransport();
|
|
1013
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);
|
|
1014
1044
|
}
|
|
1015
1045
|
catch (error) {
|
|
1016
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",
|