@switchbot/openapi-cli 2.1.0 → 2.2.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 CHANGED
@@ -165,7 +165,8 @@ switchbot config show
165
165
  | `--no-retry` | Disable automatic 429 retries |
166
166
  | `--backoff <strategy>` | Retry backoff: `exponential` (default) or `linear` |
167
167
  | `--no-quota` | Disable local request-quota tracking |
168
- | `--audit-log [path]` | Append mutating commands to a JSONL audit log (default path: `~/.switchbot/audit.log`) |
168
+ | `--audit-log` | Append mutating commands to a JSONL audit log (default path: `~/.switchbot/audit.log`) |
169
+ | `--audit-log-path <path>` | Custom audit log path; use together with `--audit-log` |
169
170
  | `-V`, `--version` | Print the CLI version |
170
171
  | `-h`, `--help` | Show help for any command or subcommand |
171
172
 
@@ -212,6 +213,11 @@ switchbot devices list --json | jq '.deviceList[].deviceId'
212
213
  # Physical: category = "physical"
213
214
  switchbot devices list --format=tsv --fields=deviceId,type,category
214
215
 
216
+ # Filter devices by type / name / category / room (server-side filter keys)
217
+ switchbot devices list --filter category=physical
218
+ switchbot devices list --filter type=Bot
219
+ switchbot devices list --filter name=living,category=physical
220
+
215
221
  # Filter by family / room (family & room info requires the 'src: OpenClaw'
216
222
  # header, which this CLI sends on every request)
217
223
  switchbot devices list --json | jq '.deviceList[] | select(.familyName == "Home")'
@@ -221,6 +227,16 @@ switchbot devices list --json | jq '[.deviceList[], .infraredRemoteList[]] | gro
221
227
  switchbot devices status <deviceId>
222
228
  switchbot devices status <deviceId> --json
223
229
 
230
+ # Resolve device by fuzzy name instead of ID (status, command, describe, expand, watch)
231
+ switchbot devices status --name "客厅空调"
232
+ switchbot devices command --name "Office Light" turnOn
233
+ switchbot devices describe --name "Kitchen Bot"
234
+
235
+ # Batch status across multiple devices
236
+ switchbot devices status --ids ABC,DEF,GHI
237
+ switchbot devices status --ids ABC,DEF --fields power,battery # only show specific fields
238
+ switchbot devices status --ids ABC,DEF --format jsonl # one JSON line per device
239
+
224
240
  # Send a control command
225
241
  switchbot devices command <deviceId> <cmd> [parameter] [--type command|customize]
226
242
 
@@ -229,7 +245,7 @@ switchbot devices describe <deviceId>
229
245
  switchbot devices describe <deviceId> --json
230
246
 
231
247
  # Discover what's supported (offline reference, no API call)
232
- switchbot devices types # List all device types + IR remote types
248
+ switchbot devices types # List all device types + IR remote types (incl. role column)
233
249
  switchbot devices commands <type> # Show commands, parameter formats, and status fields
234
250
  switchbot devices commands Bot
235
251
  switchbot devices commands "Smart Lock"
