@switchbot/openapi-cli 2.2.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
 
@@ -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';
@@ -12,6 +13,7 @@ import { registerExpandCommand } from './expand.js';
12
13
  import { registerDevicesMetaCommand } from './device-meta.js';
13
14
  import { isDryRun } from '../utils/flags.js';
14
15
  export function registerDevicesCommand(program) {
16
+ const COMMAND_TYPES = ['command', 'customize'];
15
17
  const devices = program
16
18
  .command('devices')
17
19
  .description('Manage and control SwitchBot devices')
@@ -38,6 +40,7 @@ Run any subcommand with --help for its own flags and examples.
38
40
  // switchbot devices list
39
41
  devices
40
42
  .command('list')
43
+ .alias('ls')
41
44
  .description('List all physical devices and IR remote devices on the account')
42
45
  .addHelpText('after', `
43
46
  Default columns: deviceId, deviceName, type, category
@@ -72,7 +75,7 @@ Examples:
72
75
  `)
73
76
  .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
74
77
  .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)')
78
+ .option('--filter <expr>', 'Filter devices: "type=X", "name=X", "category=physical|ir", "room=X" (comma-separated key=value pairs)', stringArg('--filter'))
76
79
  .action(async (options) => {
77
80
  try {
78
81
  const body = await fetchDeviceList();
@@ -195,8 +198,8 @@ Examples:
195
198
  .command('status')
196
199
  .description('Query the real-time status of a specific device')
197
200
  .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)')
201
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
202
+ .option('--ids <list>', 'Comma-separated device IDs for batch status (incompatible with --name)', stringArg('--ids'))
200
203
  .addHelpText('after', `
201
204
  Status fields vary by device type. To discover them without a live call:
202
205
 
@@ -287,10 +290,10 @@ Examples:
287
290
  .argument('[deviceId]', 'Target device ID (or use --name)')
288
291
  .argument('[cmd]', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean')
289
292
  .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')
293
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
294
+ .option('--type <commandType>', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
292
295
  .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)')
296
+ .option('--idempotency-key <key>', 'Idempotency key for deduplication (60s window; same key replays cached result)', stringArg('--idempotency-key'))
294
297
  .addHelpText('after', `
295
298
  ────────────────────────────────────────────────────────────────────────
296
299
  For the full list of commands a specific device supports — and their
@@ -361,6 +364,9 @@ Examples:
361
364
  effectiveDeviceIdArg = deviceIdArg;
362
365
  }
363
366
  const deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
367
+ if (!getCachedDevice(deviceId)) {
368
+ console.error(`Note: device ${deviceId} is not in the local cache — run 'switchbot devices list' first to enable command validation.`);
369
+ }
364
370
  const validation = validateCommand(deviceId, cmd, parameter, options.type);
365
371
  if (!validation.ok) {
366
372
  const err = validation.error;
@@ -539,7 +545,7 @@ Examples:
539
545
  .command('describe')
540
546
  .description('Describe a device by ID: metadata + supported commands + status fields (1 API call)')
541
547
  .argument('[deviceId]', 'Target device ID (or use --name)')
542
- .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
548
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
543
549
  .option('--live', 'Also fetch live status values and merge them into capabilities (costs 1 extra API call)')
544
550
  .addHelpText('after', `
545
551
  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);
@@ -1,3 +1,4 @@
1
+ import { intArg, stringArg } from '../utils/arg-parsers.js';
1
2
  import { handleError, isJsonMode, printJson, UsageError } from '../utils/output.js';
2
3
  import { getCachedDevice } from '../devices/cache.js';
3
4
  import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js';
@@ -88,15 +89,15 @@ export function registerExpandCommand(devices) {
88
89
  .description('Send a command with semantic flags instead of raw positional parameters')
89
90
  .argument('[deviceId]', 'Target device ID from "devices list" (or use --name)')
90
91
  .argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)')
91
- .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
92
- .option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)')
93
- .option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary')
94
- .option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high')
95
- .option('--power <state>', 'AC setAll: on|off')
96
- .option('--position <percent>', 'Curtain setPosition: 0-100 (0=open, 100=closed)')
97
- .option('--direction <dir>', 'Blind Tilt setPosition: up|down')
98
- .option('--angle <percent>', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)')
99
- .option('--channel <n>', 'Relay Switch 2 setMode: channel 1 or 2')
92
+ .option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
93
+ .option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)', intArg('--temp', { min: 16, max: 30 }))
94
+ .option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary', stringArg('--mode'))
95
+ .option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high', stringArg('--fan'))
96
+ .option('--power <state>', 'AC setAll: on|off', stringArg('--power'))
97
+ .option('--position <percent>', 'Curtain setPosition: 0-100 (0=open, 100=closed)', intArg('--position', { min: 0, max: 100 }))
98
+ .option('--direction <dir>', 'Blind Tilt setPosition: up|down', stringArg('--direction'))
99
+ .option('--angle <percent>', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)', intArg('--angle', { min: 0, max: 100 }))
100
+ .option('--channel <n>', 'Relay Switch 2 setMode: channel 1 or 2', intArg('--channel', { min: 1, max: 2 }))
100
101
  .option('--yes', 'Confirm destructive commands')
101
102
  .addHelpText('after', `
102
103
  Translates semantic flags into the wire parameter format, then sends the command.
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
+ import { intArg, stringArg } from '../utils/arg-parsers.js';
3
4
  import { printJson, isJsonMode, handleError } from '../utils/output.js';
4
5
  import { readAudit } from '../utils/audit.js';
5
6
  import { executeCommand } from '../lib/devices.js';
@@ -21,8 +22,8 @@ Examples:
21
22
  history
22
23
  .command('show')
23
24
  .description('Print recent audit entries')
24
- .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`)
25
- .option('--limit <n>', 'Show only the last N entries')
25
+ .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
26
+ .option('--limit <n>', 'Show only the last N entries', intArg('--limit', { min: 1 }))
26
27
  .action((options) => {
27
28
  const file = options.file ?? DEFAULT_AUDIT;
28
29
  const entries = readAudit(file);
@@ -52,7 +53,7 @@ Examples:
52
53
  .command('replay')
53
54
  .description('Re-run a recorded command by its 1-indexed position')
54
55
  .argument('<index>', 'Entry index (1 = oldest; as shown by "history show")')
55
- .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`)
56
+ .option('--file <path>', `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg('--file'))
56
57
  .addHelpText('after', `
57
58
  Dry-run-honouring: pass --dry-run on the parent command to preview without
58
59
  sending the actual call. Errors from the recorded entry are NOT replayed —
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { z } from 'zod';
5
+ import { intArg, stringArg } from '../utils/arg-parsers.js';
5
6
  import { handleError, isJsonMode } from '../utils/output.js';
6
7
  import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
7
8
  import { fetchScenes, executeScene } from '../lib/scenes.js';
@@ -489,11 +490,11 @@ Inspect locally:
489
490
  mcp
490
491
  .command('serve')
491
492
  .description('Start the MCP server on stdio (default) or HTTP (--port)')
492
- .option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)')
493
- .option('--bind <host>', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', '127.0.0.1')
494
- .option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)')
495
- .option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)')
496
- .option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', '60')
493
+ .option('--port <n>', 'Listen on HTTP instead of stdio (Streamable HTTP transport)', intArg('--port', { min: 1, max: 65535 }))
494
+ .option('--bind <host>', 'IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)', stringArg('--bind'), '127.0.0.1')
495
+ .option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)', stringArg('--auth-token'))
496
+ .option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
497
+ .option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60')
497
498
  .action(async (options) => {
498
499
  try {
499
500
  if (options.port) {
@@ -1,3 +1,4 @@
1
+ import { enumArg, stringArg } from '../utils/arg-parsers.js';
1
2
  import { printJson } from '../utils/output.js';
2
3
  import { getEffectiveCatalog } from '../devices/catalog.js';
3
4
  function toSchemaEntry(e) {
@@ -24,15 +25,17 @@ function toSchemaCommand(c) {
24
25
  };
25
26
  }
26
27
  export function registerSchemaCommand(program) {
28
+ const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'];
29
+ const CATEGORIES = ['physical', 'ir'];
27
30
  const schema = program
28
31
  .command('schema')
29
32
  .description('Export the device catalog as structured JSON (for agent prompts / tooling)');
30
33
  schema
31
34
  .command('export')
32
35
  .description('Print the full catalog as structured JSON (one object per type)')
33
- .option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")')
34
- .option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other')
35
- .option('--category <cat>', 'Restrict to "physical" or "ir"')
36
+ .option('--type <type>', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type'))
37
+ .option('--role <role>', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES))
38
+ .option('--category <cat>', 'Restrict to "physical" or "ir"', enumArg('--category', CATEGORIES))
36
39
  .addHelpText('after', `
37
40
  Output is always JSON (this command ignores --format). The output is a
38
41
  catalog export — not a formal JSON Schema standard document — suitable for
@@ -2,6 +2,7 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.
2
2
  import { fetchDeviceStatus } from '../lib/devices.js';
3
3
  import { getCachedDevice } from '../devices/cache.js';
4
4
  import { parseDurationToMs, getFields } from '../utils/flags.js';
5
+ import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js';
5
6
  import { createClient } from '../api/client.js';
6
7
  import { resolveDeviceId } from '../utils/name-resolver.js';
7
8
  const DEFAULT_INTERVAL_MS = 30_000;
@@ -57,9 +58,9 @@ export function registerWatchCommand(devices) {
57
58
  .command('watch')
58
59
  .description('Poll device status on an interval and emit field-level changes (JSONL)')
59
60
  .argument('[deviceId...]', 'One or more deviceIds to watch (or use --name for one device)')
60
- .option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)')
61
- .option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, '30s')
62
- .option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)')
61
+ .option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)', stringArg('--name'))
62
+ .option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, durationArg('--interval'), '30s')
63
+ .option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)', intArg('--max', { min: 1 }))
63
64
  .option('--include-unchanged', 'Emit a tick even when no field changed')
64
65
  .addHelpText('after', `
65
66
  Each poll emits one JSON line per deviceId with the shape:
@@ -1,3 +1,4 @@
1
+ import { stringArg } from '../utils/arg-parsers.js';
1
2
  import { createClient } from '../api/client.js';
2
3
  import { printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
3
4
  import chalk from 'chalk';
@@ -54,7 +55,7 @@ Example:
54
55
  webhook
55
56
  .command('query')
56
57
  .description('Query webhook configuration')
57
- .option('--details <url>', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL')
58
+ .option('--details <url>', 'Query detailed configuration (enable/deviceList/timestamps) for a specific URL', stringArg('--details'))
58
59
  .addHelpText('after', `
59
60
  Without --details, lists all configured webhook URLs.
60
61
  With --details, prints enable/deviceList/createTime/lastUpdateTime for the given URL.
package/dist/config.js CHANGED
@@ -99,7 +99,7 @@ export function showConfig() {
99
99
  const envSecret = process.env.SWITCHBOT_SECRET;
100
100
  if (envToken && envSecret) {
101
101
  console.log('Credential source: environment variables');
102
- console.log(`token : ${envToken}`);
102
+ console.log(`token : ${maskCredential(envToken)}`);
103
103
  console.log(`secret: ${maskSecret(envSecret)}`);
104
104
  return;
105
105
  }
@@ -112,13 +112,18 @@ export function showConfig() {
112
112
  const raw = fs.readFileSync(file, 'utf-8');
113
113
  const cfg = JSON.parse(raw);
114
114
  console.log(`Credential source: ${file}`);
115
- console.log(`token : ${cfg.token}`);
115
+ console.log(`token : ${maskCredential(cfg.token)}`);
116
116
  console.log(`secret: ${maskSecret(cfg.secret)}`);
117
117
  }
118
118
  catch {
119
119
  console.error('Failed to read config file');
120
120
  }
121
121
  }
122
+ function maskCredential(token) {
123
+ if (token.length <= 8)
124
+ return '*'.repeat(Math.max(4, token.length));
125
+ return token.slice(0, 4) + '*'.repeat(token.length - 8) + token.slice(-4);
126
+ }
122
127
  function maskSecret(secret) {
123
128
  if (secret.length <= 4)
124
129
  return '****';
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { Command, CommanderError } from 'commander';
2
+ import { Command, CommanderError, InvalidArgumentError } from 'commander';
3
3
  import { createRequire } from 'node:module';
4
+ import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
5
+ import { parseDurationToMs } from './utils/flags.js';
4
6
  import { registerConfigCommand } from './commands/config.js';
5
7
  import { registerDevicesCommand } from './commands/devices.js';
6
8
  import { registerScenesCommand } from './commands/scenes.js';
@@ -19,26 +21,44 @@ import { registerCapabilitiesCommand } from './commands/capabilities.js';
19
21
  const require = createRequire(import.meta.url);
20
22
  const { version: pkgVersion } = require('../package.json');
21
23
  const program = new Command();
24
+ // Top-level subcommand names. Used by stringArg to produce clearer errors when
25
+ // a value is omitted and the next argv token turns out to be a subcommand name.
26
+ const TOP_LEVEL_COMMANDS = [
27
+ 'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp',
28
+ 'quota', 'catalog', 'cache', 'events', 'doctor', 'schema',
29
+ 'history', 'plan', 'capabilities',
30
+ ];
31
+ const cacheModeArg = (value) => {
32
+ if (value.startsWith('-')) {
33
+ throw new InvalidArgumentError(`--cache requires a mode value, got "${value}". ` +
34
+ `Valid: "off", "auto", or a duration like "5m", "1h". Use --cache=<mode> if needed.`);
35
+ }
36
+ if (value === 'off' || value === 'auto')
37
+ return value;
38
+ if (parseDurationToMs(value) !== null)
39
+ return value;
40
+ throw new InvalidArgumentError(`--cache must be "off", "auto", or a duration like "30s"/"5m"/"1h" (got "${value}")`);
41
+ };
22
42
  program
23
43
  .name('switchbot')
24
44
  .description('Command-line tool for SwitchBot API v1.1')
25
45
  .version(pkgVersion)
26
46
  .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
27
- .option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id')
28
- .option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)')
47
+ .option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id']))
48
+ .option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
29
49
  .option('-v, --verbose', 'Log HTTP request/response details to stderr')
30
50
  .option('--dry-run', 'Print mutating requests without sending them (GETs still execute)')
31
- .option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)')
32
- .option('--retry-on-429 <n>', 'Max 429 retries before surfacing the error (default: 3)')
33
- .option('--backoff <strategy>', 'Backoff strategy for retries: "linear" or "exponential" (default)')
51
+ .option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)', intArg('--timeout', { min: 1 }))
52
+ .option('--retry-on-429 <n>', 'Max 429 retries before surfacing the error (default: 3)', intArg('--retry-on-429', { min: 0 }))
53
+ .option('--backoff <strategy>', 'Backoff strategy for retries: "linear" or "exponential" (default)', enumArg('--backoff', ['linear', 'exponential']))
34
54
  .option('--no-retry', 'Disable 429 retries entirely (equivalent to --retry-on-429 0)')
35
55
  .option('--no-quota', 'Disable the local ~/.switchbot/quota.json counter for this run')
36
- .option('--cache <mode>', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)')
56
+ .option('--cache <mode>', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)', cacheModeArg)
37
57
  .option('--no-cache', 'Disable cache reads (equivalent to --cache off)')
38
- .option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)')
39
- .option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json')
58
+ .option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)', stringArg('--config', { disallow: TOP_LEVEL_COMMANDS }))
59
+ .option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json', stringArg('--profile', { disallow: TOP_LEVEL_COMMANDS }))
40
60
  .option('--audit-log', 'Append every mutating command to JSONL audit log (default path: ~/.switchbot/audit.log)')
41
- .option('--audit-log-path <path>', 'Custom audit log file path; use together with --audit-log')
61
+ .option('--audit-log-path <path>', 'Custom audit log file path; use together with --audit-log', stringArg('--audit-log-path', { disallow: TOP_LEVEL_COMMANDS }))
42
62
  .showHelpAfterError('(run with --help to see usage)')
43
63
  .showSuggestionAfterError();
44
64
  registerConfigCommand(program);
@@ -95,24 +115,33 @@ Discovery:
95
115
 
96
116
  Docs: https://github.com/OpenWonderLabs/SwitchBotAPI
97
117
  `);
98
- // Map commander usage errors (unknown option, missing argument, etc.) to exit code 2.
99
- program.exitOverride((err) => {
100
- // --help and --version print to stdout and exit 0
118
+ // Map commander usage errors (unknown option, missing argument, argParser
119
+ // InvalidArgumentError, etc.) to exit code 2. Commander's exitOverride is
120
+ // per-command: subcommand errors won't bubble to the root override, so walk
121
+ // every registered command and apply the same handler.
122
+ const usageExitHandler = (err) => {
101
123
  if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
102
124
  process.exit(0);
103
125
  }
104
- // Everything else from commander (unknown option, missing argument,
105
- // invalid choice, conflicting options, unknown command) is a usage error.
106
126
  process.exit(2);
107
- });
127
+ };
128
+ function applyExitOverride(cmd) {
129
+ cmd.exitOverride(usageExitHandler);
130
+ cmd.commands.forEach(applyExitOverride);
131
+ }
132
+ applyExitOverride(program);
108
133
  try {
109
134
  await program.parseAsync();
110
135
  }
111
136
  catch (err) {
112
- // exitOverride already handled CommanderErrors; anything that escapes is a
113
- // runtime error (should be rare since actions use handleError).
137
+ // Subcommand-level CommanderErrors (e.g. InvalidArgumentError from an
138
+ // argParser on a subcommand option) don't always hit the root exitOverride.
139
+ // Mirror the root mapping so all usage errors surface as exit 2.
114
140
  if (err instanceof CommanderError) {
115
- process.exit(err.exitCode ?? 2);
141
+ if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
142
+ process.exit(0);
143
+ }
144
+ process.exit(2);
116
145
  }
117
146
  throw err;
118
147
  }
@@ -0,0 +1,62 @@
1
+ import { InvalidArgumentError } from 'commander';
2
+ import { parseDurationToMs } from './flags.js';
3
+ /**
4
+ * Commander argParser callbacks that fail fast when a required-value flag
5
+ * swallows the next token (another flag, a subcommand name, etc.) — the
6
+ * default Commander behavior is to take the next argv token verbatim.
7
+ *
8
+ * Use `--flag=<val>` form to pass values that legitimately start with `--`.
9
+ */
10
+ export function intArg(flagName, opts) {
11
+ return (value) => {
12
+ if (value.startsWith('-')) {
13
+ throw new InvalidArgumentError(`${flagName} requires a numeric value, got "${value}". ` +
14
+ `Did you forget a value? Use ${flagName}=<n> if the value really starts with "-".`);
15
+ }
16
+ const n = Number(value);
17
+ if (!Number.isInteger(n)) {
18
+ throw new InvalidArgumentError(`${flagName} must be an integer (got "${value}")`);
19
+ }
20
+ if (opts?.min !== undefined && n < opts.min) {
21
+ throw new InvalidArgumentError(`${flagName} must be >= ${opts.min} (got "${value}")`);
22
+ }
23
+ if (opts?.max !== undefined && n > opts.max) {
24
+ throw new InvalidArgumentError(`${flagName} must be <= ${opts.max} (got "${value}")`);
25
+ }
26
+ return String(n);
27
+ };
28
+ }
29
+ export function durationArg(flagName) {
30
+ return (value) => {
31
+ if (value.startsWith('-')) {
32
+ throw new InvalidArgumentError(`${flagName} requires a duration value, got "${value}". ` +
33
+ `Use ${flagName}=<dur> if the value really starts with "-".`);
34
+ }
35
+ const ms = parseDurationToMs(value);
36
+ if (ms === null) {
37
+ throw new InvalidArgumentError(`${flagName} must look like "30s", "1m", "500ms", "1h" (got "${value}")`);
38
+ }
39
+ return value;
40
+ };
41
+ }
42
+ export function stringArg(flagName, opts) {
43
+ return (value) => {
44
+ if (value.startsWith('--')) {
45
+ throw new InvalidArgumentError(`${flagName} requires a value. "${value}" looks like another option — ` +
46
+ `did you forget the value? Use ${flagName}=<val> if your value really starts with "--".`);
47
+ }
48
+ if (opts?.disallow?.includes(value)) {
49
+ throw new InvalidArgumentError(`${flagName} requires a value but got "${value}", which is a subcommand name. ` +
50
+ `Did you forget the value? Use ${flagName}=<val> or put ${flagName} after the subcommand.`);
51
+ }
52
+ return value;
53
+ };
54
+ }
55
+ export function enumArg(flagName, allowed) {
56
+ return (value) => {
57
+ if (!allowed.includes(value)) {
58
+ throw new InvalidArgumentError(`${flagName} must be one of: ${allowed.join(', ')} (got "${value}")`);
59
+ }
60
+ return value;
61
+ };
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchbot/openapi-cli",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
5
5
  "keywords": [
6
6
  "switchbot",