@switchbot/openapi-cli 2.2.0 → 2.3.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
@@ -177,6 +177,8 @@ switchbot --help
177
177
  switchbot devices command --help
178
178
  ```
179
179
 
180
+ > **Tip — required-value flags and subcommands.** Flags like `--profile`, `--timeout`, `--max`, and `--interval` take a value. If you omit it, Commander will happily consume the next token — including a subcommand name. Since v2.2.1 the CLI rejects that eagerly (exit 2 with a clear error), but if you ever hit `unknown command 'list'` after something like `switchbot --profile list`, use the `--flag=value` form: `switchbot --profile=home devices list`.
181
+
180
182
  ### `--dry-run`
181
183
 
182
184
  Intercepts every non-GET request: the CLI prints the URL/body it would have
@@ -206,6 +208,7 @@ switchbot config list-profiles # List saved profiles
206
208
  # Default columns (4): deviceId, deviceName, type, category
207
209
  # Pass --wide for the full 10-column operator view
208
210
  switchbot devices list
211
+ switchbot devices ls # short alias for 'list'
209
212
  switchbot devices list --wide
210
213
  switchbot devices list --json | jq '.deviceList[].deviceId'
211
214
 
@@ -275,6 +278,8 @@ Generic parameter shapes (which one applies is decided by the device — see the
275
278
  | `<json object>` | `'{"action":"sweep","param":{"fanLevel":2,"times":1}}'` |
276
279
  | Custom IR button | `devices command <id> MyButton --type customize` |
277
280
 
281
+ Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), and `setMode` (Relay Switch) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list.
282
+
278
283
  For the complete per-device command reference, see the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands).
279
284
 
280
285
  #### `devices expand` — named flags for packed parameters
@@ -1,3 +1,4 @@
1
+ import { intArg, enumArg, stringArg } from '../utils/arg-parsers.js';
1
2
  import { printJson, isJsonMode, handleError, buildErrorPayload } from '../utils/output.js';
2
3
  import { fetchDeviceList, executeCommand, isDestructiveCommand, buildHubLocationMap, } from '../lib/devices.js';
3
4
  import { createClient } from '../api/client.js';
@@ -6,6 +7,7 @@ import { isDryRun } from '../utils/flags.js';
6
7
  import { DryRunSignal } from '../api/client.js';
7
8
  import { getCachedTypeMap } from '../devices/cache.js';
8
9
  const DEFAULT_CONCURRENCY = 5;
10
+ const COMMAND_TYPES = ['command', 'customize'];
9
11
  /** Run `task(x)` for every element with at most `concurrency` running at once. */
10
12
  async function runPool(items, concurrency, task) {
11
13
  const results = new Array(items.length);
@@ -84,13 +86,13 @@ export function registerBatchCommand(devices) {
84
86
  .description('Send the same command to many devices in one run (filter- or stdin-driven)')
85
87
  .argument('<command>', 'Command name, e.g. turnOn, turnOff, setBrightness')
86
88
  .argument('[parameter]', 'Command parameter (same rules as `devices command`; omit for no-arg)')
87
- .option('--filter <expr>', 'Target devices matching a filter, e.g. type=Bot,family=Home')
88
- .option('--ids <csv>', 'Explicit comma-separated list of deviceIds')
89
- .option('--concurrency <n>', 'Max parallel in-flight requests (default 5)', '5')
89
+ .option('--filter <expr>', 'Target devices matching a filter, e.g. type=Bot,family=Home', stringArg('--filter'))
90
+ .option('--ids <csv>', 'Explicit comma-separated list of deviceIds', stringArg('--ids'))
91
+ .option('--concurrency <n>', 'Max parallel in-flight requests (default 5)', intArg('--concurrency', { min: 1 }), '5')
90
92
  .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
91
- .option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', 'command')
93
+ .option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
92
94
  .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
93
- .option('--idempotency-key-prefix <prefix>', 'Prefix for idempotency keys (key per device: <prefix>-<deviceId>)')
95
+ .option('--idempotency-key-prefix <prefix>', 'Prefix for idempotency keys (key per device: <prefix>-<deviceId>)', stringArg('--idempotency-key-prefix'))
94
96
  .addHelpText('after', `
95
97
  Targets are resolved in this priority order:
96
98
  1. --ids when present (explicit deviceIds)
@@ -1,3 +1,4 @@
1
+ import { enumArg } from '../utils/arg-parsers.js';
1
2
  import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
2
3
  import { clearCache, clearStatusCache, describeCache, loadStatusCache, } from '../devices/cache.js';