@@ -268,6 +284,8 @@ Some commands require a packed string like `"26,2,2,on"`. `devices expand` build
268
284
  ```bash
269
285
  # Air Conditioner — setAll
270
286
  switchbot devices expand <acId> setAll --temp 26 --mode cool --fan low --power on
287
+ # Resolve by name
288
+ switchbot devices expand --name "客厅空调" setAll --temp 26 --mode cool --fan low --power on
271
289
 
272
290
  # Curtain / Roller Shade — setPosition
273
291
  switchbot devices expand <curtainId> setPosition --position 50 --mode silent
@@ -412,6 +430,40 @@ nohup switchbot events mqtt-tail --json >> ~/switchbot-events.log 2>&1 &
412
430
 
413
431
  Run `switchbot doctor` to verify MQTT credentials are configured correctly before connecting.
414
432
 
433
+ #### `mqtt-tail` sinks — route events to external services
434
+
435
+ By default `mqtt-tail` prints JSONL to stdout. Use `--sink` (repeatable) to route events to one or more destinations instead:
436
+
437
+ | Sink | Required flags |
438
+ |---|---|
439
+ | `stdout` | (default when no `--sink` given) |
440
+ | `file` | `--sink-file <path>` — append JSONL |
441
+ | `webhook` | `--webhook-url <url>` — HTTP POST each event |
442
+ | `openclaw` | `--openclaw-url`, `--openclaw-token` (or `$OPENCLAW_TOKEN`), `--openclaw-model` |
443
+ | `telegram` | `--telegram-token` (or `$TELEGRAM_TOKEN`), `--telegram-chat <chatId>` |
444
+ | `homeassistant` | `--ha-url <url>` + `--ha-webhook-id` (no auth) or `--ha-token` (REST event API) |
445
+
446
+ ```bash
447
+ # Push events to an OpenClaw agent (replaces the SwitchBot channel plugin)
448
+ switchbot events mqtt-tail \
449
+ --sink openclaw \
450
+ --openclaw-token <token> \
451
+ --openclaw-model my-home-agent
452
+
453
+ # Write to file + push to OpenClaw simultaneously
454
+ switchbot events mqtt-tail \
455
+ --sink file --sink-file ~/.switchbot/events.jsonl \
456
+ --sink openclaw --openclaw-token <token> --openclaw-model home
457
+
458
+ # Generic webhook (n8n, Make, etc.)
459
+ switchbot events mqtt-tail --sink webhook --webhook-url https://n8n.local/hook/abc
460
+
461
+ # Forward to Home Assistant via webhook trigger
462
+ switchbot events mqtt-tail --sink homeassistant --ha-url http://homeassistant.local:8123 --ha-webhook-id switchbot
463
+ ```
464
+
465
+ Device state is also persisted to `~/.switchbot/device-history/<deviceId>.json` (latest + 100-entry ring buffer) regardless of sink configuration. This enables the `get_device_history` MCP tool to answer state queries without an API call.
466
+
415
467
  ### `completion` — shell tab-completion
416
468
 
417
469
  ```bash
@@ -498,7 +550,7 @@ switchbot history replay 7 # re-run entry #7
498
550
  switchbot --json history show --limit 50 | jq '.entries[] | select(.result=="error")'
499
551
  ```
500
552
 
501
- Reads the JSONL audit log (`~/.switchbot/audit.log` by default; override with `--audit-log`). Each entry records the timestamp, command, device ID, result, and dry-run flag. `replay` re-runs the original command with the original arguments.
553
+ Reads the JSONL audit log (`~/.switchbot/audit.log` by default; override with `--audit-log --audit-log-path <path>`). Each entry records the timestamp, command, device ID, result, and dry-run flag. `replay` re-runs the original command with the original arguments.
502
554
 
503
555
  ### `catalog` — device type catalog
504
556
 
@@ -26,32 +26,42 @@ const MCP_TOOLS = [
26
26
  'run_scene',
27
27
  'search_catalog',
28
28
  'account_overview',
29
+ 'get_device_history',
29
30
  ];
30
31
  export function registerCapabilitiesCommand(program) {
31
32
  program
32
33
  .command('capabilities')
33
34
  .description('Print a machine-readable manifest of CLI capabilities (for agent bootstrap)')
34
- .action(() => {
35
+ .option('--minimal', 'Omit per-subcommand flag details to reduce output size')
36
+ .action((opts) => {
35
37
  const catalog = getEffectiveCatalog();
36
- const commands = program.commands
37
- .filter((c) => c.name() !== 'capabilities')
38
- .map((c) => ({
39
- name: c.name(),
40
- description: c.description(),
41
- subcommands: c.commands.map((s) => ({
42
- name: s.name(),
43
- description: s.description(),
44
- args: s.registeredArguments.map((a) => ({
45
- name: a.name(),
46
- required: a.required,
47
- variadic: a.variadic,
48
- })),
49
- flags: s.options.map((o) => ({
50
- flags: o.flags,
51
- description: o.description,
52
- })),
53
- })),
54
- }));
38
+ const allCommands = [
39
+ ...program.commands,
40
+ // Commander adds 'help' implicitly; include it explicitly so it appears in the manifest
41
+ { name: () => 'help', description: () => 'Display help for a command', commands: [], options: [], registeredArguments: [] },
42
+ ];
43
+ const commands = allCommands.map((c) => {
44
+ const entry = {
45
+ name: c.name(),
46
+ description: c.description(),
47
+ };
48
+ if (!opts.minimal) {
49
+ entry.subcommands = c.commands.map((s) => ({
50
+ name: s.name(),
51
+ description: s.description(),
52
+ args: s.registeredArguments.map((a) => ({
53
+ name: a.name(),
54
+ required: a.required,
55
+ variadic: a.variadic,
56
+ })),
57
+ flags: s.options.map((o) => ({
58
+ flags: o.flags,
59
+ description: o.description,
60
+ })),
61
+ }));
62
+ }
63
+ return entry;
64
+ });
55
65
  const globalFlags = program.options.map((opt) => ({
56
66
  flags: opt.flags,
57
67
  description: opt.description,
@@ -66,20 +66,62 @@ Examples:
66
66
  $ switchbot devices list --format tsv --fields deviceId,deviceName,type,category
67
67
  $ switchbot devices list --json | jq '.deviceList[] | select(.familyName == "家里")'
68
68
  $ switchbot devices list --json | jq '[.deviceList[], .infraredRemoteList[]] | group_by(.familyName)'
69
+ $ switchbot devices list --filter type="Air Conditioner"
70
+ $ switchbot devices list --filter category=ir
71
+ $ switchbot devices list --filter name=living,category=physical
69
72
  `)
