@switchbot/openapi-cli 2.1.0 → 2.2.1

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.
@@ -1,3 +1,4 @@
1
+ import { enumArg, stringArg } from '../utils/arg-parsers.js';
1
2
  import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
2
3
  import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
3
4
  import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js';
@@ -12,6 +13,7 @@ import { registerExpandCommand } from './expand.js';
12
13
  import { registerDevicesMetaCommand } from './device-meta.js';
13
14
  import { isDryRun } from '../utils/flags.js';
14
15
  export function registerDevicesCommand(program) {
16
+ const COMMAND_TYPES = ['command', 'customize'];
15
17
  const devices = program
16
18
  .command('devices')
17
19
  .description('Manage and control SwitchBot devices')
@@ -38,6 +40,7 @@ Run any subcommand with --help for its own flags and examples.
38
40
  // switchbot devices list
39
41
  devices
40
42
  .command('list')
43
+ .alias('ls')
41
44
  .description('List all physical devices and IR remote devices on the account')
42
45
  .addHelpText('after', `
43
46
  Default columns: deviceId, deviceName, type, category
@@ -66,20 +69,62 @@ Examples:
66
69
  $ switchbot devices list --format tsv --fields deviceId,deviceName,type,category
67
70
  $ switchbot devices list --json | jq '.deviceList[] | select(.familyName == "家里")'
68
71
  $ switchbot devices list --json | jq '[.deviceList[], .infraredRemoteList[]] | group_by(.familyName)'
72
+ $ switchbot devices list --filter type="Air Conditioner"
73
+ $ switchbot devices list --filter category=ir
74
+ $ switchbot devices list --filter name=living,category=physical
69
75
  `)
70
76
  .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
71
77
  .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
78
+ .option('--filter <expr>', 'Filter devices: "type=X", "name=X", "category=physical|ir", "room=X" (comma-separated key=value pairs)', stringArg('--filter'))
72
79
  .action(async (options) => {
73
80
  try {
74
81
  const body = await fetchDeviceList();
75
82
  const { deviceList, infraredRemoteList } = body;
76
83
  const fmt = resolveFormat();
77
84
  const deviceMeta = loadDeviceMeta();
85
+ const hubLocation = buildHubLocationMap(deviceList);
86
+ let listFilter = null;
87
+ if (options.filter) {
88
+ listFilter = {};
89
+ for (const pair of options.filter.split(',')) {
90
+ const eq = pair.indexOf('=');
91
+ if (eq === -1)
92
+ throw new UsageError(`Invalid --filter pair "${pair.trim()}". Expected key=value.`);
93
+ const k = pair.slice(0, eq).trim();
94
+ const v = pair.slice(eq + 1).trim();
95
+ if (!['type', 'name', 'category', 'room'].includes(k)) {
96
+ throw new UsageError(`Unknown --filter key "${k}". Supported: type, name, category, room.`);
97
+ }
98
+ listFilter[k] = v.toLowerCase();
99
+ }
100
+ }
101
+ const matchesFilter = (entry) => {
102
+ if (!listFilter)
103
+ return true;
104
+ if (listFilter.type && !entry.type.toLowerCase().includes(listFilter.type))
105
+ return false;
106
+ if (listFilter.name && !entry.name.toLowerCase().includes(listFilter.name))
107
+ return false;
108
+ if (listFilter.category && entry.category !== listFilter.category)
109
+ return false;
110
+ if (listFilter.room && !entry.room.toLowerCase().includes(listFilter.room))
111
+ return false;
112
+ return true;
113
+ };
78
114
  if (fmt === 'json' && process.argv.includes('--json')) {
79
- printJson(body);
115
+ if (listFilter) {
116
+ const filteredDeviceList = deviceList.filter((d) => matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }));
117
+ const filteredIrList = infraredRemoteList.filter((d) => {
118
+ const inherited = hubLocation.get(d.hubDeviceId);
119
+ return matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' });
120
+ });
121
+ printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList });
122
+ }
123
+ else {
124
+ printJson({ ok: true, ...body });
125
+ }
80
126
  return;
81
127
  }
