@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 +55 -3
- package/dist/commands/capabilities.js +30 -20
- package/dist/commands/devices.js +206 -66
- package/dist/commands/events.js +114 -9
- package/dist/commands/expand.js +20 -3
- package/dist/commands/mcp.js +35 -0
- package/dist/commands/plan.js +10 -2
- package/dist/commands/scenes.js +1 -1
- package/dist/commands/watch.js +13 -2
- package/dist/index.js +2 -1
- package/dist/lib/devices.js +16 -1
- package/dist/mcp/device-history.js +66 -0
- package/dist/mcp/events-subscription.js +10 -3
- package/dist/mqtt/client.js +8 -0
- package/dist/mqtt/credential.js +3 -2
- package/dist/sinks/dispatcher.js +12 -0
- package/dist/sinks/file.js +19 -0
- package/dist/sinks/format.js +56 -0
- package/dist/sinks/homeassistant.js +44 -0
- package/dist/sinks/openclaw.js +33 -0
- package/dist/sinks/stdout.js +5 -0
- package/dist/sinks/telegram.js +28 -0
- package/dist/sinks/types.js +1 -0
- package/dist/sinks/webhook.js +22 -0
- package/dist/utils/flags.js +13 -12
- package/dist/utils/format.js +6 -5
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
.
|
|
35
|
+
.option('--minimal', 'Omit per-subcommand flag details to reduce output size')
|
|
36
|
+
.action((opts) => {
|
|
35
37
|
const catalog = getEffectiveCatalog();
|
|
36
|
-
const
|
|
37
|
-
.
|
|
38
|
-
|
|
39
|
-
name:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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,
|
package/dist/commands/devices.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
171
|
-
const
|
|
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('
|
|
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,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
681
|
+
const base = [label, c.parameter, c.description];
|
|
682
|
+
return hasExamples ? [...base, (c.exampleParams ?? []).join(' | ') || ''] : base;
|
|
546
683
|
});
|
|
547
|
-
|
|
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.');
|