@switchbot/openapi-cli 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -7
- package/dist/api/client.js +35 -3
- package/dist/commands/agent-bootstrap.js +3 -0
- package/dist/commands/batch.js +64 -24
- package/dist/commands/cache.js +17 -1
- package/dist/commands/capabilities.js +36 -0
- package/dist/commands/catalog.js +60 -2
- package/dist/commands/config.js +6 -6
- package/dist/commands/device-meta.js +18 -1
- package/dist/commands/devices.js +148 -68
- package/dist/commands/events.js +63 -32
- package/dist/commands/expand.js +7 -5
- package/dist/commands/history.js +4 -7
- package/dist/commands/mcp.js +54 -8
- package/dist/commands/plan.js +6 -1
- package/dist/commands/scenes.js +9 -0
- package/dist/commands/watch.js +7 -0
- package/dist/devices/cache.js +61 -26
- package/dist/devices/param-validator.js +170 -0
- package/dist/utils/arg-parsers.js +2 -1
- package/dist/utils/filter.js +120 -39
- package/dist/utils/flags.js +27 -1
- package/dist/utils/format.js +2 -2
- package/dist/utils/name-resolver.js +6 -3
- package/dist/utils/output.js +64 -4
- package/package.json +1 -1
package/dist/commands/config.js
CHANGED
|
@@ -4,7 +4,7 @@ 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
6
|
import { saveConfig, showConfig, listProfiles, readProfileMeta } from '../config.js';
|
|
7
|
-
import { isJsonMode, printJson } from '../utils/output.js';
|
|
7
|
+
import { isJsonMode, printJson, emitJsonError } from '../utils/output.js';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
function parseEnvFile(file) {
|
|
10
10
|
const out = {};
|
|
@@ -147,7 +147,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
147
147
|
if (!fs.existsSync(options.fromEnvFile)) {
|
|
148
148
|
const msg = `--from-env-file: file not found: ${options.fromEnvFile}`;
|
|
149
149
|
if (isJsonMode()) {
|
|
150
|
-
|
|
150
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
151
151
|
}
|
|
152
152
|
else {
|
|
153
153
|
console.error(msg);
|
|
@@ -162,7 +162,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
162
162
|
if (!options.opSecret) {
|
|
163
163
|
const msg = '--from-op requires --op-secret <ref> for the secret reference.';
|
|
164
164
|
if (isJsonMode()) {
|
|
165
|
-
|
|
165
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
166
166
|
}
|
|
167
167
|
else {
|
|
168
168
|
console.error(msg);
|
|
@@ -176,7 +176,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
176
176
|
catch (err) {
|
|
177
177
|
const msg = `1Password CLI read failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
178
178
|
if (isJsonMode()) {
|
|
179
|
-
|
|
179
|
+
emitJsonError({ code: 1, kind: 'runtime', message: msg, hint: 'Ensure the "op" CLI is installed and authenticated (op signin).' });
|
|
180
180
|
}
|
|
181
181
|
else {
|
|
182
182
|
console.error(msg);
|
|
@@ -189,7 +189,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
189
189
|
if ((!token || !secret) && !options.fromEnvFile && !options.fromOp && process.stdin.isTTY) {
|
|
190
190
|
if (isJsonMode()) {
|
|
191
191
|
const msg = 'Interactive mode cannot run under --json. Provide token/secret via --from-env-file, --from-op, or positional args.';
|
|
192
|
-
|
|
192
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
193
193
|
process.exit(2);
|
|
194
194
|
}
|
|
195
195
|
try {
|
|
@@ -206,7 +206,7 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
|
|
|
206
206
|
if (!token || !secret) {
|
|
207
207
|
const msg = 'Missing token/secret. Run interactively, or use --from-env-file / --from-op, or pass positional arguments (discouraged).';
|
|
208
208
|
if (isJsonMode()) {
|
|
209
|
-
|
|
209
|
+
emitJsonError({ code: 2, kind: 'usage', message: msg });
|
|
210
210
|
}
|
|
211
211
|
else {
|
|
212
212
|
console.error(msg);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { stringArg } from '../utils/arg-parsers.js';
|
|
2
2
|
import { handleError, isJsonMode, printJson, printTable, UsageError } from '../utils/output.js';
|
|
3
|
-
import { loadDeviceMeta, setDeviceMeta, clearDeviceMeta, getDeviceMeta, getMetaFilePath, } from '../devices/device-meta.js';
|
|
3
|
+
import { loadDeviceMeta, saveDeviceMeta, setDeviceMeta, clearDeviceMeta, getDeviceMeta, getMetaFilePath, } from '../devices/device-meta.js';
|
|
4
4
|
export function registerDevicesMetaCommand(devices) {
|
|
5
5
|
const meta = devices
|
|
6
6
|
.command('meta')
|
|
@@ -14,6 +14,7 @@ export function registerDevicesMetaCommand(devices) {
|
|
|
14
14
|
.option('--hide', 'Hide this device from "devices list"')
|
|
15
15
|
.option('--show', 'Un-hide this device')
|
|
16
16
|
.option('--notes <text>', 'Freeform notes shown in "devices describe"', stringArg('--notes'))
|
|
17
|
+
.option('--force', 'Reassign alias even if it already belongs to another device')
|
|
17
18
|
.action((deviceId, options) => {
|
|
18
19
|
try {
|
|
19
20
|
if (options.hide && options.show) {
|
|
@@ -22,6 +23,22 @@ export function registerDevicesMetaCommand(devices) {
|
|
|
22
23
|
if (!options.alias && !options.hide && !options.show && !options.notes) {
|
|
23
24
|
throw new UsageError('Specify at least one of: --alias, --hide, --show, --notes');
|
|
24
25
|
}
|
|
26
|
+
// Enforce alias uniqueness across devices
|
|
27
|
+
if (options.alias !== undefined) {
|
|
28
|
+
const meta = loadDeviceMeta();
|
|
29
|
+
const holder = Object.entries(meta.devices).find(([id, m]) => m.alias === options.alias && id !== deviceId);
|
|
30
|
+
if (holder) {
|
|
31
|
+
if (!options.force) {
|
|
32
|
+
throw new UsageError(`Alias "${options.alias}" is already assigned to device ${holder[0]}. Use --force to reassign.`);
|
|
33
|
+
}
|
|
34
|
+
// --force: clear the alias from the previous holder
|
|
35
|
+
meta.devices[holder[0]] = { ...meta.devices[holder[0]], alias: undefined };
|
|
36
|
+
saveDeviceMeta(meta);
|
|
37
|
+
if (!isJsonMode()) {
|
|
38
|
+
console.log(`(reassigned alias from ${holder[0]})`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
25
42
|
const patch = {};
|
|
26
43
|
if (options.alias !== undefined)
|
|
27
44
|
patch.alias = options.alias;
|
package/dist/commands/devices.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { enumArg, stringArg } from '../utils/arg-parsers.js';
|
|
2
|
-
import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
|
|
2
|
+
import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, emitJsonError } from '../utils/output.js';
|
|
3
3
|
import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
|
|
4
4
|
import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js';
|
|
5
|
-
import { getCachedDevice } from '../devices/cache.js';
|
|
5
|
+
import { getCachedDevice, loadCache } from '../devices/cache.js';
|
|
6
6
|
import { loadDeviceMeta } from '../devices/device-meta.js';
|
|
7
|
-
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
7
|
+
import { resolveDeviceId, ALL_STRATEGIES } from '../utils/name-resolver.js';
|
|
8
8
|
import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, buildHubLocationMap, DeviceNotFoundError, } from '../lib/devices.js';
|
|
9
|
+
import { parseFilterExpr, matchClause, FilterSyntaxError } from '../utils/filter.js';
|
|
9
10
|
import { validateParameter } from '../devices/param-validator.js';
|
|
10
11
|
import { registerBatchCommand } from './batch.js';
|
|
11
12
|
import { registerWatchCommand } from './watch.js';
|
|
@@ -13,6 +14,7 @@ import { registerExplainCommand } from './explain.js';
|
|
|
13
14
|
import { registerExpandCommand } from './expand.js';
|
|
14
15
|
import { registerDevicesMetaCommand } from './device-meta.js';
|
|
15
16
|
import { isDryRun } from '../utils/flags.js';
|
|
17
|
+
import { DryRunSignal } from '../api/client.js';
|
|
16
18
|
export function registerDevicesCommand(program) {
|
|
17
19
|
const COMMAND_TYPES = ['command', 'customize'];
|
|
18
20
|
const devices = program
|
|
@@ -73,10 +75,12 @@ Examples:
|
|
|
73
75
|
$ switchbot devices list --filter type="Air Conditioner"
|
|
74
76
|
$ switchbot devices list --filter category=ir
|
|
75
77
|
$ switchbot devices list --filter name=living,category=physical
|
|
78
|
+
$ switchbot devices list --filter 'name~living' # explicit substring
|
|
79
|
+
$ switchbot devices list --filter 'type=/Air.*/' # regex (case-insensitive)
|
|
76
80
|
`)
|
|
77
81
|
.option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
|
|
78
82
|
.option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
|
|
79
|
-
.option('--filter <expr>', 'Filter devices: "
|
|
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: type, name, category, room.', stringArg('--filter'))
|
|
80
84
|
.action(async (options) => {
|
|
81
85
|
try {
|
|
82
86
|
const body = await fetchDeviceList();
|
|
@@ -84,36 +88,32 @@ Examples:
|
|
|
84
88
|
const fmt = resolveFormat();
|
|
85
89
|
const deviceMeta = loadDeviceMeta();
|
|
86
90
|
const hubLocation = buildHubLocationMap(deviceList);
|
|
87
|
-
|
|
91
|
+
// Parse --filter into a list of clauses. Shared grammar across
|
|
92
|
+
// `devices list`, `devices batch`, and `events tail` / `mqtt-tail`.
|
|
93
|
+
const LIST_KEYS = ['type', 'name', 'category', 'room'];
|
|
94
|
+
let listClauses = null;
|
|
88
95
|
if (options.filter) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (!['type', 'name', 'category', 'room'].includes(k)) {
|
|
97
|
-
throw new UsageError(`Unknown --filter key "${k}". Supported: type, name, category, room.`);
|
|
98
|
-
}
|
|
99
|
-
listFilter[k] = v.toLowerCase();
|
|
96
|
+
try {
|
|
97
|
+
listClauses = parseFilterExpr(options.filter, LIST_KEYS);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err instanceof FilterSyntaxError)
|
|
101
|
+
throw new UsageError(err.message);
|
|
102
|
+
throw err;
|
|
100
103
|
}
|
|
101
104
|
}
|
|
102
105
|
const matchesFilter = (entry) => {
|
|
103
|
-
if (!
|
|
106
|
+
if (!listClauses || listClauses.length === 0)
|
|
104
107
|
return true;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return false;
|
|
111
|
-
if (listFilter.room && !entry.room.toLowerCase().includes(listFilter.room))
|
|
112
|
-
return false;
|
|
108
|
+
for (const c of listClauses) {
|
|
109
|
+
const fieldVal = entry[c.key] ?? '';
|
|
110
|
+
if (!matchClause(fieldVal, c))
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
113
|
return true;
|
|
114
114
|
};
|
|
115
115
|
if (fmt === 'json' && process.argv.includes('--json')) {
|
|
116
|
-
if (
|
|
116
|
+
if (listClauses) {
|
|
117
117
|
const filteredDeviceList = deviceList.filter((d) => matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }));
|
|
118
118
|
const filteredIrList = infraredRemoteList.filter((d) => {
|
|
119
119
|
const inherited = hubLocation.get(d.hubDeviceId);
|
|
@@ -171,19 +171,19 @@ Examples:
|
|
|
171
171
|
]);
|
|
172
172
|
}
|
|
173
173
|
if (rows.length === 0 && fmt === 'table') {
|
|
174
|
-
console.log(
|
|
174
|
+
console.log(listClauses ? 'No devices matched the filter.' : 'No devices found');
|
|
175
175
|
return;
|
|
176
176
|
}
|
|
177
177
|
const defaultFields = options.wide ? undefined : narrowHeaders;
|
|
178
178
|
// Accept API field names and short aliases alongside canonical column names
|
|
179
179
|
const DEVICE_LIST_ALIASES = {
|
|
180
|
-
name: 'deviceName', deviceType: 'type', type: 'type',
|
|
180
|
+
id: 'deviceId', name: 'deviceName', deviceType: 'type', type: 'type',
|
|
181
181
|
roomName: 'room', familyName: 'family',
|
|
182
182
|
hubDeviceId: 'hub', enableCloudService: 'cloud',
|
|
183
183
|
};
|
|
184
184
|
renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_LIST_ALIASES);
|
|
185
185
|
if (fmt === 'table') {
|
|
186
|
-
const totalLabel =
|
|
186
|
+
const totalLabel = listClauses
|
|
187
187
|
? `${rows.length} match(es) (${deviceList.length} physical + ${infraredRemoteList.length} IR before filter)`
|
|
188
188
|
: `${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`;
|
|
189
189
|
console.log(`\nTotal: ${totalLabel}`);
|
|
@@ -200,7 +200,7 @@ Examples:
|
|
|
200
200
|
.description('Query the real-time status of a specific device')
|
|
201
201
|
.argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)')
|
|
202
202
|
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
|
|
203
|
-
.option('--name-strategy <s>',
|
|
203
|
+
.option('--name-strategy <s>', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default: fuzzy)`, stringArg('--name-strategy'))
|
|
204
204
|
.option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
|
|
205
205
|
.option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
|
|
206
206
|
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
|
|
@@ -299,14 +299,17 @@ Examples:
|
|
|
299
299
|
.description('Send a control command to a device')
|
|
300
300
|
.argument('[deviceId]', 'Target device ID (or use --name)')
|
|
301
301
|
.argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
|
|
302
|
-
.argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).')
|
|
302
|
+
.argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below). Negative numbers like -1 are accepted as-is (use `--` before them only if Commander mis-parses in your shell).')
|
|
303
|
+
.allowUnknownOption()
|
|
303
304
|
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
|
|
304
|
-
.option('--name-strategy <s>',
|
|
305
|
+
.option('--name-strategy <s>', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default for command: require-unique)`, stringArg('--name-strategy'))
|
|
305
306
|
.option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
|
|
306
307
|
.option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
|
|
307
308
|
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
|
|
308
309
|
.option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
|
|
309
310
|
.option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
|
|
311
|
+
.option('--allow-unknown-device', 'Allow targeting a deviceId that is not in the local cache. By default unknown IDs exit 2 so --dry-run is a reliable pre-flight gate; use this flag for scripted pass-through.')
|
|
312
|
+
.option('--skip-param-validation', 'Skip client-side parameter validation (escape hatch — prefer fixing the argument over using this).')
|
|
310
313
|
.option('--idempotency-key <key>', 'Client-supplied key to dedupe retries. process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key'))
|
|
311
314
|
.addHelpText('after', `
|
|
312
315
|
────────────────────────────────────────────────────────────────────────
|
|
@@ -354,6 +357,10 @@ Examples:
|
|
|
354
357
|
$ switchbot devices command <lockId> unlock --yes
|
|
355
358
|
`)
|
|
356
359
|
.action(async (deviceIdArg, cmdArg, parameter, options) => {
|
|
360
|
+
// Declared outside try so the DryRunSignal catch branch can reference them.
|
|
361
|
+
let _deviceId;
|
|
362
|
+
let _cmd;
|
|
363
|
+
let _parsedParam;
|
|
357
364
|
try {
|
|
358
365
|
// BUG-FIX: When --name is provided, Commander fills positionals left-to-right
|
|
359
366
|
// starting at [deviceId]. Shift them back to their semantic slots.
|
|
@@ -387,8 +394,28 @@ Examples:
|
|
|
387
394
|
category: options.nameCategory,
|
|
388
395
|
room: options.nameRoom,
|
|
389
396
|
});
|
|
397
|
+
_deviceId = deviceId;
|
|
390
398
|
if (!getCachedDevice(deviceId)) {
|
|
391
|
-
|
|
399
|
+
if (options.allowUnknownDevice) {
|
|
400
|
+
console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation. (--allow-unknown-device is set, continuing.)`);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
const cache = loadCache();
|
|
404
|
+
const allIds = cache ? Object.keys(cache.devices) : [];
|
|
405
|
+
const candidates = allIds
|
|
406
|
+
.filter((id) => id.toLowerCase().includes(deviceId.toLowerCase()) || id.startsWith(deviceId.slice(0, 4)))
|
|
407
|
+
.slice(0, 5)
|
|
408
|
+
.map((id) => {
|
|
409
|
+
const dev = cache.devices[id];
|
|
410
|
+
return { deviceId: id, name: dev.name, type: dev.type };
|
|
411
|
+
});
|
|
412
|
+
throw new StructuredUsageError(`Unknown deviceId "${deviceId}" — not in local cache. Run 'switchbot devices list' first, or pass --allow-unknown-device to bypass this check.`, {
|
|
413
|
+
error: 'unknown_device_id',
|
|
414
|
+
deviceId,
|
|
415
|
+
candidates,
|
|
416
|
+
hint: `Pass --allow-unknown-device to skip this check (and rely on the API for validation).`,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
392
419
|
}
|
|
393
420
|
const validation = validateCommand(deviceId, cmd, parameter, options.type);
|
|
394
421
|
if (!validation.ok) {
|
|
@@ -398,7 +425,7 @@ Examples:
|
|
|
398
425
|
if (err.hint)
|
|
399
426
|
obj.hint = err.hint;
|
|
400
427
|
obj.context = { validationKind: err.kind };
|
|
401
|
-
|
|
428
|
+
emitJsonError(obj);
|
|
402
429
|
}
|
|
403
430
|
else {
|
|
404
431
|
console.error(`Error: ${err.message}`);
|
|
@@ -424,18 +451,16 @@ Examples:
|
|
|
424
451
|
}
|
|
425
452
|
// Raw-parameter validation (runs for known (deviceType, command) pairs only).
|
|
426
453
|
const cachedForParam = getCachedDevice(deviceId);
|
|
427
|
-
if (cachedForParam && options.type === 'command') {
|
|
454
|
+
if (cachedForParam && options.type === 'command' && !options.skipParamValidation) {
|
|
428
455
|
const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
|
|
429
456
|
if (!paramCheck.ok) {
|
|
430
457
|
if (isJsonMode()) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
},
|
|
438
|
-
}));
|
|
458
|
+
emitJsonError({
|
|
459
|
+
code: 2,
|
|
460
|
+
kind: 'usage',
|
|
461
|
+
message: paramCheck.error,
|
|
462
|
+
context: { command: cmd, deviceType: cachedForParam.type, deviceId, humanHint: paramCheck.error },
|
|
463
|
+
});
|
|
439
464
|
}
|
|
440
465
|
else {
|
|
441
466
|
console.error(`Error: ${paramCheck.error}`);
|
|
@@ -452,17 +477,15 @@ Examples:
|
|
|
452
477
|
const typeLabel = cachedForGuard?.type ?? 'unknown';
|
|
453
478
|
const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
|
|
454
479
|
if (isJsonMode()) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
},
|
|
465
|
-
}));
|
|
480
|
+
emitJsonError({
|
|
481
|
+
code: 2,
|
|
482
|
+
kind: 'guard',
|
|
483
|
+
message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`,
|
|
484
|
+
hint: reason
|
|
485
|
+
? `Re-run with --yes to confirm. Reason: ${reason}`
|
|
486
|
+
: 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
|
|
487
|
+
context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
|
|
488
|
+
});
|
|
466
489
|
}
|
|
467
490
|
else {
|
|
468
491
|
console.error(`Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`);
|
|
@@ -486,6 +509,9 @@ Examples:
|
|
|
486
509
|
// keep as string
|
|
487
510
|
}
|
|
488
511
|
}
|
|
512
|
+
// Capture for DryRunSignal catch branch (which runs after executeCommand throws).
|
|
513
|
+
_cmd = cmd;
|
|
514
|
+
_parsedParam = parsedParam;
|
|
489
515
|
const body = await executeCommand(deviceId, cmd, parsedParam, options.type, undefined, { idempotencyKey: options.idempotencyKey });
|
|
490
516
|
const isIr = getCachedDevice(deviceId)?.category === 'ir';
|
|
491
517
|
const verification = isIr
|
|
@@ -523,6 +549,17 @@ Examples:
|
|
|
523
549
|
// Error('__exit__')) so they aren't double-handled and the exit code is preserved.
|
|
524
550
|
if (error instanceof Error && error.message === '__exit__')
|
|
525
551
|
throw error;
|
|
552
|
+
if (error instanceof DryRunSignal) {
|
|
553
|
+
const commandType = (options.type ?? 'command');
|
|
554
|
+
const wouldSend = { deviceId: _deviceId, command: _cmd, parameter: _parsedParam, commandType };
|
|
555
|
+
if (isJsonMode()) {
|
|
556
|
+
printJson({ dryRun: true, wouldSend });
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
console.log(`[dry-run] Would POST devices/${_deviceId}/commands with ${JSON.stringify({ command: _cmd, parameter: _parsedParam, commandType })}`);
|
|
560
|
+
}
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
526
563
|
handleError(error);
|
|
527
564
|
}
|
|
528
565
|
});
|
|
@@ -584,21 +621,52 @@ Examples:
|
|
|
584
621
|
$ switchbot devices commands Robot --json
|
|
585
622
|
`)
|
|
586
623
|
.action((typeParts) => {
|
|
587
|
-
const type = typeParts.join(' ');
|
|
588
624
|
try {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
625
|
+
// First try the joined form so legacy multi-word unquoted input still
|
|
626
|
+
// works (`devices commands Air Conditioner` → "Air Conditioner"). If
|
|
627
|
+
// that doesn't match and every individual token resolves on its own,
|
|
628
|
+
// treat it as variadic and emit a section per type.
|
|
629
|
+
const joined = typeParts.join(' ');
|
|
630
|
+
const joinedMatch = findCatalogEntry(joined);
|
|
631
|
+
if (joinedMatch && !Array.isArray(joinedMatch)) {
|
|
632
|
+
if (isJsonMode()) {
|
|
633
|
+
printJson(joinedMatch);
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
renderCatalogEntry(joinedMatch);
|
|
637
|
+
}
|
|
638
|
+
return;
|
|
592
639
|
}
|
|
593
|
-
if (
|
|
594
|
-
const
|
|
595
|
-
|
|
640
|
+
if (typeParts.length > 1) {
|
|
641
|
+
const individualMatches = [];
|
|
642
|
+
for (const t of typeParts) {
|
|
643
|
+
const m = findCatalogEntry(t);
|
|
644
|
+
if (!m || Array.isArray(m)) {
|
|
645
|
+
individualMatches.length = 0;
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
individualMatches.push(m);
|
|
649
|
+
}
|
|
650
|
+
if (individualMatches.length === typeParts.length) {
|
|
651
|
+
if (isJsonMode()) {
|
|
652
|
+
printJson(individualMatches);
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
individualMatches.forEach((entry, i) => {
|
|
656
|
+
if (i > 0)
|
|
657
|
+
console.log('');
|
|
658
|
+
renderCatalogEntry(entry);
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
596
663
|
}
|
|
597
|
-
if (
|
|
598
|
-
|
|
599
|
-
return;
|
|
664
|
+
if (!joinedMatch) {
|
|
665
|
+
throw new UsageError(`No device type matches "${joined}". Try 'switchbot devices types' to see the full list.`);
|
|
600
666
|
}
|
|
601
|
-
|
|
667
|
+
// joinedMatch is an ambiguous-match array here
|
|
668
|
+
const types = joinedMatch.map((m) => m.type).join(', ');
|
|
669
|
+
throw new UsageError(`"${joined}" matches multiple types: ${types}. Be more specific.`);
|
|
602
670
|
}
|
|
603
671
|
catch (error) {
|
|
604
672
|
handleError(error);
|
|
@@ -610,7 +678,7 @@ Examples:
|
|
|
610
678
|
.description('Describe a device by ID: metadata + supported commands + status fields (1 API call)')
|
|
611
679
|
.argument('[deviceId]', 'Target device ID (or use --name)')
|
|
612
680
|
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
|
|
613
|
-
.option('--name-strategy <s>',
|
|
681
|
+
.option('--name-strategy <s>', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default: fuzzy)`, stringArg('--name-strategy'))
|
|
614
682
|
.option('--name-type <type>', 'Narrow --name by device type', stringArg('--name-type'))
|
|
615
683
|
.option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir']))
|
|
616
684
|
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
|
|
@@ -716,8 +784,20 @@ Examples:
|
|
|
716
784
|
}
|
|
717
785
|
catch (error) {
|
|
718
786
|
if (error instanceof DeviceNotFoundError) {
|
|
719
|
-
|
|
720
|
-
|
|
787
|
+
const message = `${error.message} Try 'switchbot devices list' to see the full list.`;
|
|
788
|
+
if (isJsonMode()) {
|
|
789
|
+
emitJsonError({
|
|
790
|
+
code: 1,
|
|
791
|
+
kind: 'runtime',
|
|
792
|
+
message,
|
|
793
|
+
errorClass: 'runtime',
|
|
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
|
+
}
|
|
721
801
|
process.exit(1);
|
|
722
802
|
}
|
|
723
803
|
handleError(error);
|