82
- const hubLocation = buildHubLocationMap(deviceList);
83
128
  const narrowHeaders = ['deviceId', 'deviceName', 'type', 'category'];
84
129
  const wideHeaders = ['deviceId', 'deviceName', 'type', 'category', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud', 'alias'];
85
130
  const userFields = resolveFields();
@@ -88,6 +133,8 @@ Examples:
88
133
  for (const d of deviceList) {
89
134
  if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
90
135
  continue;
136
+ if (!matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }))
137
+ continue;
91
138
  rows.push([
92
139
  d.deviceId,
93
140
  d.deviceName,
@@ -106,6 +153,8 @@ Examples:
106
153
  if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
107
154
  continue;
108
155
  const inherited = hubLocation.get(d.hubDeviceId);
156
+ if (!matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' }))
157
+ continue;
109
158
  rows.push([
110
159
  d.deviceId,
111
160
  d.deviceName,
@@ -121,13 +170,22 @@ Examples:
121
170
  ]);
122
171
  }
123
172
  if (rows.length === 0 && fmt === 'table') {
124
- console.log('No devices found');
173
+ console.log(listFilter ? 'No devices matched the filter.' : 'No devices found');
125
174
  return;
126
175
  }
127
176
  const defaultFields = options.wide ? undefined : narrowHeaders;
128
- renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields);
177
+ // Accept API field names and short aliases alongside canonical column names
178
+ const DEVICE_LIST_ALIASES = {
179
+ name: 'deviceName', deviceType: 'type', type: 'type',
180
+ roomName: 'room', familyName: 'family',
181
+ hubDeviceId: 'hub', enableCloudService: 'cloud',
182
+ };
183
+ renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_LIST_ALIASES);
129
184
  if (fmt === 'table') {
130
- console.log(`\nTotal: ${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`);
185
+ const totalLabel = listFilter
186
+ ? `${rows.length} match(es) (${deviceList.length} physical + ${infraredRemoteList.length} IR before filter)`
187
+ : `${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`;
188
+ console.log(`\nTotal: ${totalLabel}`);
131
189
  console.log(`Tip: 'switchbot devices describe <deviceId>' shows a device's supported commands.`);
132
190
  }
133
191
  }
@@ -139,8 +197,9 @@ Examples:
139
197
  devices
140
198
  .command('status')
141
199
  .description('Query the real-time status of a specific device')
142
- .argument('[deviceId]', 'Device ID from "devices list" (or use --name)')
143
- .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
200
+ .argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)')
201
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
202
+ .option('--ids <list>', 'Comma-separated device IDs for batch status (incompatible with --name)', stringArg('--ids'))
144
203
  .addHelpText('after', `
145
204
  Status fields vary by device type. To discover them without a live call:
146
205
 
@@ -155,25 +214,70 @@ Examples:
155
214
  $ switchbot devices status ABC123DEF456 --json
156
215
  $ switchbot devices status ABC123DEF456 --format yaml
157
216
  $ switchbot devices status ABC123DEF456 --format tsv --fields power,battery
158
- $ switchbot devices status ABC123DEF456 --json | jq '.battery'
217
+ $ switchbot devices status ABC123DEF456 --json | jq '.data.battery'
218
+ $ switchbot devices status --ids ABC123,DEF456,GHI789
219
+ $ switchbot devices status --ids ABC123,DEF456 --fields power,battery
159
220
  `)