3
4
  function formatAge(ms) {
@@ -15,6 +16,7 @@ function formatAge(ms) {
15
16
  return `${h}h ${m % 60}m`;
16
17
  }
17
18
  export function registerCacheCommand(program) {
19
+ const CACHE_KEYS = ['list', 'status', 'all'];
18
20
  const cache = program
19
21
  .command('cache')
20
22
  .description('Inspect and manage the local SwitchBot CLI caches')
@@ -78,7 +80,7 @@ Examples:
78
80
  cache
79
81
  .command('clear')
80
82
  .description('Delete cache files')
81
- .option('--key <which>', 'Which cache to clear: "list" | "status" | "all" (default)', 'all')
83
+ .option('--key <which>', 'Which cache to clear: "list" | "status" | "all" (default)', enumArg('--key', CACHE_KEYS), 'all')
82
84
  .action((options) => {
83
85
  try {
84
86
  const key = options.key;
@@ -1,7 +1,9 @@
1
+ import { enumArg } from '../utils/arg-parsers.js';
1
2
  import { printTable, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
2
3
  import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
3
4
  import { DEVICE_CATALOG, findCatalogEntry, getCatalogOverlayPath, getEffectiveCatalog, loadCatalogOverlay, resetCatalogOverlayCache, } from '../devices/catalog.js';
4
5
  export function registerCatalogCommand(program) {
6
+ const SOURCES = ['built-in', 'overlay', 'effective'];
5
7
  const catalog = program
6
8
  .command('catalog')
7
9
  .description('Inspect the built-in device catalog and any local overlay')
@@ -66,7 +68,7 @@ Examples:
66
68
  .command('show')
67
69
  .description("Show the effective catalog (or one entry). Defaults to 'effective' source.")
68
70
  .argument('[type...]', 'Optional device type/alias (case-insensitive, partial match)')
69
- .option('--source <source>', 'Which catalog to show: built-in | overlay | effective (default)', 'effective')
71
+ .option('--source <source>', 'Which catalog to show: built-in | overlay | effective (default)', enumArg('--source', SOURCES), 'effective')
70
72
  .action((typeParts, options) => {
71
73
  try {
72
74
  const source = options.source;
@@ -12,13 +12,19 @@ _switchbot_completion() {
12
12
  cword="\${COMP_CWORD}"
13
13
  }
14
14
 
15
- local top_cmds="config devices scenes webhook completion help"
16
- local config_sub="set-token show"
17
- local devices_sub="list status command types commands"
15
+ local top_cmds="config devices scenes webhook completion mcp quota catalog cache events doctor schema history plan capabilities help"
16
+ local config_sub="set-token show list-profiles"
17
+ local devices_sub="list ls status command types commands describe batch watch explain expand meta"
18
18
  local scenes_sub="list execute"
19
19
  local webhook_sub="setup query update delete"
20
+ local events_sub="tail mqtt-tail"
21
+ local quota_sub="status reset"
22
+ local catalog_sub="path show diff refresh"
23
+ local cache_sub="show clear"
24
+ local history_sub="show replay"
25
+ local plan_sub="schema validate run"
20
26
  local completion_shells="bash zsh fish powershell"
21
- local global_opts="--json --verbose -v --dry-run --timeout --config --help -h --version -V"
27
+ local global_opts="--json --format --fields --verbose -v --dry-run --timeout --retry-on-429 --backoff --no-retry --no-quota --cache --no-cache --config --profile --audit-log --audit-log-path --help -h --version -V"
22
28
 
23
29
  if [[ \${cword} -eq 1 ]]; then
24
30
  COMPREPLY=( $(compgen -W "\${top_cmds} \${global_opts}" -- "\${cur}") )
@@ -43,6 +49,36 @@ _switchbot_completion() {
43
49
  COMPREPLY=( $(compgen -W "\${scenes_sub}" -- "\${cur}") )
44
50
  fi
45
51
  ;;
52
+ events)
53
+ if [[ \${cword} -eq 2 ]]; then
54
+ COMPREPLY=( $(compgen -W "\${events_sub}" -- "\${cur}") )
55
+ fi
56
+ ;;
57
+ quota)
58
+ if [[ \${cword} -eq 2 ]]; then
59
+ COMPREPLY=( $(compgen -W "\${quota_sub}" -- "\${cur}") )
60
+ fi
61
+ ;;
62
+ catalog)
63
+ if [[ \${cword} -eq 2 ]]; then
64
+ COMPREPLY=( $(compgen -W "\${catalog_sub}" -- "\${cur}") )
65
+ fi
66
+ ;;
67
+ cache)
68
+ if [[ \${cword} -eq 2 ]]; then
69
+ COMPREPLY=( $(compgen -W "\${cache_sub}" -- "\${cur}") )
70
+ fi
71
+ ;;
72
+ history)
73
+ if [[ \${cword} -eq 2 ]]; then
74
+ COMPREPLY=( $(compgen -W "\${history_sub}" -- "\${cur}") )
75
+ fi
76
+ ;;
77
+ plan)
78
+ if [[ \${cword} -eq 2 ]]; then
79
+ COMPREPLY=( $(compgen -W "\${plan_sub}" -- "\${cur}") )
80
+ fi
81
+ ;;
46
82
  webhook)
47
83
  if [[ \${cword} -eq 2 ]]; then
48
84
  COMPREPLY=( $(compgen -W "\${webhook_sub}" -- "\${cur}") )
@@ -69,22 +105,39 @@ const ZSH_SCRIPT = `# switchbot zsh completion
69
105
  # source <(switchbot completion zsh)
70
106
 
71
107
  _switchbot() {
72
- local -a top_cmds config_sub devices_sub scenes_sub webhook_sub completion_shells
108
+ local -a top_cmds config_sub devices_sub scenes_sub webhook_sub events_sub quota_sub catalog_sub cache_sub history_sub plan_sub completion_shells
73
109
  top_cmds=(
74
110
  'config:Manage API credentials'
75
111
  'devices:List and control devices'
76
112
  'scenes:List and execute scenes'
77
113
  'webhook:Manage webhook configuration'
78
114
  'completion:Print a shell completion script'
115
+ 'mcp:Run the MCP server'
116
+ 'quota:Inspect local request quota'
117
+ 'catalog:Inspect the built-in device catalog'
118
+ 'cache:Inspect local caches'
119
+ 'events:Receive webhook or MQTT events'
120
+ 'doctor:Run self-checks'
121
+ 'schema:Export the device catalog as JSON'
122
+ 'history:View and replay audited commands'
123
+ 'plan:Validate and run batch plans'
124
+ 'capabilities:Print a machine-readable manifest'
79
125
  'help:Show help for a command'
80
126
  )
81
- config_sub=('set-token:Save token + secret' 'show:Show current credential source')
127
+ config_sub=('set-token:Save token + secret' 'show:Show current credential source' 'list-profiles:List named credential profiles')
82
128
  devices_sub=(
83
129
  'list:List all devices'
130
+ 'ls:Alias for list'
84
131
  'status:Query device status'
85
132
  'command:Send a control command'
86
133
  'types:List known device types (offline)'
87
134
  'commands:Show commands for a device type (offline)'
135
+ 'describe:Show metadata + supported commands for one device'
136
+ 'batch:Send one command to many devices'
137
+ 'watch:Poll device status and emit changes'
138
+ 'explain:One-shot device summary'
139
+ 'expand:Build wire-format params from semantic flags'
140
+ 'meta:Manage local device metadata'
88
141
  )
89
142
  scenes_sub=('list:List manual scenes' 'execute:Run a scene')
90
143
  webhook_sub=(
@@ -93,15 +146,32 @@ _switchbot() {
93
146
  'update:Enable/disable a webhook'
94
147
  'delete:Delete a webhook'
95
148
  )
149
+ events_sub=('tail:Run a local webhook receiver' 'mqtt-tail:Stream MQTT shadow events')
150
+ quota_sub=('status:Show today and recent quota usage' 'reset:Delete the local quota counter')
151
+ catalog_sub=('path:Show overlay path' 'show:Show built-in/overlay/effective catalog' 'diff:Show overlay changes' 'refresh:Clear overlay cache')
152
+ cache_sub=('show:Summarize cache files' 'clear:Delete cache files')
153
+ history_sub=('show:Print recent audit entries' 'replay:Re-run one audited command')
154
+ plan_sub=('schema:Print the plan schema' 'validate:Validate a plan file' 'run:Validate and execute a plan')
96
155
  completion_shells=('bash' 'zsh' 'fish' 'powershell')
97
156
 
98
157
  local global_opts
99
158
  global_opts=(
100
159
  '--json[Raw JSON output]'
160
+ '--format[Output format]:type:(table json jsonl tsv yaml id)'
161
+ '--fields[Comma-separated output columns]:csv:'
101
162
  '(-v --verbose)'{-v,--verbose}'[Log HTTP details to stderr]'
102
163
  '--dry-run[Print mutating requests without sending]'
103
164
  '--timeout[HTTP timeout in ms]:ms:'
165
+ '--retry-on-429[Max 429 retries]:n:'
166
+ '--backoff[Retry backoff strategy]:strategy:(linear exponential)'
167
+ '--no-retry[Disable 429 retries]'
168
+ '--no-quota[Disable the local quota counter]'
169
+ '--cache[Cache mode]:mode:'
170
+ '--no-cache[Disable cache reads]'
104
171
  '--config[Override credential file path]:path:_files'
172
+ '--profile[Use a named credential profile]:name:'
173
+ '--audit-log[Append mutating commands to ~/.switchbot/audit.log]'
174
+ '--audit-log-path[Custom audit log file path]:path:_files'
105
175
  '(-h --help)'{-h,--help}'[Show help]'
106
176
  '(-V --version)'{-V,--version}'[Show version]'
107
177
  )
@@ -122,6 +192,12 @@ _switchbot() {
122
192
  devices) _describe 'devices' devices_sub ;;
123
193
  scenes) _describe 'scenes' scenes_sub ;;
124
194
  webhook) _describe 'webhook' webhook_sub ;;
195
+ events) _describe 'events' events_sub ;;
196
+ quota) _describe 'quota' quota_sub ;;
197
+ catalog) _describe 'catalog' catalog_sub ;;
198
+ cache) _describe 'cache' cache_sub ;;
199
+ history) _describe 'history' history_sub ;;
200
+ plan) _describe 'plan' plan_sub ;;
125
201
  completion) _values 'shell' $completion_shells ;;
126
202
  esac
127
203
  ;;
@@ -143,10 +219,21 @@ complete -c switchbot -f
143
219
 
144
220
  # Global options
145
221
  complete -c switchbot -l json -d 'Raw JSON output'
222
+ complete -c switchbot -l format -r -d 'Output format'
223
+ complete -c switchbot -l fields -r -d 'Comma-separated output columns'
146
224
  complete -c switchbot -s v -l verbose -d 'Log HTTP details to stderr'
147
225
  complete -c switchbot -l dry-run -d 'Print mutating requests without sending'
148
226
  complete -c switchbot -l timeout -r -d 'HTTP timeout in ms'
227
+ complete -c switchbot -l retry-on-429 -r -d 'Max 429 retries'
228
+ complete -c switchbot -l backoff -r -d 'Retry backoff strategy'
229
+ complete -c switchbot -l no-retry -d 'Disable 429 retries'
230
+ complete -c switchbot -l no-quota -d 'Disable the local quota counter'
231
+ complete -c switchbot -l cache -r -d 'Cache mode'
232
+ complete -c switchbot -l no-cache -d 'Disable cache reads'
149
233
  complete -c switchbot -l config -r -d 'Credential file path'
234
+ complete -c switchbot -l profile -r -d 'Named credential profile'
235
+ complete -c switchbot -l audit-log -d 'Append mutating commands to audit log'
236
+ complete -c switchbot -l audit-log-path -r -d 'Custom audit log file path'
150
237
  complete -c switchbot -s h -l help -d 'Show help'
151
238
  complete -c switchbot -s V -l version -d 'Show version'
152
239
 
@@ -156,13 +243,23 @@ complete -c switchbot -n '__fish_use_subcommand' -a 'devices' -d 'List and co
156
243
  complete -c switchbot -n '__fish_use_subcommand' -a 'scenes' -d 'List and execute scenes'
157
244
  complete -c switchbot -n '__fish_use_subcommand' -a 'webhook' -d 'Manage webhook configuration'
158
245
  complete -c switchbot -n '__fish_use_subcommand' -a 'completion' -d 'Print a shell completion script'
246
+ complete -c switchbot -n '__fish_use_subcommand' -a 'mcp' -d 'Run the MCP server'
247
+ complete -c switchbot -n '__fish_use_subcommand' -a 'quota' -d 'Inspect local request quota'
248
+ complete -c switchbot -n '__fish_use_subcommand' -a 'catalog' -d 'Inspect the built-in device catalog'
249
+ complete -c switchbot -n '__fish_use_subcommand' -a 'cache' -d 'Inspect local caches'
250
+ complete -c switchbot -n '__fish_use_subcommand' -a 'events' -d 'Receive webhook or MQTT events'
251
+ complete -c switchbot -n '__fish_use_subcommand' -a 'doctor' -d 'Run self-checks'
252
+ complete -c switchbot -n '__fish_use_subcommand' -a 'schema' -d 'Export the device catalog as JSON'
253
+ complete -c switchbot -n '__fish_use_subcommand' -a 'history' -d 'View and replay audited commands'
254
+ complete -c switchbot -n '__fish_use_subcommand' -a 'plan' -d 'Validate and run batch plans'
255
+ complete -c switchbot -n '__fish_use_subcommand' -a 'capabilities' -d 'Print a machine-readable manifest'
159
256
  complete -c switchbot -n '__fish_use_subcommand' -a 'help' -d 'Show help'
160
257
 
161
258
  # config
162
- complete -c switchbot -n '__fish_seen_subcommand_from config' -a 'set-token show'
259
+ complete -c switchbot -n '__fish_seen_subcommand_from config' -a 'set-token show list-profiles'
163
260
 
164
261
  # devices
165
- complete -c switchbot -n '__fish_seen_subcommand_from devices' -a 'list status command types commands'
262
+ complete -c switchbot -n '__fish_seen_subcommand_from devices' -a 'list ls status command types commands describe batch watch explain expand meta'
166
263
 
167
264
  # scenes
168
265
  complete -c switchbot -n '__fish_seen_subcommand_from scenes' -a 'list execute'
@@ -172,6 +269,24 @@ complete -c switchbot -n '__fish_seen_subcommand_from webhook' -a 'setup query u
172
269
  complete -c switchbot -n '__fish_seen_subcommand_from webhook; and __fish_seen_subcommand_from update' -l enable -d 'Enable the webhook'
173
270
  complete -c switchbot -n '__fish_seen_subcommand_from webhook; and __fish_seen_subcommand_from update' -l disable -d 'Disable the webhook'
174
271
 
272
+ # events
273
+ complete -c switchbot -n '__fish_seen_subcommand_from events' -a 'tail mqtt-tail'
274
+
275
+ # quota
276
+ complete -c switchbot -n '__fish_seen_subcommand_from quota' -a 'status reset'
277
+
278
+ # catalog
279
+ complete -c switchbot -n '__fish_seen_subcommand_from catalog' -a 'path show diff refresh'
280
+
281
+ # cache
282
+ complete -c switchbot -n '__fish_seen_subcommand_from cache' -a 'show clear'
283
+
284
+ # history
285
+ complete -c switchbot -n '__fish_seen_subcommand_from history' -a 'show replay'
286
+
287
+ # plan
288
+ complete -c switchbot -n '__fish_seen_subcommand_from plan' -a 'schema validate run'
289
+
175
290
  # completion
176
291
  complete -c switchbot -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish powershell'
177
292
  `;
@@ -186,13 +301,19 @@ Register-ArgumentCompleter -Native -CommandName switchbot -ScriptBlock {
186
301
  $tokens = $commandAst.CommandElements | ForEach-Object { $_.ToString() }
187
302
  $count = $tokens.Count
188
303
 
189
- $top = 'config','devices','scenes','webhook','completion','help'
190
- $configSub = 'set-token','show'
191
- $devicesSub = 'list','status','command','types','commands'
304
+ $top = 'config','devices','scenes','webhook','completion','mcp','quota','catalog','cache','events','doctor','schema','history','plan','capabilities','help'
305
+ $configSub = 'set-token','show','list-profiles'
306
+ $devicesSub = 'list','ls','status','command','types','commands','describe','batch','watch','explain','expand','meta'
192
307
  $scenesSub = 'list','execute'
193
308
  $webhookSub = 'setup','query','update','delete'
309
+ $eventsSub = 'tail','mqtt-tail'
310
+ $quotaSub = 'status','reset'
311
+ $catalogSub = 'path','show','diff','refresh'
312
+ $cacheSub = 'show','clear'
313
+ $historySub = 'show','replay'
314
+ $planSub = 'schema','validate','run'
194
315
  $shells = 'bash','zsh','fish','powershell'
195
- $globalOpts = '--json','--verbose','-v','--dry-run','--timeout','--config','--help','-h','--version','-V'
316
+ $globalOpts = '--json','--format','--fields','--verbose','-v','--dry-run','--timeout','--retry-on-429','--backoff','--no-retry','--no-quota','--cache','--no-cache','--config','--profile','--audit-log','--audit-log-path','--help','-h','--version','-V'
196
317
 
197
318
  function _emit($values) {
198
319
  $values |
@@ -206,6 +327,12 @@ Register-ArgumentCompleter -Native -CommandName switchbot -ScriptBlock {
206
327
  'config' { if ($count -eq 3) { return _emit $configSub } }
207
328
  'devices' { if ($count -eq 3) { return _emit $devicesSub } }
208
329
  'scenes' { if ($count -eq 3) { return _emit $scenesSub } }
330
+ 'events' { if ($count -eq 3) { return _emit $eventsSub } }
331
+ 'quota' { if ($count -eq 3) { return _emit $quotaSub } }
332
+ 'catalog' { if ($count -eq 3) { return _emit $catalogSub } }
333
+ 'cache' { if ($count -eq 3) { return _emit $cacheSub } }
334
+ 'history' { if ($count -eq 3) { return _emit $historySub } }
335
+ 'plan' { if ($count -eq 3) { return _emit $planSub } }
209
336
  'webhook' {
210
337
  if ($count -eq 3) { return _emit $webhookSub }
211
338
  if ($tokens[2] -eq 'update') { return _emit ('--enable','--disable' + $globalOpts) }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { execFileSync } from 'node:child_process';
3
+ import { stringArg } from '../utils/arg-parsers.js';
3
4
  import { saveConfig, showConfig, listProfiles } from '../config.js';
4
5
  import { isJsonMode, printJson } from '../utils/output.js';
5
6
  import chalk from 'chalk';
@@ -49,9 +50,9 @@ Obtain your token/secret from the SwitchBot mobile app:
49
50
  .description('Save token and secret (mode 0600). Use --profile to target a named profile.')
50
51
  .argument('[token]', 'API token; omit when using --from-env-file / --from-op')
51
52
  .argument('[secret]', 'API client secret; omit when using --from-env-file / --from-op')
52
- .option('--from-env-file <path>', 'Read SWITCHBOT_TOKEN and SWITCHBOT_SECRET from a dotenv file')
53
- .option('--from-op <tokenRef>', 'Read token via 1Password CLI (op read). Pair with --op-secret <ref>')
54
- .option('--op-secret <secretRef>', '1Password reference for the secret, used with --from-op')
53
+ .option('--from-env-file <path>', 'Read SWITCHBOT_TOKEN and SWITCHBOT_SECRET from a dotenv file', stringArg('--from-env-file'))
54
+ .option('--from-op <tokenRef>', 'Read token via 1Password CLI (op read). Pair with --op-secret <ref>', stringArg('--from-op'))
55
+ .option('--op-secret <secretRef>', '1Password reference for the secret, used with --from-op', stringArg('--op-secret'))
55
56
  .addHelpText('after', `
56
57
  Examples:
57
58
  $ switchbot config set-token <token> <secret>
@@ -1,3 +1,4 @@
1
+ import { stringArg } from '../utils/arg-parsers.js';
1
2
  import { handleError, isJsonMode, printJson, printTable, UsageError } from '../utils/output.js';
2
3
  import { loadDeviceMeta, setDeviceMeta, clearDeviceMeta, getDeviceMeta, getMetaFilePath, } from '../devices/device-meta.js';
3
4
  export function registerDevicesMetaCommand(devices) {
@@ -9,10 +10,10 @@ export function registerDevicesMetaCommand(devices) {
9
10
  .command('set')
10
11
  .description('Set local metadata for a device (alias, hide/show, notes)')
11
12
  .argument('<deviceId>', 'Target device ID')
12
- .option('--alias <name>', 'Local alias for the device (used with --name flag)')
13
+ .option('--alias <name>', 'Local alias for the device (used with --name flag)', stringArg('--alias'))
13
14
  .option('--hide', 'Hide this device from "devices list"')
14
15
  .option('--show', 'Un-hide this device')
15
- .option('--notes <text>', 'Freeform notes shown in "devices describe"')
16
+ .option('--notes <text>', 'Freeform notes shown in "devices describe"', stringArg('--notes'))
16
17
  .action((deviceId, options) => {
17
18
  try {
18
19
  if (options.hide && options.show) {
@@ -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';
@@ -5,6 +6,7 @@ import { getCachedDevice } from '../devices/cache.js';
5
6
  import { loadDeviceMeta } from '../devices/device-meta.js';
6
7
  import { resolveDeviceId } from '../utils/name-resolver.js';
7
8
  import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, buildHubLocationMap, DeviceNotFoundError, } from '../lib/devices.js';
9
+ import { validateParameter } from '../devices/param-validator.js';
8
10
  import { registerBatchCommand } from './batch.js';
9
11
  import { registerWatchCommand } from './watch.js';
10
12
  import { registerExplainCommand } from './explain.js';
@@ -12,6 +14,7 @@ import { registerExpandCommand } from './expand.js';
12
14
  import { registerDevicesMetaCommand } from './device-meta.js';
13
15
  import { isDryRun } from '../utils/flags.js';
14
16
  export function registerDevicesCommand(program) {
17
+ const COMMAND_TYPES = ['command', 'customize'];
15
18
  const devices = program
16
19
  .command('devices')
17
20
  .description('Manage and control SwitchBot devices')
@@ -38,6 +41,7 @@ Run any subcommand with --help for its own flags and examples.
38
41
  // switchbot devices list
39
42
  devices
40
43
  .command('list')
44
+ .alias('ls')
41
45
  .description('List all physical devices and IR remote devices on the account')
42
46
  .addHelpText('after', `
43
47
  Default columns: deviceId, deviceName, type, category
@@ -72,7 +76,7 @@ Examples:
72
76
  `)
73
77
  .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
74
78
  .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)')
79
+ .option('--filter <expr>', 'Filter devices: "type=X", "name=X", "category=physical|ir", "room=X" (comma-separated key=value pairs)', stringArg('--filter'))
76
80
  .action(async (options) => {
77
81
  try {
78
82
  const body = await fetchDeviceList();
@@ -195,8 +199,8 @@ Examples:
195
199
  .command('status')
196
200
  .description('Query the real-time status of a specific device')
197
201
  .argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)')
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)')
202
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
203
+ .option('--ids <list>', 'Comma-separated device IDs for batch status (incompatible with --name)', stringArg('--ids'))
200
204
  .addHelpText('after', `
201
205
  Status fields vary by device type. To discover them without a live call:
202
206
 
@@ -287,10 +291,10 @@ Examples:
287
291
  .argument('[deviceId]', 'Target device ID (or use --name)')
288
292
  .argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
289
293
  .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).')
290
- .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
291
- .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', 'command')
294
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
295
+ .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
292
296
  .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.')
293
- .option('--idempotency-key <key>', 'Idempotency key for deduplication (60s window; same key replays cached result)')
297
+ .option('--idempotency-key <key>', 'Idempotency key for deduplication (60s window; same key replays cached result)', stringArg('--idempotency-key'))
294
298
  .addHelpText('after', `
295
299
  ────────────────────────────────────────────────────────────────────────
296
300
  For the full list of commands a specific device supports — and their
@@ -338,19 +342,22 @@ Examples:
338
342
  `)
339
343
  .action(async (deviceIdArg, cmdArg, parameter, options) => {
340
344
  try {
341
- // BUG-FIX: When --name is provided, Commander misassigns the first positional
342
- // to [deviceId] instead of [cmd]. Detect and shift positionals accordingly.
345
+ // BUG-FIX: When --name is provided, Commander fills positionals left-to-right
346
+ // starting at [deviceId]. Shift them back to their semantic slots.
343
347
  let cmd;
344
348
  let effectiveDeviceIdArg;
345
349
  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
+ // `--name "x" <cmd> [parameter]` → Commander binds deviceIdArg=<cmd>, cmdArg=[parameter].
351
+ if (!deviceIdArg) {
350
352
  throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
351
353
  }
352
- // --name "x" turnOn → deviceIdArg="turnOn", cmdArg=undefined → shift
353
- cmd = (deviceIdArg ?? cmdArg);
354
+ cmd = deviceIdArg;
355
+ if (cmdArg !== undefined) {
356
+ if (parameter !== undefined) {
357
+ throw new UsageError('Too many positional arguments after --name. Expected: --name <query> <cmd> [parameter].');
358
+ }
359
+ parameter = cmdArg;
360
+ }
354
361
  effectiveDeviceIdArg = undefined;
355
362
  }
356
363
  else {
@@ -361,6 +368,9 @@ Examples:
361
368
  effectiveDeviceIdArg = deviceIdArg;
362
369
  }
363
370
  const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
371
+ if (!getCachedDevice(deviceId)) {
372
+ console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`);
373
+ }
364
374
  const validation = validateCommand(deviceId, cmd, parameter, options.type);
365
375
  if (!validation.ok) {
366
376
  const err = validation.error;
@@ -385,6 +395,37 @@ Examples:
385
395
  }
386
396
  process.exit(2);
387
397
  }
398
+ // Case-only mismatch: emit a warning and continue with the canonical name.
399
+ if (validation.caseNormalizedFrom && validation.normalized) {
400
+ console.error(`Note: '${validation.caseNormalizedFrom}' normalized to '${validation.normalized}' (case mismatch). Use exact casing to silence this warning.`);
401
+ cmd = validation.normalized;
402
+ }
403
+ else if (validation.normalized) {
404
+ cmd = validation.normalized;
405
+ }
406
+ // Raw-parameter validation (runs for known (deviceType, command) pairs only).
407
+ const cachedForParam = getCachedDevice(deviceId);
408
+ if (cachedForParam && options.type === 'command') {
409
+ const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
410
+ if (!paramCheck.ok) {
411
+ if (isJsonMode()) {
412
+ console.error(JSON.stringify({
413
+ error: {
414
+ code: 2,
415
+ kind: 'usage',
416
+ message: paramCheck.error,
417
+ context: { command: cmd, deviceType: cachedForParam.type, deviceId },
418
+ },
419
+ }));
420
+ }
421
+ else {
422
+ console.error(`Error: ${paramCheck.error}`);
423
+ }
424
+ process.exit(2);
425
+ }
426
+ if (paramCheck.normalized !== undefined)
427
+ parameter = paramCheck.normalized;
428
+ }
388
429
  const cachedForGuard = getCachedDevice(deviceId);
389
430
  if (!options.yes &&
390
431
  !isDryRun() &&
@@ -539,7 +580,7 @@ Examples:
539
580
  .command('describe')
540
581
  .description('Describe a device by ID: metadata + supported commands + status fields (1 API call)')
541
582
  .argument('[deviceId]', 'Target device ID (or use --name)')
542
- .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
583
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
543
584
  .option('--live', 'Also fetch live status values and merge them into capabilities (costs 1 extra API call)')
544
585
  .addHelpText('after', `
545
586
  Makes a GET /v1.1/devices call to look up the device's type, then prints its
@@ -1,5 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
3
+ import { intArg, stringArg } from '../utils/arg-parsers.js';
3
4
  import { SwitchBotMqttClient } from '../mqtt/client.js';
4
5
  import { fetchMqttCredential } from '../mqtt/credential.js';
5
6
  import { tryLoadConfig } from '../config.js';
@@ -118,10 +119,10 @@ export function registerEventsCommand(program) {
118
119
  events
119
120
  .command('tail')
120
121
  .description('Run a local HTTP receiver and print incoming webhook events as JSONL')
121
- .option('--port <n>', `Local port to listen on (default ${DEFAULT_PORT})`, String(DEFAULT_PORT))
122
- .option('--path <p>', `HTTP path to match (default "${DEFAULT_PATH}"; use "*" for all paths)`, DEFAULT_PATH)
123
- .option('--filter <expr>', 'Filter events, e.g. "deviceId=ABC123" or "type=Bot" (comma-separated)')
124
- .option('--max <n>', 'Stop after N matching events (default: run until Ctrl-C)')
122
+ .option('--port <n>', `Local port to listen on (default ${DEFAULT_PORT})`, intArg('--port', { min: 1, max: 65535 }), String(DEFAULT_PORT))
123
+ .option('--path <p>', `HTTP path to match (default "${DEFAULT_PATH}"; use "*" for all paths)`, stringArg('--path'), DEFAULT_PATH)
124
+ .option('--filter <expr>', 'Filter events, e.g. "deviceId=ABC123" or "type=Bot" (comma-separated)', stringArg('--filter'))
125
+ .option('--max <n>', 'Stop after N matching events (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
125
126
  .addHelpText('after', `
126
127
  SwitchBot posts events to a single webhook URL configured via:
127
128
  $ switchbot webhook setup https://<your-public-host>/<path>
@@ -199,20 +200,20 @@ Examples:
199
200
  events
200
201
  .command('mqtt-tail')
201
202
  .description('Subscribe to SwitchBot MQTT shadow events and stream them as JSONL')
202
- .option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)')
203
- .option('--max <n>', 'Stop after N events (default: run until Ctrl-C)')
203
+ .option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)', stringArg('--topic'))
204
+ .option('--max <n>', 'Stop after N events (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
204
205
  .option('--sink <type>', 'Output sink: stdout (default), file, webhook, openclaw, telegram, homeassistant (repeatable)', (val, prev) => [...prev, val], [])
205
- .option('--sink-file <path>', 'File path for file sink')
206
- .option('--webhook-url <url>', 'Webhook URL for webhook sink')
207
- .option('--openclaw-url <url>', 'OpenClaw gateway URL (default: http://localhost:18789)')
208
- .option('--openclaw-token <token>', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)')
209
- .option('--openclaw-model <id>', 'OpenClaw agent model ID to route events to')
210
- .option('--telegram-token <token>', 'Telegram bot token (or env TELEGRAM_TOKEN)')
211
- .option('--telegram-chat <id>', 'Telegram chat/channel ID to send messages to')
212
- .option('--ha-url <url>', 'Home Assistant base URL (e.g. http://homeassistant.local:8123)')
213
- .option('--ha-token <token>', 'HA long-lived access token (for REST event API)')
214
- .option('--ha-webhook-id <id>', 'HA webhook ID (no auth; takes priority over --ha-token)')
215
- .option('--ha-event-type <type>', 'HA event type for REST API (default: switchbot_event)')
206
+ .option('--sink-file <path>', 'File path for file sink', stringArg('--sink-file'))
207
+ .option('--webhook-url <url>', 'Webhook URL for webhook sink', stringArg('--webhook-url'))
208
+ .option('--openclaw-url <url>', 'OpenClaw gateway URL (default: http://localhost:18789)', stringArg('--openclaw-url'))
209
+ .option('--openclaw-token <token>', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)', stringArg('--openclaw-token'))
210
+ .option('--openclaw-model <id>', 'OpenClaw agent model ID to route events to', stringArg('--openclaw-model'))
211
+ .option('--telegram-token <token>', 'Telegram bot token (or env TELEGRAM_TOKEN)', stringArg('--telegram-token'))
212
+ .option('--telegram-chat <id>', 'Telegram chat/channel ID to send messages to', stringArg('--telegram-chat'))
213
+ .option('--ha-url <url>', 'Home Assistant base URL (e.g. http://homeassistant.local:8123)', stringArg('--ha-url'))
214
+ .option('--ha-token <token>', 'HA long-lived access token (for REST event API)', stringArg('--ha-token'))
215
+ .option('--ha-webhook-id <id>', 'HA webhook ID (no auth; takes priority over --ha-token)', stringArg('--ha-webhook-id'))
216
+ .option('--ha-event-type <type>', 'HA event type for REST API (default: switchbot_event)', stringArg('--ha-event-type'))
216
217
  .addHelpText('after', `
217
218
  Connects to the SwitchBot MQTT service using your existing credentials
218
219
  (SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json).
@@ -343,10 +344,18 @@ Examples:
343
344
  ac.abort();
344
345
  }
345
346
  });
347
+ let mqttFailed = false;
346
348
  const unsubState = client.onStateChange((state) => {
347
349
  if (!isJsonMode()) {
348
350
  console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`);
349
351
  }
352
+ if (state === 'failed') {
353
+ mqttFailed = true;
354
+ if (!isJsonMode()) {
355
+ console.error('MQTT connection failed permanently (credential expired or reconnect exhausted) — exiting.');
356
+ }
357
+ ac.abort();
358
+ }
350
359
  });
351
360
  await client.connect();
352
361
  client.subscribe(topic);
@@ -366,6 +375,10 @@ Examples:
366
375
  process.once('SIGTERM', cleanup);
367
376
  ac.signal.addEventListener('abort', cleanup, { once: true });
368
377
  });
378
+ if (mqttFailed) {
379
+ // Surface as a runtime error so supervisors (pm2, systemd) can restart.
380
+ process.exit(1);
381
+ }
369
382
  }
370
383
  catch (error) {
371
384
  handleError(error);