70
73
  .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
71
74
  .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
75
+ .option('--filter <expr>', 'Filter devices: "type=X", "name=X", "category=physical|ir", "room=X" (comma-separated key=value pairs)')
72
76
  .action(async (options) => {
73
77
  try {
74
78
  const body = await fetchDeviceList();
75
79
  const { deviceList, infraredRemoteList } = body;
76
80
  const fmt = resolveFormat();
77
81
  const deviceMeta = loadDeviceMeta();
82
+ const hubLocation = buildHubLocationMap(deviceList);
83
+ let listFilter = null;
84
+ if (options.filter) {
85
+ listFilter = {};
86
+ for (const pair of options.filter.split(',')) {
87
+ const eq = pair.indexOf('=');
88
+ if (eq === -1)
89
+ throw new UsageError(`Invalid --filter pair "${pair.trim()}". Expected key=value.`);
90
+ const k = pair.slice(0, eq).trim();
91
+ const v = pair.slice(eq + 1).trim();
92
+ if (!['type', 'name', 'category', 'room'].includes(k)) {
93
+ throw new UsageError(`Unknown --filter key "${k}". Supported: type, name, category, room.`);
94
+ }
95
+ listFilter[k] = v.toLowerCase();
96
+ }
97
+ }
98
+ const matchesFilter = (entry) => {
99
+ if (!listFilter)
100
+ return true;
101
+ if (listFilter.type && !entry.type.toLowerCase().includes(listFilter.type))
102
+ return false;
103
+ if (listFilter.name && !entry.name.toLowerCase().includes(listFilter.name))
104
+ return false;
105
+ if (listFilter.category && entry.category !== listFilter.category)
106
+ return false;
107
+ if (listFilter.room && !entry.room.toLowerCase().includes(listFilter.room))
108
+ return false;
109
+ return true;
110
+ };
78
111
  if (fmt === 'json' && process.argv.includes('--json')) {
79
- printJson(body);
112
+ if (listFilter) {
113
+ const filteredDeviceList = deviceList.filter((d) => matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }));
114
+ const filteredIrList = infraredRemoteList.filter((d) => {
115
+ const inherited = hubLocation.get(d.hubDeviceId);
116
+ return matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' });
117
+ });
118
+ printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList });
119
+ }
120
+ else {
121
+ printJson({ ok: true, ...body });
122
+ }
80
123
  return;
81
124
  }
82
- const hubLocation = buildHubLocationMap(deviceList);
83
125
  const narrowHeaders = ['deviceId', 'deviceName', 'type', 'category'];
