@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.
@@ -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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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
- console.error(JSON.stringify({ error: { code: 1, kind: 'runtime', message: msg, hint: 'Ensure the "op" CLI is installed and authenticated (op signin).' } }));
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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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
- console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } }));
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;
@@ -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: "type=X", "name=X", "category=physical|ir", "room=X" (comma-separated key=value pairs)', stringArg('--filter'))
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
- let listFilter = null;
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
- listFilter = {};
90
- for (const pair of options.filter.split(',')) {
91
- const eq = pair.indexOf('=');
92
- if (eq === -1)
93
- throw new UsageError(`Invalid --filter pair "${pair.trim()}". Expected key=value.`);
94
- const k = pair.slice(0, eq).trim();
95
- const v = pair.slice(eq + 1).trim();
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 (!listFilter)
106
+ if (!listClauses || listClauses.length === 0)
104
107
  return true;
105
- if (listFilter.type && !entry.type.toLowerCase().includes(listFilter.type))
106
- return false;
107
- if (listFilter.name && !entry.name.toLowerCase().includes(listFilter.name))
108
- return false;
109
- if (listFilter.category && entry.category !== listFilter.category)
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 (listFilter) {
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(listFilter ? 'No devices matched the filter.' : 'No devices found');
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 = listFilter
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>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy'))
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>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default for command: require-unique)', stringArg('--name-strategy'))
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
- console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`);
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
- console.error(JSON.stringify({ error: obj }));
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
- console.error(JSON.stringify({
432
- error: {
433
- code: 2,
434
- kind: 'usage',
435
- message: paramCheck.error,
436
- context: { command: cmd, deviceType: cachedForParam.type, deviceId },
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
- console.error(JSON.stringify({
456
- error: {
457
- code: 2,
458
- kind: 'guard',
459
- message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`,
460
- hint: reason
461
- ? `Re-run with --yes to confirm. Reason: ${reason}`
462
- : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
463
- context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
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
- const match = findCatalogEntry(type);
590
- if (!match) {
591
- throw new UsageError(`No device type matches "${type}". Try 'switchbot devices types' to see the full list.`);
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 (Array.isArray(match)) {
594
- const types = match.map((m) => m.type).join(', ');
595
- throw new UsageError(`"${type}" matches multiple types: ${types}. Be more specific.`);
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 (isJsonMode()) {
598
- printJson(match);
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
- renderCatalogEntry(match);
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>', 'Name match strategy: exact|prefix|substring|fuzzy|first|require-unique (default: fuzzy)', stringArg('--name-strategy'))
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
- console.error(error.message);
720
- console.error(`Try 'switchbot devices list' to see the full list.`);
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);