160
221
  .action(async (deviceIdArg, options) => {
161
222
  try {
223
+ // Batch mode: --ids id1,id2,id3
224
+ if (options.ids) {
225
+ if (options.name)
226
+ throw new UsageError('--ids and --name cannot be used together.');
227
+ const ids = options.ids.split(',').map((s) => s.trim()).filter(Boolean);
228
+ if (ids.length === 0)
229
+ throw new UsageError('--ids requires at least one device ID.');
230
+ const results = await Promise.allSettled(ids.map((id) => fetchDeviceStatus(id)));
231
+ const fetchedAt = new Date().toISOString();
232
+ const batch = results.map((r, i) => r.status === 'fulfilled'
233
+ ? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt, ...r.value }
234
+ : { deviceId: ids[i], ok: false, error: r.reason?.message ?? String(r.reason) });
235
+ const batchFmt = resolveFormat();
236
+ if (isJsonMode() || batchFmt === 'json') {
237
+ printJson(batch);
238
+ }
239
+ else if (batchFmt === 'jsonl') {
240
+ for (const entry of batch) {
241
+ console.log(JSON.stringify(entry));
242
+ }
243
+ }
244
+ else {
245
+ const fields = resolveFields();
246
+ for (const entry of batch) {
247
+ const { deviceId, ok, error, _fetchedAt: ts, ...status } = entry;
248
+ console.log(`\n─── ${String(deviceId)} ───`);
249
+ if (!ok) {
250
+ console.error(` error: ${String(error)}`);
251
+ }
252
+ else {
253
+ const displayStatus = fields
254
+ ? Object.fromEntries(fields.map((f) => [f, status[f] ?? null]))
255
+ : status;
256
+ printKeyValue(displayStatus);
257
+ console.error(` fetched at ${String(ts)}`);
258
+ }
259
+ }
260
+ }
261
+ return;
262
+ }
162
263
  const deviceId = resolveDeviceId(deviceIdArg, options.name);
163
264
  const body = await fetchDeviceStatus(deviceId);
265
+ const fetchedAt = new Date().toISOString();
164
266
  const fmt = resolveFormat();
165
267
  if (fmt === 'json' && process.argv.includes('--json')) {
166
- printJson(body);
268
+ printJson({ ...body, _fetchedAt: fetchedAt });
167
269
  return;
168
270
  }
169
271
  if (fmt !== 'table') {
170
- const allHeaders = Object.keys(body);
171
- const allRows = [Object.values(body)];
272
+ const statusWithTs = { ...body, _fetchedAt: fetchedAt };
273
+ const allHeaders = Object.keys(statusWithTs);
274
+ const allRows = [Object.values(statusWithTs)];
172
275
  const fields = resolveFields();
173
276
  renderRows(allHeaders, allRows, fmt, fields);
174
277
  return;
175
278
  }
176
279
  printKeyValue(body);
280
+ console.error(`\nfetched at ${fetchedAt}`);
177
281
  }
178
282
  catch (error) {
179
283
  handleError(error);
@@ -184,12 +288,12 @@ Examples:
184
288
  .command('command')
185
289
  .description('Send a control command to a device')
186
290
  .argument('[deviceId]', 'Target device ID (or use --name)')
187
- .argument('<cmd>', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
291
+ .argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
188
292
  .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).')
189
- .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
190
- .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', 'command')
293
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
294
+ .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
191
295
  .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
192
- .option('--idempotency-key <key>', 'Idempotency key for deduplication (60s window; same key replays cached result)')
296
+ .option('--idempotency-key <key>', 'Idempotency key for deduplication (60s window; same key replays cached result)', stringArg('--idempotency-key'))
193
297
  .addHelpText('after', `
194
298
  ────────────────────────────────────────────────────────────────────────
195
299
  For the full list of commands a specific device supports — and their
@@ -235,60 +339,89 @@ Examples:
235
339
  $ switchbot devices command ABC123 "MyButton" --type customize
236
340
  $ switchbot devices command <lockId> unlock --yes
237
341
  `)
238
- .action(async (deviceIdArg, cmd, parameter, options) => {
239
- const deviceId = resolveDeviceId(deviceIdArg, options.name);
240
- const validation = validateCommand(deviceId, cmd, parameter, options.type);
241
- if (!validation.ok) {
242
- const err = validation.error;
243
- if (isJsonMode()) {
244
- const obj = { code: 2, kind: 'usage', message: err.message };
245
- if (err.hint)
246
- obj.hint = err.hint;
247
- obj.context = { validationKind: err.kind };
248
- console.error(JSON.stringify({ error: obj }));
342
+ .action(async (deviceIdArg, cmdArg, parameter, options) => {
343
+ try {
344
+ // BUG-FIX: When --name is provided, Commander misassigns the first positional
345
+ // to [deviceId] instead of [cmd]. Detect and shift positionals accordingly.
346
+ let cmd;
347
+ let effectiveDeviceIdArg;
348
+ if (options.name) {
349
+ if (deviceIdArg && cmdArg) {
350
+ throw new UsageError('Provide either a deviceId argument or --name, not both.');
351
+ }
352
+ if (!deviceIdArg && !cmdArg) {
353
+ throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
354
+ }
355
+ // --name "x" turnOn → deviceIdArg="turnOn", cmdArg=undefined → shift
356
+ cmd = (deviceIdArg ?? cmdArg);
357
+ effectiveDeviceIdArg = undefined;
249
358
  }
250
359
  else {
251
- console.error(`Error: ${err.message}`);
252
- if (err.hint)
253
- console.error(err.hint);
254
- if (err.kind === 'unknown-command') {
255
- const cached = getCachedDevice(deviceId);
256
- if (cached) {
257
- console.error(`Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.`);
258
- console.error(`(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`);
360
+ if (!cmdArg) {
361
+ throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
362
+ }
363
+ cmd = cmdArg;
364
+ effectiveDeviceIdArg = deviceIdArg;
365
+ }
366
+ const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
367
+ if (!getCachedDevice(deviceId)) {
368
+ console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`);
369
+ }
370
+ const validation = validateCommand(deviceId, cmd, parameter, options.type);
371
+ if (!validation.ok) {
372
+ const err = validation.error;
373
+ if (isJsonMode()) {
374
+ const obj = { code: 2, kind: 'usage', message: err.message };
375
+ if (err.hint)
376
+ obj.hint = err.hint;
377
+ obj.context = { validationKind: err.kind };
378
+ console.error(JSON.stringify({ error: obj }));
379
+ }
380
+ else {
381
+ console.error(`Error: ${err.message}`);
382
+ if (err.hint)
383
+ console.error(err.hint);
384
+ if (err.kind === 'unknown-command') {
385
+ const cached = getCachedDevice(deviceId);
386
+ if (cached) {
387
+ console.error(`Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.`);
388
+ console.error(`(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`);
389
+ }
259
390
  }
260
391
  }
392
+ process.exit(2);
261
393
  }
262
- process.exit(2);
263
- }
264
- const cachedForGuard = getCachedDevice(deviceId);
265
- if (!options.yes &&
266
- !isDryRun() &&
267
- isDestructiveCommand(cachedForGuard?.type, cmd, options.type)) {
268
- const typeLabel = cachedForGuard?.type ?? 'unknown';
269
- const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
270
- if (isJsonMode()) {
271
- console.error(JSON.stringify({
272
- error: {
273
- code: 2,
274
- kind: 'guard',
275
- message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`,
276
- hint: reason
277
- ? `Re-run with --yes to confirm. Reason: ${reason}`
278
- : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
279
- context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
280
- },
281
- }));
394
+ const cachedForGuard = getCachedDevice(deviceId);
395
+ if (!options.yes &&
396
+ !isDryRun() &&
397
+ isDestructiveCommand(cachedForGuard?.type, cmd, options.type)) {
398
+ const typeLabel = cachedForGuard?.type ?? 'unknown';
399
+ const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
400
+ if (isJsonMode()) {
401
+ console.error(JSON.stringify({
402
+ error: {
403
+ code: 2,
404
+ kind: 'guard',
405
+ message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`,
406
+ hint: reason
407
+ ? `Re-run with --yes to confirm. Reason: ${reason}`
408
+ : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
409
+ context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
410
+ },
411
+ }));
412
+ }
413
+ else {
414
+ console.error(`Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`);
415
+ if (reason)
416
+ console.error(`Reason: ${reason}`);
417
+ console.error(`Re-run with --yes to confirm, or --dry-run to preview without sending.`);
418
+ }
419
+ process.exit(2);
282
420
  }
283
- else {
284
- console.error(`Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`);
285
- if (reason)
286
- console.error(`Reason: ${reason}`);
287
- console.error(`Re-run with --yes to confirm, or --dry-run to preview without sending.`);
421
+ // Warn when --yes is given but the command is not destructive (no-op flag)
422
+ if (options.yes && !isDestructiveCommand(cachedForGuard?.type, cmd, options.type) && !isDryRun()) {
423
+ console.error(`Note: --yes has no effect; "${cmd}" is not a destructive command.`);
288
424
  }
289
- process.exit(2);
290
- }
291
- try {
292
425
  // parameter may be a JSON object string (e.g. S10 startClean) or a plain string
293
426
  let parsedParam = parameter ?? 'default';
294
427
  if (parameter) {
@@ -311,14 +444,21 @@ Examples:
311
444
  printJson(result);
312
445
  return;
313
446
  }
314
- console.log(`✓ Command sent: ${cmd}`);
315
- if (isIr)
316
- console.log(' Note: IR command sent — no device confirmation (fire-and-forget).');
317
- if (body && typeof body === 'object' && Object.keys(body).length > 0) {
318
- printKeyValue(body);
447
+ if (isIr) {
448
+ console.log(`→ IR signal sent: ${cmd} (no feedback — fire-and-forget)`);
449
+ }
450
+ else {
451
+ console.log(`✓ Command sent: ${cmd}`);
452
+ if (body && typeof body === 'object' && Object.keys(body).length > 0) {
453
+ printKeyValue(body);
454
+ }
319
455
  }
320
456
  }
321
457
  catch (error) {
458
+ // Re-throw mock process.exit signals (Vitest intercepts process.exit as thrown
459
+ // Error('__exit__')) so they aren't double-handled and the exit code is preserved.
460
+ if (error instanceof Error && error.message === '__exit__')
461
+ throw error;
322
462
  handleError(error);
323
463
  }
324
464
  });
@@ -342,9 +482,10 @@ Examples:
342
482
  printJson(catalog);
343
483
  return;
344
484
  }
345
- const headers = ['type', 'category', 'commands', 'aliases'];
485
+ const headers = ['type', 'role', 'category', 'commands', 'aliases'];
346
486
  const rows = catalog.map((e) => [
347
487
  e.type,
488
+ e.role ?? '—',
348
489
  e.category,
349
490
  String(e.commands.length),
350
491
  (e.aliases ?? []).join(', ') || '—',
@@ -404,7 +545,7 @@ Examples:
404
545
  .command('describe')
405
546
  .description('Describe a device by ID: metadata + supported commands + status fields (1 API call)')
406
547
  .argument('[deviceId]', 'Target device ID (or use --name)')
407
- .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
548
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
408
549
  .option('--live', 'Also fetch live status values and merge them into capabilities (costs 1 extra API call)')
409
550
  .addHelpText('after', `
410
551
  Makes a GET /v1.1/devices call to look up the device's type, then prints its
@@ -535,6 +676,7 @@ function renderCatalogEntry(entry) {
535
676
  }
536
677
  else {
537
678
  console.log('\nCommands:');
679
+ const hasExamples = entry.commands.some((c) => c.exampleParams && c.exampleParams.length > 0);
538
680
  const rows = entry.commands.map((c) => {
539
681
  const flags = [];
540
682
  if (c.commandType === 'customize')
@@ -542,9 +684,13 @@ function renderCatalogEntry(entry) {
542
684
  if (c.destructive)
543
685
  flags.push('!destructive');
544
686
  const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command;
545
- return [label, c.parameter, c.description];
687
+ const base = [label, c.parameter, c.description];
688
+ return hasExamples ? [...base, (c.exampleParams ?? []).join(' | ') || ''] : base;
546
689
  });
547
- printTable(['command', 'parameter', 'description'], rows);
690
+ const tableHeaders = hasExamples
691
+ ? ['command', 'parameter', 'description', 'example']
692
+ : ['command', 'parameter', 'description'];
693
+ printTable(tableHeaders, rows);
548
694
  const hasDestructive = entry.commands.some((c) => c.destructive);
549
695
  if (hasDestructive) {
550
696
  console.log('\n[!destructive] commands have hard-to-reverse real-world effects — confirm before issuing.');