84
126
  const wideHeaders = ['deviceId', 'deviceName', 'type', 'category', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud', 'alias'];
85
127
  const userFields = resolveFields();
@@ -88,6 +130,8 @@ Examples:
88
130
  for (const d of deviceList) {
89
131
  if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
90
132
  continue;
133
+ if (!matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }))
134
+ continue;
91
135
  rows.push([
92
136
  d.deviceId,
93
137
  d.deviceName,
@@ -106,6 +150,8 @@ Examples:
106
150
  if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
107
151
  continue;
108
152
  const inherited = hubLocation.get(d.hubDeviceId);
153
+ if (!matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' }))
154
+ continue;
109
155
  rows.push([
110
156
  d.deviceId,
111
157
  d.deviceName,
@@ -121,13 +167,22 @@ Examples:
121
167
  ]);
122
168
  }
123
169
  if (rows.length === 0 && fmt === 'table') {
124
- console.log('No devices found');
170
+ console.log(listFilter ? 'No devices matched the filter.' : 'No devices found');
125
171
  return;
126
172
  }
127
173
  const defaultFields = options.wide ? undefined : narrowHeaders;
128
- renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields);
174
+ // Accept API field names and short aliases alongside canonical column names
175
+ const DEVICE_LIST_ALIASES = {
176
+ name: 'deviceName', deviceType: 'type', type: 'type',
177
+ roomName: 'room', familyName: 'family',
178
+ hubDeviceId: 'hub', enableCloudService: 'cloud',
179
+ };
180
+ renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_LIST_ALIASES);
129
181
  if (fmt === 'table') {
130
- console.log(`\nTotal: ${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`);
182
+ const totalLabel = listFilter
183
+ ? `${rows.length} match(es) (${deviceList.length} physical + ${infraredRemoteList.length} IR before filter)`
184
+ : `${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`;
185
+ console.log(`\nTotal: ${totalLabel}`);
131
186
  console.log(`Tip: 'switchbot devices describe <deviceId>' shows a device's supported commands.`);
132
187
  }
133
188
  }
@@ -139,8 +194,9 @@ Examples:
139
194
  devices
140
195
  .command('status')
141
196
  .description('Query the real-time status of a specific device')
142
- .argument('[deviceId]', 'Device ID from "devices list" (or use --name)')
197
+ .argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)')
143
198
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
199
+ .option('--ids <list>', 'Comma-separated device IDs for batch status (incompatible with --name)')
144
200
  .addHelpText('after', `
145
201
  Status fields vary by device type. To discover them without a live call:
146
202
 
@@ -155,25 +211,70 @@ Examples:
155
211
  $ switchbot devices status ABC123DEF456 --json
156
212
  $ switchbot devices status ABC123DEF456 --format yaml
157
213
  $ switchbot devices status ABC123DEF456 --format tsv --fields power,battery
158
- $ switchbot devices status ABC123DEF456 --json | jq '.battery'
214
+ $ switchbot devices status ABC123DEF456 --json | jq '.data.battery'
215
+ $ switchbot devices status --ids ABC123,DEF456,GHI789
216
+ $ switchbot devices status --ids ABC123,DEF456 --fields power,battery
159
217
  `)
160
218
  .action(async (deviceIdArg, options) => {
161
219
  try {
220
+ // Batch mode: --ids id1,id2,id3
221
+ if (options.ids) {
222
+ if (options.name)
223
+ throw new UsageError('--ids and --name cannot be used together.');
224
+ const ids = options.ids.split(',').map((s) => s.trim()).filter(Boolean);
225
+ if (ids.length === 0)
226
+ throw new UsageError('--ids requires at least one device ID.');
227
+ const results = await Promise.allSettled(ids.map((id) => fetchDeviceStatus(id)));
228
+ const fetchedAt = new Date().toISOString();
229
+ const batch = results.map((r, i) => r.status === 'fulfilled'
230
+ ? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt, ...r.value }
231
+ : { deviceId: ids[i], ok: false, error: r.reason?.message ?? String(r.reason) });
232
+ const batchFmt = resolveFormat();
233
+ if (isJsonMode() || batchFmt === 'json') {
234
+ printJson(batch);
235
+ }
236
+ else if (batchFmt === 'jsonl') {
237
+ for (const entry of batch) {
238
+ console.log(JSON.stringify(entry));
239
+ }
240
+ }
241
+ else {
242
+ const fields = resolveFields();
243
+ for (const entry of batch) {
244
+ const { deviceId, ok, error, _fetchedAt: ts, ...status } = entry;
245
+ console.log(`\n─── ${String(deviceId)} ───`);
246
+ if (!ok) {
247
+ console.error(` error: ${String(error)}`);
248
+ }
249
+ else {
250
+ const displayStatus = fields
251
+ ? Object.fromEntries(fields.map((f) => [f, status[f] ?? null]))
252
+ : status;
253
+ printKeyValue(displayStatus);
254
+ console.error(` fetched at ${String(ts)}`);
255
+ }
256
+ }
257
+ }
258
+ return;
259
+ }
162
260
  const deviceId = resolveDeviceId(deviceIdArg, options.name);
163
261
  const body = await fetchDeviceStatus(deviceId);
262
+ const fetchedAt = new Date().toISOString();
164
263
  const fmt = resolveFormat();
165
264
  if (fmt === 'json' && process.argv.includes('--json')) {
166
- printJson(body);
265
+ printJson({ ...body, _fetchedAt: fetchedAt });
167
266
  return;
168
267
  }
169
268
  if (fmt !== 'table') {
170
- const allHeaders = Object.keys(body);
171
- const allRows = [Object.values(body)];
269
+ const statusWithTs = { ...body, _fetchedAt: fetchedAt };
270
+ const allHeaders = Object.keys(statusWithTs);
271
+ const allRows = [Object.values(statusWithTs)];
172
272
  const fields = resolveFields();
173
273
  renderRows(allHeaders, allRows, fmt, fields);
174
274
  return;
175
275
  }
176
276
  printKeyValue(body);
277
+ console.error(`\nfetched at ${fetchedAt}`);
177
278
  }
178
279
  catch (error) {
179
280
  handleError(error);
@@ -184,7 +285,7 @@ Examples:
184
285
  .command('command')
185
286
  .description('Send a control command to a device')
186
287
  .argument('[deviceId]', 'Target device ID (or use --name)')
187
- .argument('<cmd>', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
288
+ .argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
188
289
  .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).')
189
290
  .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
190
291
  .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', 'command')
@@ -235,60 +336,86 @@ Examples:
235
336
  $ switchbot devices command ABC123 "MyButton" --type customize
236
337
  $ switchbot devices command <lockId> unlock --yes
237
338
  `)
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 }));
339
+ .action(async (deviceIdArg, cmdArg, parameter, options) => {
340
+ try {
341
+ // BUG-FIX: When --name is provided, Commander misassigns the first positional
342
+ // to [deviceId] instead of [cmd]. Detect and shift positionals accordingly.
343
+ let cmd;
344
+ let effectiveDeviceIdArg;
345
+ if (options.name) {
346
+ if (deviceIdArg && cmdArg) {
347
+ throw new UsageError('Provide either a deviceId argument or --name, not both.');
348
+ }
349
+ if (!deviceIdArg && !cmdArg) {
350
+ throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
351
+ }
352
+ // --name "x" turnOn → deviceIdArg="turnOn", cmdArg=undefined → shift
353
+ cmd = (deviceIdArg ?? cmdArg);
354
+ effectiveDeviceIdArg = undefined;
249
355
  }
250
356
  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.)`);
357
+ if (!cmdArg) {
358
+ throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
359
+ }
360
+ cmd = cmdArg;
361
+ effectiveDeviceIdArg = deviceIdArg;
362
+ }
363
+ const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
364
+ const validation = validateCommand(deviceId, cmd, parameter, options.type);
365
+ if (!validation.ok) {
366
+ const err = validation.error;
367
+ if (isJsonMode()) {
368
+ const obj = { code: 2, kind: 'usage', message: err.message };
369
+ if (err.hint)
370
+ obj.hint = err.hint;
371
+ obj.context = { validationKind: err.kind };
372
+ console.error(JSON.stringify({ error: obj }));
373
+ }
374
+ else {
375
+ console.error(`Error: ${err.message}`);
376
+ if (err.hint)
377
+ console.error(err.hint);
378
+ if (err.kind === 'unknown-command') {
379
+ const cached = getCachedDevice(deviceId);
380
+ if (cached) {
381
+ console.error(`Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.`);
382
+ 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.)`);
383
+ }
259
384
  }
260
385
  }
386
+ process.exit(2);
261
387
  }
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
- }));
388
+ const cachedForGuard = getCachedDevice(deviceId);
389
+ if (!options.yes &&
390
+ !isDryRun() &&
391
+ isDestructiveCommand(cachedForGuard?.type, cmd, options.type)) {
392
+ const typeLabel = cachedForGuard?.type ?? 'unknown';
393
+ const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
394
+ if (isJsonMode()) {
395
+ console.error(JSON.stringify({
396
+ error: {
397
+ code: 2,
398
+ kind: 'guard',
399
+ message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`,
400
+ hint: reason
401
+ ? `Re-run with --yes to confirm. Reason: ${reason}`
402
+ : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
403
+ context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
404
+ },
405
+ }));
406
+ }
407
+ else {
408
+ console.error(`Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`);
409
+ if (reason)
410
+ console.error(`Reason: ${reason}`);
411
+ console.error(`Re-run with --yes to confirm, or --dry-run to preview without sending.`);
412
+ }
413
+ process.exit(2);
282
414
  }
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.`);
415
+ // Warn when --yes is given but the command is not destructive (no-op flag)
416
+ if (options.yes && !isDestructiveCommand(cachedForGuard?.type, cmd, options.type) && !isDryRun()) {
417
+ console.error(`Note: --yes has no effect; "${cmd}" is not a destructive command.`);
288
418
  }
289
- process.exit(2);
290
- }
291
- try {
292
419
  // parameter may be a JSON object string (e.g. S10 startClean) or a plain string
293
420
  let parsedParam = parameter ?? 'default';
294
421
  if (parameter) {
@@ -311,14 +438,21 @@ Examples:
311
438
  printJson(result);
312
439
  return;
313
440
  }
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);
441
+ if (isIr) {
442
+ console.log(`→ IR signal sent: ${cmd} (no feedback — fire-and-forget)`);
443
+ }
444
+ else {
445
+ console.log(`✓ Command sent: ${cmd}`);
446
+ if (body && typeof body === 'object' && Object.keys(body).length > 0) {
447
+ printKeyValue(body);
448
+ }
319
449
  }
320
450
  }
321
451
  catch (error) {
452
+ // Re-throw mock process.exit signals (Vitest intercepts process.exit as thrown
453
+ // Error('__exit__')) so they aren't double-handled and the exit code is preserved.
454
+ if (error instanceof Error && error.message === '__exit__')
455
+ throw error;
322
456
  handleError(error);
323
457
  }
324
458
  });
@@ -342,9 +476,10 @@ Examples:
342
476
  printJson(catalog);
343
477
  return;
344
478
  }
345
- const headers = ['type', 'category', 'commands', 'aliases'];
479
+ const headers = ['type', 'role', 'category', 'commands', 'aliases'];
346
480
  const rows = catalog.map((e) => [
347
481
  e.type,
482
+ e.role ?? '—',
348
483
  e.category,
349
484
  String(e.commands.length),
350
485
  (e.aliases ?? []).join(', ') || '—',
@@ -535,6 +670,7 @@ function renderCatalogEntry(entry) {
535
670
  }
536
671
  else {
537
672
  console.log('\nCommands:');
673
+ const hasExamples = entry.commands.some((c) => c.exampleParams && c.exampleParams.length > 0);
538
674
  const rows = entry.commands.map((c) => {
539
675
  const flags = [];
540
676
  if (c.commandType === 'customize')
@@ -542,9 +678,13 @@ function renderCatalogEntry(entry) {
542
678
  if (c.destructive)
543
679
  flags.push('!destructive');
544
680
  const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command;
545
- return [label, c.parameter, c.description];
681
+ const base = [label, c.parameter, c.description];
682
+ return hasExamples ? [...base, (c.exampleParams ?? []).join(' | ') || ''] : base;
546
683
  });
547
- printTable(['command', 'parameter', 'description'], rows);
684
+ const tableHeaders = hasExamples
685
+ ? ['command', 'parameter', 'description', 'example']
686
+ : ['command', 'parameter', 'description'];
687
+ printTable(tableHeaders, rows);
548
688
  const hasDestructive = entry.commands.some((c) => c.destructive);
549
689
  if (hasDestructive) {
550
690
  console.log('\n[!destructive] commands have hard-to-reverse real-world effects — confirm before issuing.');